Coverage for src/bioimageio/spec/_internal/io_utils.py: 76%

190 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-18 09:17 +0000

1from __future__ import annotations 

2 

3import collections.abc 

4import io 

5import shutil 

6import zipfile 

7from contextlib import nullcontext 

8from difflib import get_close_matches 

9from pathlib import Path 

10from types import MappingProxyType 

11from typing import IO, Any, Dict, Mapping, cast 

12from zipfile import ZipFile 

13 

14import httpx 

15import numpy 

16from loguru import logger 

17from numpy.typing import NDArray 

18from pydantic import BaseModel, FilePath, NewPath, RootModel 

19from ruyaml import YAML 

20from typing_extensions import Unpack 

21 

22from ._settings import settings 

23from .io import ( 

24 BIOIMAGEIO_YAML, 

25 BioimageioYamlContent, 

26 BioimageioYamlContentView, 

27 BytesReader, 

28 FileDescr, 

29 HashKwargs, 

30 LightHttpFileDescr, 

31 OpenedBioimageioYaml, 

32 RelativeFilePath, 

33 YamlValue, 

34 extract_file_name, 

35 find_bioimageio_yaml_file_name, 

36 get_reader, 

37 identify_bioimageio_yaml_file_name, 

38 interprete_file_source, 

39) 

40from .io_basics import AbsoluteDirectory, FileName, ZipPath 

41from .types import PermissiveFileSource 

42from .url import HttpUrl, RootHttpUrl 

43from .utils import cache 

44from .validation_context import ValidationContext, get_validation_context 

45 

46_yaml_load = YAML(typ="safe") 

47 

48_yaml_dump = YAML() 

49_yaml_dump.version = (1, 2) # pyright: ignore[reportAttributeAccessIssue] 

50_yaml_dump.default_flow_style = False 

51_yaml_dump.indent(mapping=2, sequence=4, offset=2) 

52_yaml_dump.width = 88 # pyright: ignore[reportAttributeAccessIssue] 

53 

54 

55def read_yaml( 

56 file: FilePath | ZipPath | IO[str] | IO[bytes] | BytesReader | str, 

57) -> YamlValue: 

58 if isinstance(file, (ZipPath, Path)): 

59 data = file.read_text(encoding="utf-8") 

60 else: 

61 data = file 

62 

63 content: YamlValue = _yaml_load.load(data) 

64 return content 

65 

66 

67def write_yaml( 

68 content: YamlValue | BioimageioYamlContentView | BaseModel, 

69 /, 

70 file: NewPath | FilePath | IO[str] | IO[bytes] | ZipPath, 

71): 

72 if isinstance(file, Path): 

73 cm = file.open("w", encoding="utf-8") 

74 else: 

75 cm = nullcontext(file) 

76 

77 if isinstance(content, BaseModel): 

78 content = content.model_dump(mode="json") 

79 

80 with cm as f: 

81 _yaml_dump.dump(content, f) 

82 

83 

84def _sanitize_bioimageio_yaml(content: YamlValue) -> BioimageioYamlContent: 

85 if not isinstance(content, dict): 

86 raise ValueError( 

87 f"Expected {BIOIMAGEIO_YAML} content to be a mapping (got {type(content)})." 

88 ) 

89 

90 for key in content: 

91 if not isinstance(key, str): 

92 raise ValueError( 

93 f"Expected all keys (field names) in a {BIOIMAGEIO_YAML} " 

94 + f"to be strings (got '{key}' of type {type(key)})." 

95 ) 

96 

97 return cast(BioimageioYamlContent, content) 

98 

99 

100def _open_bioimageio_rdf_in_zip( 

101 path: ZipPath, 

102 *, 

103 original_root: AbsoluteDirectory | RootHttpUrl | ZipFile, 

104 original_source_name: str, 

105) -> OpenedBioimageioYaml: 

106 with path.open("rb") as f: 

107 assert not isinstance(f, io.TextIOWrapper) 

108 unparsed_content = f.read().decode(encoding="utf-8") 

109 

110 content = _sanitize_bioimageio_yaml(read_yaml(io.StringIO(unparsed_content))) 

111 

112 return OpenedBioimageioYaml( 

113 content, 

114 original_root=original_root, 

115 original_file_name=extract_file_name(path), 

116 original_source_name=original_source_name, 

117 unparsed_content=unparsed_content, 

118 ) 

119 

120 

121def _open_bioimageio_zip( 

122 source: ZipFile, 

123 *, 

124 original_source_name: str, 

125) -> OpenedBioimageioYaml: 

126 rdf_name = identify_bioimageio_yaml_file_name( 

127 [info.filename for info in source.filelist] 

128 ) 

129 return _open_bioimageio_rdf_in_zip( 

130 ZipPath(source, rdf_name), 

131 original_root=source, 

132 original_source_name=original_source_name, 

133 ) 

134 

135 

136def open_bioimageio_yaml( 

137 source: PermissiveFileSource | ZipFile | ZipPath, 

138 /, 

139 **kwargs: Unpack[HashKwargs], 

140) -> OpenedBioimageioYaml: 

