Coverage for src/backoffice/index.py: 0%
131 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 08:10 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 08:10 +0000
1"""Data models and functions for indexing the bioimage.io collection"""
3from __future__ import annotations
5import hashlib
6import json
7import shutil
8from collections import defaultdict
9from collections.abc import Sequence
10from datetime import datetime
11from pathlib import Path
13try:
14 import httpx
15 from loguru import logger
16 from pydantic import BaseModel, Field
18 from backoffice._settings import settings
19 from backoffice.compatibility import InitialSummary
20 from backoffice.utils import (
21 get_summary,
22 get_summary_file_path,
23 yaml,
24 )
25except ImportError as e:
26 raise ImportError(
27 "Missing dependencies. "
28 "Please install `backoffice[dev]` to use backoffice.index."
29 ) from e
32from .utils_pure import get_report_path
35class Node(BaseModel, frozen=True, extra="ignore"):
36 pass
39class ResponseItemVersion(Node, frozen=True):
40 version: str
41 comment: str | None
42 created_at: datetime
45class IndexItemVersion(Node, frozen=True):
46 version: str
47 comment: str | None
48 created_at: datetime
49 source: str
50 sha256: str
53class ResponseItem(Node, frozen=True):
54 id: str
55 versions: Sequence[ResponseItemVersion]
56 type: str
59class IndexItem(Node, frozen=True):
60 id: str
61 versions: Sequence[IndexItemVersion]
62 type: str
65class Response(Node, frozen=True):
66 """Response from Hypha list endpoint"""
68 items: list[ResponseItem]
69 total: int
70 offset: int
71 limit: int
74class Index(Node, frozen=True):
75 items: list[IndexItem]
76 total: int
77 count_per_type: dict[str, int]
78 timestamp: datetime = Field(default_factory=datetime.now)
81def load_index(path: Path = Path("index.json")) -> Index:
82 logger.info("loading index from {}", path)
83 return Index.model_validate_json(path.read_text(encoding="utf-8"))
86def create_index() -> Index:
87 """Index the bioimage.io collection"""
89 index_path = Path("index.json")
90 if index_path.exists():
91 index = load_index(index_path)
92 else:
93 url = f"{settings.hypha_base_url}/public/services/artifact-manager/list"
95 def request(offset: int) -> Response:
96 r = httpx.get(
97 url,
98 params={
99 "parent_id": "bioimage-io/bioimage.io",
100 "offset": offset,
101 "pagination": True,
102 "limit": 10000,
103 },
104 headers=settings.get_hypha_headers(),
105 timeout=settings.http_timeout,
106 )
107 try:
108 _ = r.raise_for_status()
109 except Exception:
110 logger.error(r.json())
111 raise
112 else:
113 return Response.model_validate_json(r.content)
115 items: list[ResponseItem] = []
116 for page in range(100):
117 response = request(len(items))
118 logger.info("Page {}: {} entries", page, len(response.items))
119 items.extend(response.items)
120 if response.total <= len(items):
121 if response.total != len(items):
122 logger.error(
123 "response.total {} != len(items) {}", response.total, len(items)
124 )
125 break
127 index_items: list[IndexItem] = []
128 for item in items:
129 domain, item_id_wo_domain = item.id.split("/", 1)
130 try:
131 item_id = item.id
132 item_type = item.type
133 versions: list[IndexItemVersion] = []
134 for v in item.versions:
135 bioimageio_yaml_base_url = f"{settings.hypha_base_url}/{domain}/artifacts/{item_id_wo_domain}/files/{{bioimageio_yaml}}?version={v.version}"
137 def get_bioimageio_yaml_url(
138 base_url: str, bioimageio_yaml: str
139 ) -> str:
140 return base_url.format(bioimageio_yaml=bioimageio_yaml)
142 url = get_bioimageio_yaml_url(
143 bioimageio_yaml_base_url, "bioimageio.yaml"
144 )
145 try:
146 sha256 = _initialize_report_directory(item, v, url)
147 except Exception:
148 url = get_bioimageio_yaml_url(
149 bioimageio_yaml_base_url, "rdf.yaml"
150 )
151 sha256 = _initialize_report_directory(item, v, url)
153 versions.append(
154 IndexItemVersion(
155 version=v.version,
156 comment=v.comment,
157 created_at=v.created_at,
158 source=url,
159 sha256=sha256,
160 )
161 )
162 index_items.append(
163 IndexItem(id=item_id, versions=versions, type=item_type)
164 )
165 except Exception as e:
166 logger.error(
167 "Error processing index item {}: {}. Skipping this item.",
168 item,
169 str(e),
170 )
172 count_per_type = defaultdict[str, int](int)
173 for item in index_items:
174 count_per_type[item.type] += 1
176 index = Index(
177 items=index_items,
178 total=len(index_items),
179 count_per_type=dict(count_per_type),
180 )
182 json_dict = index.model_dump(mode="json")
183 with index_path.open("wt", encoding="utf-8") as f:
184 json.dump(json_dict, f, indent=4, sort_keys=True, ensure_ascii=False)
185 # TODO: use .model_dump_json once it supports 'sort_keys' argument for a potential speed gain
186 # _ = index_path.write_text(index.model_dump_json(indent=4), encoding="utf-8")
188 logger.info("saved index to {}", index_path)
190 logger.info(
191 "loaded index with {} ids and {} versions",
192 len(index.items),
193 sum(len(item.versions) for item in index.items),
194 )
195 return index
198def _initialize_report_directory(
199 item: ResponseItem, v: ResponseItemVersion, url: str
200) -> str:
201 """Initialize the report directory for an item version.
203 Returns sha256 of the rdf.yaml file."""
204 report_path = get_report_path(item.id, v.version)
205 r = httpx.get(url, follow_redirects=True, timeout=settings.http_timeout)
206 _ = r.raise_for_status()
207 data = r.content
208 sha256 = hashlib.sha256(data).hexdigest()
210 summary = get_summary(item.id, v.version)
211 existing_sha256 = summary.rdf_yaml_sha256
212 if existing_sha256 == sha256:
213 logger.info(
214 "Found existing summary for {}/{} with matching RDF SHA-256: {}",
215 item.id,
216 v.version,
217 sha256,
218 )
219 return sha256
220 else:
221 if existing_sha256:
222 logger.warning(
223 "Found existing summary for {}/{} with different RDF SHA-256: {} != {}. deleting and replacing...",
224 item.id,
225 v.version,
226 existing_sha256,
227 sha256,
228 )
229 if report_path.exists():
230 shutil.rmtree(report_path)
232 report_path.mkdir(parents=True, exist_ok=True)
233 try:
234 rdf_content = yaml.load(data)
235 except Exception as e:
236 rdf_content = {"error": str(e)}
238 summary = InitialSummary(
239 rdf_content=rdf_content,
240 rdf_yaml_sha256=sha256,
241 status="untested",
242 )
243 summary_path = get_summary_file_path(item.id, v.version)
244 _ = summary_path.write_text(summary.model_dump_json(indent=4), encoding="utf-8")
245 logger.info("Initialized report directory {}", report_path)
246 return sha256
249if __name__ == "__main__":
250 _ = create_index()