Coverage for src/bioimageio/spec/generic/v0_3.py: 92%

198 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 10:20 +0000

1from __future__ import annotations 

2 

3import string 

4from typing import ( 

5 TYPE_CHECKING, 

6 Any, 

7 Callable, 

8 ClassVar, 

9 List, 

10 Literal, 

11 Sequence, 

12 TypeVar, 

13 cast, 

14) 

15 

16import annotated_types 

17from annotated_types import Len, LowerCase, MaxLen, MinLen 

18from pydantic import Field, RootModel, ValidationInfo, field_validator, model_validator 

19from typing_extensions import Annotated, get_args 

20 

21from .._internal.common_nodes import Node, ResourceDescrBase 

22from .._internal.constants import TAG_CATEGORIES 

23from .._internal.field_validation import validate_github_user 

24from .._internal.field_warning import as_warning, issue_warning, warn 

25from .._internal.io import ( 

26 BioimageioYamlContent, 

27 FileDescr, 

28 WithSuffix, 

29 is_yaml_value, 

30) 

31from .._internal.io_basics import Sha256 

32from .._internal.io_packaging import FileDescr_package 

33from .._internal.license_id import DeprecatedLicenseId, LicenseId 

34from .._internal.node_converter import Converter 

35from .._internal.type_guards import is_dict 

36from .._internal.types import FAIR, NotEmpty, RelativeFilePath 

37from .._internal.url import HttpUrl 

38from .._internal.validated_string import ValidatedString 

39from .._internal.validator_annotations import ( 

40 Predicate, 

41 RestrictCharacters, 

42) 

43from .._internal.version_type import Version 

44from .._internal.warning_levels import ALERT, INFO 

45from ._v0_3_converter import convert_from_older_format 

46from .v0_2 import Author as _Author_v0_2 

47from .v0_2 import BadgeDescr, Doi, OrcidId, Uploader 

48from .v0_2 import Maintainer as _Maintainer_v0_2 

49 

50__all__ = [ 

51 "KNOWN_SPECIFIC_RESOURCE_TYPES", 

52 "VALID_COVER_IMAGE_EXTENSIONS", 

53 "Author", 

54 "BadgeDescr", 

55 "CiteEntry", 

56 "DeprecatedLicenseId", 

57 "Doi", 

58 "FileDescr", 

59 "GenericDescr", 

60 "HttpUrl", 

61 "LicenseId", 

62 "LinkedResource", 

63 "Maintainer", 

64 "OrcidId", 

65 "RelativeFilePath", 

66 "ResourceId", 

67 "Sha256", 

68 "Uploader", 

69 "Version", 

70] 

71 

72KNOWN_SPECIFIC_RESOURCE_TYPES = ( 

73 "application", 

74 "collection", 

75 "dataset", 

76 "model", 

77 "notebook", 

78) 

79VALID_COVER_IMAGE_EXTENSIONS = ( 

80 ".gif", 

81 ".jpeg", 

82 ".jpg", 

83 ".png", 

84 ".svg", 

85) 

86 

87 

88FileDescr_documentation = Annotated[ 

89 FileDescr_package, 

90 WithSuffix(".md", case_sensitive=True), 

91 Field( 

92 examples=[ 

93 {"source": "README.md"}, 

94 ], 

95 ), 

96] 

97 

98 

99class ResourceId(ValidatedString): 

100 root_model: ClassVar[type[RootModel[Any]]] = RootModel[ 

101 Annotated[ 

102 NotEmpty[str], 

103 RestrictCharacters(string.ascii_lowercase + string.digits + "_-/."), 

104 annotated_types.Predicate( 

105 lambda s: not (s.startswith("/") or s.endswith("/")) 

106 ), 

107 ] 

108 ] 

109 

110 

111def _has_no_slash(s: str) -> bool: 

112 return "/" not in s and "\\" not in s 

113 

114 

115class Author(_Author_v0_2): 

116 name: Annotated[str, Predicate(_has_no_slash)] 