141 if ( 

142 isinstance(source, str) 

143 and source.startswith("huggingface/") 

144 and source.count("/") >= 2 

145 ): 

146 if source.count("/") == 2: 

147 # huggingface/{user_or_org}/{repo_name} 

148 repo_id = source[len("huggingface/") :] 

149 branch = "main" 

150 else: 

151 # huggingface/{user_or_org}/{repo_id}/ 

152 # huggingface/{user_or_org}/{repo_id}/version 

153 repo_id, version = source[len("huggingface/") :].rsplit("/", 1) 

154 if len(version) == 0: 

155 branch = "main" 

156 elif version[0].isdigit(): 

157 branch = f"v{version}" 

158 else: 

159 branch = version 

160 

161 source = HttpUrl( 

162 settings.huggingface_http_pattern.format(repo_id=repo_id, branch=branch) 

163 ) 

164 

165 if isinstance(source, RelativeFilePath): 

166 source = source.absolute() 

167 

168 if isinstance(source, ZipFile): 

169 return _open_bioimageio_zip(source, original_source_name=str(source)) 

170 elif isinstance(source, ZipPath): 

171 return _open_bioimageio_rdf_in_zip( 

172 source, original_root=source.root, original_source_name=str(source) 

173 ) 

174 

175 try: 

176 if isinstance(source, (FileDescr, ZipPath)): 

177 src = source 

178 elif isinstance(source, (Path, str)) and (source_dir := Path(source)).is_dir(): 

179 # open bioimageio yaml from a folder 

180 src = source_dir / find_bioimageio_yaml_file_name(source_dir) 

181 else: 

182 src = interprete_file_source(source) 

183 

184 reader = get_reader(src, **kwargs) 

185 

186 except Exception as e: 

187 # check if `source` is a collection id 

188 if not isinstance(source, str): 

189 raise 

190 

191 if settings.collection_http_pattern: 

192 with ValidationContext(perform_io_checks=False): 

193 url = HttpUrl( 

194 settings.collection_http_pattern.format(bioimageio_id=source) 

195 ) 

196 

197 try: 

198 r = httpx.get(url, follow_redirects=True) 

199 _ = r.raise_for_status() 

200 unparsed_content = r.content.decode(encoding="utf-8") 

201 content = _sanitize_bioimageio_yaml(read_yaml(unparsed_content)) 

202 except Exception as e_coll_pattern: 

203 collection_pattern_error_msg = f"BIOIMAGEIO_COLLECTION_HTTP_PATTERN: Failed to get bioimageio.yaml from {url}: {e_coll_pattern}" 

204 logger.warning(collection_pattern_error_msg) 

205 collection_pattern_error_msg = "\n" + collection_pattern_error_msg 

206 else: 

207 logger.info("loaded {} from {}", source, url) 

208 original_file_name = ( 

209 "rdf.yaml" if url.path is None else url.path.split("/")[-1] 

210 ) 

211 return OpenedBioimageioYaml( 

212 content=content, 

213 original_root=url.parent, 

214 original_file_name=original_file_name, 

215 original_source_name=url, 

216 unparsed_content=unparsed_content, 

217 ) 

218 else: 

219 collection_pattern_error_msg = "" 

220 

221 if not isinstance(settings.id_map, str) or "/" not in settings.id_map: 

222 raise ValueError( 

223 f"BIOIMAGEIO_ID_MAP: Invalid id map url {settings.id_map}.{collection_pattern_error_msg}" 

224 ) from e 

225 

226 id_map = get_id_map() 

227 if not id_map: 

228 raise ValueError( 

229 f"BIOIMAGEIO_ID_MAP: Empty (or unavailable) id map from {settings.id_map}.{collection_pattern_error_msg}" 

230 ) from e 

231 

232 if id_map and source not in id_map: 

233 close_matches = get_close_matches(source, id_map) 

234 if len(close_matches) == 0: 

235 raise ValueError( 

236 f"BIOIMAGEIO_ID_MAP: '{source}' not found in {settings.id_map}.{collection_pattern_error_msg}" 

237 ) from e 

238 

239 if len(close_matches) == 1: 

240 did_you_mean = f" Did you mean '{close_matches[0]}'?" 

241 else: 

242 did_you_mean = f" Did you mean any of {close_matches}?" 

243 

244 raise ValueError( 

245 f"BIOIMAGEIO_ID_MAP: '{source}' not found in {settings.id_map}.{did_you_mean}{collection_pattern_error_msg}" 

246 ) from e 

247 

248 entry = id_map[source] 

249 logger.info("loading {} from {}", source, entry.source) 

250 reader = entry.get_reader() 

251 with get_validation_context().replace(perform_io_checks=False): 

252 src = HttpUrl(entry.source) 

253 

254 if reader.is_zipfile: 

255 return _open_bioimageio_zip(ZipFile(reader), original_source_name=str(src)) 

256 

257 unparsed_content = reader.read().decode(encoding="utf-8") 

258 content = _sanitize_bioimageio_yaml(read_yaml(unparsed_content)) 

