Coverage for src/bioimageio/spec/generic/v0_3.py: 92%
198 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 09:17 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 09:17 +0000
1from __future__ import annotations
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)
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
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
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]
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)
88FileDescr_documentation = Annotated[
89 FileDescr_package,
90 WithSuffix(".md", case_sensitive=True),
91 Field(
92 examples=[
93 {"source": "README.md"},
94 ],
95 ),
96]
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 ]
111def _has_no_slash(s: str) -> bool:
112 return "/" not in s and "\\" not in s
115class Author(_Author_v0_2):
116 name: Annotated[str, Predicate(_has_no_slash)]
117 github_user: str | None = None
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)
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 )
140_author_conv = _AuthorConv(_Author_v0_2, Author)
143class Maintainer(_Maintainer_v0_2):
144 name: Annotated[str, Predicate(_has_no_slash)] | None = None
145 github_user: str
147 @field_validator("github_user", mode="after")
148 def validate_github_user(cls, value: str):
149 return validate_github_user(value)
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 )
165_maintainer_conv = _MaintainerConv(_Maintainer_v0_2, Maintainer)
168class CiteEntry(Node):
169 """A citation that should be referenced in work using this resource."""
171 text: str
172 """free text description"""
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 """
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 """
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")
192 return self
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
203 return value
205 version: Version | None = None
206 """The version of the linked resource following SemVer 2.0."""
209class LinkedResource(LinkedResourceBase):
210 """Reference to a bioimage.io resource"""
212 id: ResourceId
213 """A valid resource `id` from the official bioimage.io collection."""
216class BioimageioConfig(Node, extra="allow"):
217 """bioimage.io internal metadata."""
220class Config(Node, extra="allow"):
221 """A place to store additional metadata (often tool specific).
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.
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 """
234 bioimageio: BioimageioConfig = Field(default_factory=BioimageioConfig)
235 """bioimage.io internal metadata."""
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 )
246 return self
248 def __getitem__(self, key: str) -> Any:
249 """Allows to access the config as a dictionary."""
250 return getattr(self, key)
252 def __setitem__(self, key: str, value: Any) -> None:
253 """Allows to set the config as a dictionary."""
254 setattr(self, key, value)
257_FileDescr_cover = Annotated[
258 FileDescr_package,
259 WithSuffix(VALID_COVER_IMAGE_EXTENSIONS, case_sensitive=False),
260]
263class GenericModelDescrBase(ResourceDescrBase):
264 """Base for all resource descriptions including of model descriptions"""
266 name: Annotated[
267 Annotated[
268 str, RestrictCharacters(string.ascii_letters + string.digits + "_+- ()")
269 ],
270 MinLen(5),
271 MaxLen(128),
272 warn(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."""
277 description: FAIR[
278 Annotated[
279 str,
280 MaxLen(1024),
281 warn(MaxLen(512), "Description longer than 512 characters."),
282 ]
283 ] = ""
284 """A string containing a brief description."""
286 covers: list[_FileDescr_cover] = Field(
287 default_factory=cast(Callable[[], List[_FileDescr_cover]], list),
288 description=(
289 "Cover images. Please use an image smaller than 500KB and an aspect"
290 " ratio width to height of 2:1 or 1:1.\nThe supported image formats"
291 f" are: {VALID_COVER_IMAGE_EXTENSIONS}"
292 ),
293 examples=[["cover.png"]],
294 )
295 """Cover images."""
297 documentation: FAIR[FileDescr_documentation | None] = None
298 """Additional model documentation.
299 The recommended documentation source file name is `README.md`. An `.md` suffix is mandatory."""
301 @classmethod
302 def convert_from_old_format_wo_validation(cls, data: BioimageioYamlContent) -> None:
303 """Convert metadata following an older format version to this classes' format
304 without validating the result.
305 """
306 convert_from_older_format(data)
308 id_emoji: (
309 Annotated[str, Len(min_length=1, max_length=2), Field(examples=["🦈", "🦥"])]
310 | None
311 ) = None
312 """UTF-8 emoji for display alongside the `id`."""
314 authors: FAIR[list[Author]] = Field(
315 default_factory=cast(Callable[[], List[Author]], list)
316 )
317 """The authors are the creators of this resource description and the primary points of contact."""
319 attachments: list[FileDescr_package] = Field(
320 default_factory=cast(Callable[[], List[FileDescr]], list)
321 )
322 """file attachments"""
324 cite: FAIR[list[CiteEntry]] = Field(
325 default_factory=cast(Callable[[], List[CiteEntry]], list)
326 )
327 """citations"""
329 license: FAIR[
330 Annotated[
331 LicenseId | DeprecatedLicenseId | None | FileDescr_package,
332 Field(
333 union_mode="left_to_right", examples=["CC0-1.0", "MIT", "BSD-2-Clause"]
334 ),
335 ]
336 ] = None
337 """A [SPDX license identifier](https://spdx.org/licenses/) or a custom license file."""
339 @field_validator("license", mode="after")
340 @classmethod
341 def _check_license(cls, value: Any) -> Any:
342 if isinstance(value, FileDescr):
343 issue_warning(
344 "Custom license file provided. Consider using a standard SPDX license identifier for better FAIR"
345 + " compliance instead of pointing to {value}.",
346 value=value.source,
347 )
348 elif value in get_args(DeprecatedLicenseId):
349 issue_warning(
350 "License '{value}' is deprecated. Consider using a non-deprecated SPDX license identifier for better"
351 + " FAIR compliance.",
352 value=value,
353 )
355 return value
357 git_repo: Annotated[
358 HttpUrl | None,
359 Field(
360 examples=[
361 "https://github.com/bioimage-io/spec-bioimage-io/tree/main/example_descriptions/models/unet2d_nuclei_broad"
362 ],
363 ),
364 ] = None
365 """A URL to the Git repository where the resource is being developed."""
367 icon: Annotated[str, Len(min_length=1, max_length=2)] | FileDescr_package | None = (
368 None
369 )
370 """An icon for illustration, e.g. on bioimage.io"""
372 links: Annotated[
373 list[str],
374 Field(
375 examples=[
376 (
377 "ilastik/ilastik",
378 "deepimagej/deepimagej",
379 "zero/notebook_u-net_3d_zerocostdl4mic",
380 )
381 ],
382 ),
383 ] = Field(default_factory=list)
384 """IDs of other bioimage.io resources"""
386 uploader: Uploader | None = None
387 """The person who uploaded the model (e.g. to bioimage.io)"""
389 maintainers: list[Maintainer] = Field(
390 default_factory=cast(Callable[[], List[Maintainer]], list)
391 )
392 """Maintainers of this resource.
393 If not specified, `authors` are maintainers and at least some of them has to specify their `github_user` name"""
395 @model_validator(mode="after")
396 def _check_maintainers_exist(self):
397 if (
398 not self.maintainers
399 and self.authors
400 and all(a.github_user is None for a in self.authors)
401 ):
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 )
410 return self
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"""
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})
434 if missing_categories:
435 raise ValueError(
436 f"Missing tags from bioimage.io categories: {missing_categories}"
437 )
439 return value
441 version: Version | None = None
442 """The version of the resource following SemVer 2.0."""
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
451 return value
453 version_comment: Annotated[str, MaxLen(512)] | None = None
454 """A comment on the version of the resource."""
457class GenericDescrBase(GenericModelDescrBase):
458 """Base for all resource descriptions except for the model descriptions"""
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"""
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
475 badges: list[BadgeDescr] = Field( # pyright: ignore[reportUnknownVariableType]
476 default_factory=list
477 )
478 """badges associated with this resource"""
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.)"""
501ResourceDescrType = TypeVar("ResourceDescrType", bound=GenericDescrBase)
504class GenericDescr(GenericDescrBase, extra="ignore"):
505 """Specification of the fields used in a generic bioimage.io-compliant resource description file (RDF).
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 """
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."""
520 id: (
521 Annotated[ResourceId, Field(examples=["affable-shark", "ambitious-sloth"])]
522 | None
523 ) = None
524 """bioimage.io-wide unique resource identifier
525 assigned by bioimage.io; version **un**specific."""
527 parent: ResourceId | None = None
528 """The description from which this one is derived"""
530 source: HttpUrl | None = None
531 """The primary source of the resource"""
533 @field_validator("type", mode="after")
534 @classmethod
535 def check_specific_types(cls, value: str) -> str:
536 if value in KNOWN_SPECIFIC_RESOURCE_TYPES:
537 raise ValueError(
538 f"Use the {value} description instead of this generic description for"
539 + f" your '{value}' resource."
540 )
542 return value