117 github_user: str | None = None 

118 

119 @field_validator("github_user", mode="after") 

120 def _validate_github_user(cls, value: str | None): 

121 if value is None: 

122 return None 

123 else: 

124 return validate_github_user(value) 

125 

126 

127class _AuthorConv(Converter[_Author_v0_2, Author]): 

128 def _convert( 

129 self, src: _Author_v0_2, tgt: type[Author | dict[str, Any]] 

130 ) -> Author | dict[str, Any]: 

131 return tgt( 

132 name=src.name, 

133 github_user=src.github_user, 

134 affiliation=src.affiliation, 

135 email=src.email, 

136 orcid=src.orcid, 

137 ) 

138 

139 

140_author_conv = _AuthorConv(_Author_v0_2, Author) 

141 

142 

143class Maintainer(_Maintainer_v0_2): 

144 name: Annotated[str, Predicate(_has_no_slash)] | None = None 

145 github_user: str 

146 

147 @field_validator("github_user", mode="after") 

148 def validate_github_user(cls, value: str): 

149 return validate_github_user(value) 

150 

151 

152class _MaintainerConv(Converter[_Maintainer_v0_2, Maintainer]): 

153 def _convert( 

154 self, src: _Maintainer_v0_2, tgt: type[Maintainer | dict[str, Any]] 

155 ) -> Maintainer | dict[str, Any]: 

156 return tgt( 

157 name=src.name, 

158 github_user=src.github_user, 

159 affiliation=src.affiliation, 

160 email=src.email, 

161 orcid=src.orcid, 

162 ) 

163 

164 

165_maintainer_conv = _MaintainerConv(_Maintainer_v0_2, Maintainer) 

166 

167 

168class CiteEntry(Node): 

169 """A citation that should be referenced in work using this resource.""" 

170 

171 text: str 

172 """free text description""" 

173 

174 doi: Doi | None = None 

175 """A digital object identifier (DOI) is the prefered citation reference. 

176 See https://www.doi.org/ for details. 

177 Note: 

178 Either **doi** or **url** have to be specified. 

179 """ 

180 

181 url: HttpUrl | None = None 

182 """URL to cite (preferably specify a **doi** instead/also). 

183 Note: 

184 Either **doi** or **url** have to be specified. 

185 """ 

186 

187 @model_validator(mode="after") 

188 def _check_doi_or_url(self): 

189 if not self.doi and not self.url: 

190 raise ValueError("Either 'doi' or 'url' is required") 

191 

192 return self 

193 

194 

195class LinkedResourceBase(Node): 

196 @model_validator(mode="before") 

197 def _remove_version_number(cls, value: Any): 

198 if is_dict(value): 

199 vn = value.pop("version_number", None) 

200 if vn is not None and value.get("version") is None: 

201 value["version"] = vn 

202 

203 return value 

204 

205 version: Version | None = None 

206 """The version of the linked resource following SemVer 2.0.""" 

207 

208 

209class LinkedResource(LinkedResourceBase): 

210 """Reference to a bioimage.io resource""" 

211 

212 id: ResourceId 

213 """A valid resource `id` from the official bioimage.io collection.""" 

214 

215 

216class BioimageioConfig(Node, extra="allow"): 

217 """bioimage.io internal metadata.""" 

218 

219 

220class Config(Node, extra="allow"): 

221 """A place to store additional metadata (often tool specific). 

222 

223 Such additional metadata is typically set programmatically by the respective tool 

224 or by people with specific insights into the tool. 

225 If you want to store additional metadata that does not match any of the other 

226 fields, think of a key unlikely to collide with anyone elses use-case/tool and save 

227 it here. 

228 

229 Please consider creating [an issue in the bioimageio.spec repository](https://github.com/bioimage-io/spec-bioimage-io/issues/new?template=Blank+issue) 

230 if you are not sure if an existing field could cover your use case 

231 or if you think such a field should exist. 

232 """ 

233 

