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

199 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-15 15:08 +0000

1from __future__ import annotations 

2 

3import string 

4from typing import ( 

5 TYPE_CHECKING, 

6 Any, 

7 Callable, 

8 ClassVar, 

9 Dict, 

10 List, 

11 Literal, 

12 Optional, 

13 Sequence, 

14 Type, 

15 TypeVar, 

16 Union, 

17 cast, 

18) 

19 

20import annotated_types 

21from annotated_types import Len, LowerCase, MaxLen, MinLen 

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

23from typing_extensions import Annotated, get_args 

24 

25from .._internal.common_nodes import Node, ResourceDescrBase 

26from .._internal.constants import TAG_CATEGORIES 

27from .._internal.field_validation import validate_github_user 

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

29from .._internal.io import ( 

30 BioimageioYamlContent, 

31 FileDescr, 

32 WithSuffix, 

33 is_yaml_value, 

34) 

35from .._internal.io_basics import Sha256 

36from .._internal.io_packaging import FileDescr_package 

37from .._internal.license_id import DeprecatedLicenseId, LicenseId 

38from .._internal.node_converter import Converter 

39from .._internal.type_guards import is_dict 

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

41from .._internal.url import HttpUrl 

42from .._internal.validated_string import ValidatedString 

43from .._internal.validator_annotations import ( 

44 Predicate, 

45 RestrictCharacters, 

46) 

47from .._internal.version_type import Version 

48from .._internal.warning_levels import ALERT, INFO 

49from ._v0_3_converter import convert_from_older_format 

50from .v0_2 import Author as _Author_v0_2 

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

52from .v0_2 import Maintainer as _Maintainer_v0_2 

53 

54__all__ = [ 

55 "Author", 

56 "BadgeDescr", 

57 "CiteEntry", 

58 "DeprecatedLicenseId", 

59 "Doi", 

60 "FileDescr", 

61 "GenericDescr", 

62 "HttpUrl", 

63 "KNOWN_SPECIFIC_RESOURCE_TYPES", 

64 "LicenseId", 

65 "LinkedResource", 

66 "Maintainer", 

67 "OrcidId", 

68 "RelativeFilePath", 

69 "ResourceId", 

70 "Sha256", 

71 "Uploader", 

72 "VALID_COVER_IMAGE_EXTENSIONS", 

73 "Version", 

74] 

75 

76KNOWN_SPECIFIC_RESOURCE_TYPES = ( 

77 "application", 

78 "collection", 

79 "dataset", 

80 "model", 

81 "notebook", 

82) 

83VALID_COVER_IMAGE_EXTENSIONS = ( 

84 ".gif", 

85 ".jpeg", 

86 ".jpg", 

87 ".png", 

88 ".svg", 

89) 

90 

91 

92FileDescr_documentation = Annotated[ 

93 FileDescr_package, 

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

95 Field( 

96 examples=[ 

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

98 ], 

99 ), 

100] 

101 

102 

103class ResourceId(ValidatedString): 

104 root_model: ClassVar[Type[RootModel[Any]]] = RootModel[ 

105 Annotated[ 

106 NotEmpty[str], 

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

108 annotated_types.Predicate( 

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

110 ), 

111 ] 

112 ] 

113 

114 

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

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

117 

118 

119class Author(_Author_v0_2): 

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

121 github_user: Optional[str] = None 

122 

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

124 def _validate_github_user(cls, value: Optional[str]): 

125 if value is None: 

126 return None 

127 else: 

128 return validate_github_user(value) 

129 

130 

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