259 

260 if isinstance(src, RelativeFilePath): 

261 src = src.absolute() 

262 

263 if isinstance(src, ZipPath): 

264 root = src.root 

265 elif isinstance(src, FileDescr): 

266 file_source = src.source.absolute() 

267 if isinstance(file_source, ZipPath): 

268 root = file_source.root 

269 else: 

270 root = file_source.parent 

271 else: 

272 root = src.parent 

273 

274 return OpenedBioimageioYaml( 

275 content, 

276 original_root=root, 

277 original_source_name=str(src), 

278 original_file_name=extract_file_name(src), 

279 unparsed_content=unparsed_content, 

280 ) 

281 

282 

283_IdMap = RootModel[Dict[str, LightHttpFileDescr]] 

284 

285 

286def _get_id_map_impl(url: str) -> dict[str, LightHttpFileDescr]: 

287 if not isinstance(url, str) or "/" not in url: 

288 logger.opt(depth=1).error("invalid id map url: {}", url) 

289 try: 

290 id_map_raw: Any = httpx.get( 

291 url, timeout=settings.http_timeout, follow_redirects=True 

292 ).json() 

293 except Exception as e: 

294 logger.opt(depth=1).error("failed to get {}: {}", url, e) 

295 return {} 

296 

297 id_map = _IdMap.model_validate(id_map_raw) 

298 return id_map.root 

299 

300 

301@cache 

302def get_id_map() -> Mapping[str, LightHttpFileDescr]: 

303 try: 

304 if settings.resolve_draft: 

305 ret = _get_id_map_impl(settings.id_map_draft) 

306 else: 

307 ret = {} 

308 

309 ret.update(_get_id_map_impl(settings.id_map)) 

310 

311 except Exception as e: 

312 logger.error("failed to get resource id map: {}", e) 

313 ret = {} 

314 

315 return MappingProxyType(ret) 

316 

317 

318def write_content_to_zip( 

319 content: Mapping[ 

320 FileName, 

321 str | FilePath | ZipPath | BioimageioYamlContentView | FileDescr | BytesReader, 

322 ], 

323 zip: zipfile.ZipFile, 

324): 

325 """write strings as text, dictionaries as yaml and files to a ZipFile 

326 Args: 

327 content: dict mapping archive names to local file paths, 

328 strings (for text files), or dict (for yaml files). 

329 zip: ZipFile 

330 """ 

331 for arc_name, file in content.items(): 

332 if isinstance(file, collections.abc.Mapping): 

333 buf = io.StringIO() 

334 write_yaml(file, buf) 

335 file = buf.getvalue() 

336 

337 if isinstance(file, str): 

338 zip.writestr(arc_name, file.encode("utf-8")) 

339 else: 

340 if isinstance(file, BytesReader): 

341 reader = file 

342 else: 

343 reader = get_reader(file) 

344 

345 if ( 

346 isinstance(reader.original_root, ZipFile) 

347 and reader.original_root is zip 

348 ): 

349 logger.debug( 

350 f"Not copying {reader.original_file_name} in " 

351 + ( 

352 "zip file" 

353 if reader.original_root.filename is None 

354 else reader.original_root.filename 

355 ) 

356 + " to itself." 

357 ) 

358 continue 

359 

360 with zip.open(arc_name, "w") as dest: 

361 shutil.copyfileobj(reader, dest, 1024 * 8) 

362 

363 

364def write_zip( 

365 path: FilePath | IO[bytes], 

366 content: Mapping[ 

367 FileName, str | FilePath | ZipPath | BioimageioYamlContentView | BytesReader 

368 ], 

369 *, 

370 compression: int, 

371 compression_level: int, 

372) -> None: 

373 """Write a zip archive. 

374 

375 Args: 

376 path: output path to write to. 

377 content: dict mapping archive names to local file paths, strings (for text files), or dict (for yaml files). 

378 compression: The numeric constant of compression method. 

379 compression_level: Compression level to use when writing files to the archive. 

380 See https://docs.python.org/3/library/zipfile.html#zipfile.ZipFile 

381 

382 """ 

383 if isinstance(path, Path): 

384 path.parent.mkdir(parents=True, exist_ok=True) 

385 

386 with ZipFile( 

387 path, "w", compression=compression, compresslevel=compression_level 

388 ) as zip: 

389 write_content_to_zip(content, zip) 

390 

391 

392def load_array(source: PermissiveFileSource) -> NDArray[Any]: 

393 """load a numpy ndarray from a .npy file""" 

394 reader = get_reader(source) 

395 if settings.allow_pickle: 

396 logger.warning("Loading numpy array with `allow_pickle=True`.") 

397 

398 return numpy.load(reader, allow_pickle=settings.allow_pickle) 

399 

400 

401def save_array(path: Path | ZipPath, array: NDArray[Any]) -> None: 

402 """save a numpy ndarray to a .npy file""" 

403 with path.open(mode="wb") as f: 

404 assert not isinstance(f, io.TextIOWrapper) 

405 return numpy.save(f, array, allow_pickle=False)