234 bioimageio: BioimageioConfig = Field(default_factory=BioimageioConfig) 

235 """bioimage.io internal metadata.""" 

236 

237 @model_validator(mode="after") 

238 def _validate_extra_fields(self): 

239 if self.model_extra: 

240 for k, v in self.model_extra.items(): 

241 if not isinstance(v, Node) and not is_yaml_value(v): 

242 raise ValueError( 

243 f"config.{k} is not a valid YAML value or `Node` instance" 

244 ) 

245 

246 return self 

247 

248 def __getitem__(self, key: str) -> Any: 

249 """Allows to access the config as a dictionary.""" 

250 return getattr(self, key) 

251 

252 def __setitem__(self, key: str, value: Any) -> None: 

253 """Allows to set the config as a dictionary.""" 

254 setattr(self, key, value) 

255 

256 

257_FileDescr_cover = Annotated[ 

258 FileDescr_package, 

259 WithSuffix(VALID_COVER_IMAGE_EXTENSIONS, case_sensitive=False), 

260] 

261 

262 

263class GenericModelDescrBase(ResourceDescrBase): 

264 """Base for all resource descriptions including of model descriptions""" 

265 

266 name: Annotated[ 

267 Annotated[ 

268 str, RestrictCharacters(string.ascii_letters + string.digits + "_+- ()") 

269 ], 

270 MinLen(5), 

271 MaxLen(128), 

272 warn(Annotated[str, MaxLen(64)], "Name longer than 64 characters.", INFO), 

273 ] 

274 """A human-friendly name of the resource description. 

275 May only contains letters, digits, underscore, minus, parentheses and spaces.""" 

276 

277 description: FAIR[ 

278 Annotated[ 

279 str, 

280 MaxLen(1024), 

281 warn( 

282 Annotated[str, MaxLen(512)], "Description longer than 512 characters." 

283 ), 

284 ] 

285 ] = "" 

286 """A string containing a brief description.""" 

287 

288 covers: list[_FileDescr_cover] = Field( 

289 default_factory=cast(Callable[[], List[_FileDescr_cover]], list), 

290 description=( 

291 "Cover images. Please use an image smaller than 500KB and an aspect" 

292 " ratio width to height of 2:1 or 1:1.\nThe supported image formats" 

293 f" are: {VALID_COVER_IMAGE_EXTENSIONS}" 

294 ), 

295 examples=[[{"source": "cover.png"}]], 

296 ) 

297 """Cover images.""" 

298 

299 documentation: FAIR[FileDescr_documentation | None] = None 

300 """Additional model documentation. 

301 The recommended documentation source file name is `README.md`. An `.md` suffix is mandatory.""" 

302 

303 @classmethod 

304 def convert_from_old_format_wo_validation(cls, data: BioimageioYamlContent) -> None: 

305 """Convert metadata following an older format version to this classes' format 

306 without validating the result. 

307 """ 

308 convert_from_older_format(data) 

309 

310 id_emoji: ( 

311 Annotated[str, Len(min_length=1, max_length=2), Field(examples=["🦈", "🦥"])] 

312 | None 

313 ) = None 

314 """UTF-8 emoji for display alongside the `id`.""" 

315 

316 authors: FAIR[list[Author]] = Field( 

317 default_factory=cast(Callable[[], List[Author]], list) 

318 ) 

319 """The authors are the creators of this resource description and the primary points of contact.""" 

320 

321 attachments: list[FileDescr_package] = Field( 

322 default_factory=cast(Callable[[], List[FileDescr]], list) 

323 ) 

324 """file attachments""" 

325 

326 cite: FAIR[list[CiteEntry]] = Field( 

327 default_factory=cast(Callable[[], List[CiteEntry]], list) 

328 ) 

329 """citations""" 

330 

331 license: FAIR[ 

332 Annotated[ 

333 LicenseId | DeprecatedLicenseId | None | FileDescr_package, 

334 Field( 

335 union_mode="left_to_right", examples=["CC0-1.0", "MIT", "BSD-2-Clause"] 

336 ), 

337 ] 

338 ] = None 