132 def _convert( 

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

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

135 return tgt( 

136 name=src.name, 

137 github_user=src.github_user, 

138 affiliation=src.affiliation, 

139 email=src.email, 

140 orcid=src.orcid, 

141 ) 

142 

143 

144_author_conv = _AuthorConv(_Author_v0_2, Author) 

145 

146 

147class Maintainer(_Maintainer_v0_2): 

148 name: Optional[Annotated[str, Predicate(_has_no_slash)]] = None 

149 github_user: str 

150 

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

152 def validate_github_user(cls, value: str): 

153 return validate_github_user(value) 

154 

155 

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

157 def _convert( 

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

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

160 return tgt( 

161 name=src.name, 

162 github_user=src.github_user, 

163 affiliation=src.affiliation, 

164 email=src.email, 

165 orcid=src.orcid, 

166 ) 

167 

168 

169_maintainer_conv = _MaintainerConv(_Maintainer_v0_2, Maintainer) 

170 

171 

172class CiteEntry(Node): 

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

174 

175 text: str 

176 """free text description""" 

177 

178 doi: Optional[Doi] = None 

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

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

181 Note: 

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

183 """ 

184 

185 url: Optional[HttpUrl] = None 

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

187 Note: 

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

189 """ 

190 

191 @model_validator(mode="after") 

192 def _check_doi_or_url(self): 

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

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

195 

196 return self 

197 

198 

199class LinkedResourceBase(Node): 

200 @model_validator(mode="before") 

201 def _remove_version_number(cls, value: Any): 

202 if is_dict(value): 

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

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

205 value["version"] = vn 

206 

207 return value 

208 

209 version: Optional[Version] = None 

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

211 

212 

213class LinkedResource(LinkedResourceBase): 

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

215 

216 id: ResourceId 

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

218 

219 

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

221 """bioimage.io internal metadata.""" 

222 

223 

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

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

226 

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

228 or by people with specific insights into the tool. 

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

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

231 it here. 

232 

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

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

235 or if you think such a field should exist. 

236 """ 

237 

238 bioimageio: BioimageioConfig = Field(default_factory=BioimageioConfig) 

239 """bioimage.io internal metadata.""" 

240 

241 @model_validator(mode="after") 

242 def _validate_extra_fields(self): 

243 if self.model_extra: 

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

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

246 raise ValueError( 

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

248 ) 

249 

250 return self 

251 

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

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

254 return getattr(self, key) 

255 

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

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

258 setattr(self, key, value) 

259 

260 

261_FileDescr_cover = Annotated[ 

262 FileDescr_package, 

263 WithSuffix(VALID_COVER_IMAGE_EXTENSIONS, case_sensitive=False), 

264] 

265 

266 

267class GenericModelDescrBase(ResourceDescrBase): 

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

269 

270 name: Annotated[ 

271 Annotated[ 

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

273 ], 

274 MinLen(5), 

275 MaxLen(128), 

276 warn(MaxLen(64), "Name longer than 64 characters.", INFO), 

277 ] 

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

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

280 

281 description: FAIR[ 

282 Annotated[ 

283 str, 

284 MaxLen(1024), 

285 warn(MaxLen(512), "Description longer than 512 characters."), 

286 ] 

287 ] = "" 

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

289 

290 covers: List[_FileDescr_cover] = Field( 

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

292 description=( 

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

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

295 f" are: {VALID_COVER_IMAGE_EXTENSIONS}" 

296 ), 

297 examples=[["cover.png"]], 

298 ) 

299 """Cover images.""" 

300 

301 documentation: FAIR[Optional[FileDescr_documentation]] = None 

302 """Additional model documentation. 

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

304 

305 @classmethod 

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

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

308 without validating the result. 

309 """ 

310 convert_from_older_format(data) 

311 

312 id_emoji: Optional[ 

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

314 ] = None 

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

316 

317 authors: FAIR[List[Author]] = Field( 

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

319 ) 

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

321 

322 attachments: List[FileDescr_package] = Field( 

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

324 ) 

325 """file attachments""" 

326 

327 cite: FAIR[List[CiteEntry]] = Field( 

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

329 ) 

330 """citations""" 

331 

332 license: FAIR[ 

333 Annotated[ 

334 Union[LicenseId, DeprecatedLicenseId, None, FileDescr_package], 

335 Field( 

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

337 ), 

338 ] 

339 ] = None 

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

341 

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

343 @classmethod 

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

345 if isinstance(value, FileDescr): 

346 issue_warning( 

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

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

349 value=value.source, 

350 ) 

351 elif value in get_args(DeprecatedLicenseId): 

352 issue_warning( 

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

354 + " FAIR compliance.", 

355 value=value, 

356 ) 

357 

358 return value 

359 

360 git_repo: Annotated[ 

361 Optional[HttpUrl], 

362 Field( 

363 examples=[ 

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

365 ], 

366 ), 

367 ] = None 

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

369 

370 icon: Union[ 

371 Annotated[str, Len(min_length=1, max_length=2)], FileDescr_package, None 

372 ] = None 

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

374 

375 links: Annotated[ 

376 List[str], 

377 Field( 

378 examples=[ 

379 ( 

380 "ilastik/ilastik", 

381 "deepimagej/deepimagej", 

382 "zero/notebook_u-net_3d_zerocostdl4mic", 

383 ) 

384 ], 

385 ), 

386 ] = Field(default_factory=list) 

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

388 

389 uploader: Optional[Uploader] = None 

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

391 

392 maintainers: List[Maintainer] = Field( 

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

394 ) 

395 """Maintainers of this resource. 

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

397 

398 @model_validator(mode="after") 

399 def _check_maintainers_exist(self): 

400 if not self.maintainers and self.authors: 

401 if all(a.github_user is None for a in self.authors): 

402 issue_warning( 

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

404 + " `github_user` name.", 

405 value=self.authors, 

406 field="authors", 

407 severity=ALERT, 

408 ) 

409 

410 return self 

411 

412 tags: FAIR[ 

413 Annotated[ 

414 List[str], 

415 Field( 

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

417 ), 

418 ] 

419 ] = Field(default_factory=list) 

420 """Associated tags""" 

421 

422 @as_warning 

423 @field_validator("tags") 

424 @classmethod 

425 def warn_about_tag_categories( 

426 cls, value: List[str], info: ValidationInfo 

427 ) -> List[str]: 

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

429 missing_categories: List[Dict[str, Sequence[str]]] = [] 

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

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

432 missing_categories.append({cat: entries}) 

433 

434 if missing_categories: 

435 raise ValueError( 

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

437 ) 

438 

439 return value 

440 

441 version: Optional[Version] = None 

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

443 

444 @model_validator(mode="before") 

445 def _remove_version_number(cls, value: Any): 

446 if is_dict(value): 

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

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

449 value["version"] = vn 

450 

451 return value 

452 

453 version_comment: Optional[Annotated[str, MaxLen(512)]] = None 

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

455 

456 

457class GenericDescrBase(GenericModelDescrBase): 

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

459 

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

461 if TYPE_CHECKING: 

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

463 else: 

464 format_version: Literal["0.3.4"] 

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

466 

467 @model_validator(mode="before") 

468 @classmethod 

469 def _convert_from_older_format( 

470 cls, data: BioimageioYamlContent, / 

471 ) -> BioimageioYamlContent: 

472 cls.convert_from_old_format_wo_validation(data) 

473 return data 

474 

475 badges: List[BadgeDescr] = Field( # pyright: ignore[reportUnknownVariableType] 

476 default_factory=list 

477 ) 

478 """badges associated with this resource""" 

479 

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

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

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

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

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

485 for example: 

486 ```yaml 

487 config: 

488 giraffe_neckometer: # here is the domain name 

489 length: 3837283 

490 address: 

491 home: zoo 

492 imagej: # config specific to ImageJ 

493 macro_dir: path/to/macro/file 

494 ``` 

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

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

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

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

499 

500 

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

502 

503 

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

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

506 

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

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

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

510 """ 

511 

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

513 if TYPE_CHECKING: 

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

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

516 else: 

517 type: Annotated[str, LowerCase] 

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

519 

520 id: Optional[ 

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

522 ] = None 

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

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

525 

526 parent: Optional[ResourceId] = None 

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

528 

529 source: Optional[HttpUrl] = None 

530 """The primary source of the resource""" 

531 

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

533 @classmethod 

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

535 if value in KNOWN_SPECIFIC_RESOURCE_TYPES: 

536 raise ValueError( 

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

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

539 ) 

540 

541 return value