339 """A [SPDX license identifier](https://spdx.org/licenses/) or a custom license file.""" 

340 

341 @field_validator("license", mode="after") 

342 @classmethod 

343 def _check_license(cls, value: Any) -> Any: 

344 if isinstance(value, FileDescr): 

345 issue_warning( 

346 "Custom license file provided. Consider using a standard SPDX license identifier for better FAIR" 

347 + " compliance instead of pointing to {value}.", 

348 value=value.source, 

349 ) 

350 elif value in get_args(DeprecatedLicenseId): 

351 issue_warning( 

352 "License '{value}' is deprecated. Consider using a non-deprecated SPDX license identifier for better" 

353 + " FAIR compliance.", 

354 value=value, 

355 ) 

356 

357 return value 

358 

359 git_repo: Annotated[ 

360 HttpUrl | None, 

361 Field( 

362 examples=[ 

363 "https://github.com/bioimage-io/spec-bioimage-io/tree/main/example_descriptions/models/unet2d_nuclei_broad" 

364 ], 

365 ), 

366 ] = None 

367 """A URL to the Git repository where the resource is being developed.""" 

368 

369 icon: Annotated[str, Len(min_length=1, max_length=2)] | FileDescr_package | None = ( 

370 None 

371 ) 

372 """An icon for illustration, e.g. on bioimage.io""" 

373 

374 links: Annotated[ 

375 list[str], 

376 Field( 

377 examples=[ 

378 ( 

379 "ilastik/ilastik", 

380 "deepimagej/deepimagej", 

381 "zero/notebook_u-net_3d_zerocostdl4mic", 

382 ) 

383 ], 

384 ), 

385 ] = Field(default_factory=list) 

386 """IDs of other bioimage.io resources""" 

387 

388 uploader: Uploader | None = None 

389 """The person who uploaded the model (e.g. to bioimage.io)""" 

390 

391 maintainers: list[Maintainer] = Field( 

392 default_factory=cast(Callable[[], List[Maintainer]], list) 

393 ) 

394 """Maintainers of this resource. 

395 If not specified, `authors` are maintainers and at least some of them has to specify their `github_user` name""" 

396 

397 @model_validator(mode="after") 

398 def _check_maintainers_exist(self): 

399 if ( 

400 not self.maintainers 

401 and self.authors 

402 and all(a.github_user is None for a in self.authors) 

403 ): 

404 issue_warning( 

405 "Missing `maintainers` or any author in `authors` with a specified" 

406 + " `github_user` name.", 

407 value=self.authors, 

408 field="authors", 

409 severity=ALERT, 

410 ) 

411 

412 return self 

413 

414 tags: FAIR[ 

415 Annotated[ 

416 list[str], 

417 Field( 

418 examples=[("unet2d", "pytorch", "nucleus", "segmentation", "dsb2018")] 

419 ), 

420 ] 

421 ] = Field(default_factory=list) 

422 """Associated tags""" 

423 

424 @as_warning 

425 @field_validator("tags") 

426 @classmethod 

427 def warn_about_tag_categories( 

428 cls, value: list[str], info: ValidationInfo 

429 ) -> list[str]: 

430 categories = TAG_CATEGORIES.get(info.data["type"], {}) 

431 missing_categories: list[dict[str, Sequence[str]]] = [] 

432 for cat, entries in categories.items(): 

433 if not any(e in value for e in entries): 

434 missing_categories.append({cat: entries}) 

435 

436 if missing_categories: 

437 raise ValueError( 

438 f"Missing tags from bioimage.io categories: {missing_categories}" 

439 ) 

440 

441 return value 

442 

443 version: Version | None = None 

444 """The version of the resource following SemVer 2.0.""" 

445 

446 @model_validator(mode="before") 

447 def _remove_version_number(cls, value: Any): 

448 if is_dict(value): 

449 vn = value.pop("version_number", None) 

450 if vn is not None and value.get("version") is None: 

451 value["version"] = vn 

452 

453 return value 

454 

455 version_comment: Annotated[str, MaxLen(512)] | None = None 

456 """A comment on the version of the resource.""" 

457 

458 

459class GenericDescrBase(GenericModelDescrBase): 

460 """Base for all resource descriptions except for the model descriptions""" 

461 

462 implemented_format_version: ClassVar[Literal["0.3.4"]] = "0.3.4" 

463 if TYPE_CHECKING: 

464 format_version: Literal["0.3.4"] = "0.3.4" 

465 else: 

466 format_version: Literal["0.3.4"] 

467 """The **format** version of this resource specification""" 

468 

469 @model_validator(mode="before") 

470 @classmethod 

471 def _convert_from_older_format( 

472 cls, data: BioimageioYamlContent, / 

473 ) -> BioimageioYamlContent: 

474 cls.convert_from_old_format_wo_validation(data) 

475 return data 

476 

477 badges: list[BadgeDescr] = Field( # pyright: ignore[reportUnknownVariableType] 

478 default_factory=list 

479 ) 

480 """badges associated with this resource""" 

481 

482 config: Config = Field(default_factory=Config.model_construct) 

483 """A field for custom configuration that can contain any keys not present in the RDF spec. 

484 This means you should not store, for example, a GitHub repo URL in `config` since there is a `git_repo` field. 

485 Keys in `config` may be very specific to a tool or consumer software. To avoid conflicting definitions, 

486 it is recommended to wrap added configuration into a sub-field named with the specific domain or tool name, 

487 for example: 

488 ```yaml 

489 config: 

490 giraffe_neckometer: # here is the domain name 

491 length: 3837283 

492 address: 

493 home: zoo 

494 imagej: # config specific to ImageJ 

495 macro_dir: path/to/macro/file 

496 ``` 

497 If possible, please use [`snake_case`](https://en.wikipedia.org/wiki/Snake_case) for keys in `config`. 

498 You may want to list linked files additionally under `attachments` to include them when packaging a resource. 

499 (Packaging a resource means downloading/copying important linked files and creating a ZIP archive that contains 

500 an altered rdf.yaml file with local references to the downloaded files.)""" 

501 

502 

503ResourceDescrType = TypeVar("ResourceDescrType", bound=GenericDescrBase) 

504 

505 

506class GenericDescr(GenericDescrBase, extra="ignore"): 

507 """Specification of the fields used in a generic bioimage.io-compliant resource description file (RDF). 

508 

509 An RDF is a YAML file that describes a resource such as a model, a dataset, or a notebook. 

510 Note that those resources are described with a type-specific RDF. 

511 Use this generic resource description, if none of the known specific types matches your resource. 

512 """ 

513 

514 implemented_type: ClassVar[Literal["generic"]] = "generic" 

515 if TYPE_CHECKING: 

516 type: Annotated[str, LowerCase] = "generic" 

517 """The resource type assigns a broad category to the resource.""" 

518 else: 

519 type: Annotated[str, LowerCase] 

520 """The resource type assigns a broad category to the resource.""" 

521 

522 id: ( 

523 Annotated[ResourceId, Field(examples=["affable-shark", "ambitious-sloth"])] 

524 | None 

525 ) = None 

526 """bioimage.io-wide unique resource identifier 

527 assigned by bioimage.io; version **un**specific.""" 

528 

529 parent: ResourceId | None = None 

530 """The description from which this one is derived""" 

531 

532 source: HttpUrl | None = None 

533 """The primary source of the resource""" 

534 

535 @field_validator("type", mode="after") 

536 @classmethod 

537 def check_specific_types(cls, value: str) -> str: 

538 if value in KNOWN_SPECIFIC_RESOURCE_TYPES: 

539 raise ValueError( 

540 f"Use the {value} description instead of this generic description for" 

541 + f" your '{value}' resource." 

542 ) 

543 

544 return value