Coverage for src/bioimageio/spec/generic/v0_2.py: 93%

187 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 Mapping, 

12 Sequence, 

13 TypeVar, 

14 cast, 

15) 

16 

17import annotated_types 

18import pydantic 

19from annotated_types import Len, LowerCase, MaxLen 

20from pydantic import ( 

21 EmailStr, 

22 Field, 

23 RootModel, 

24 ValidationInfo, 

25 field_validator, 

26 model_validator, 

27) 

28from typing_extensions import Annotated, Self, assert_never 

29 

30from .._internal.common_nodes import Node, ResourceDescrBase 

31from .._internal.constants import TAG_CATEGORIES 

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

33from .._internal.io import ( 

34 BioimageioYamlContent, 

35 WithSuffix, 

36 YamlValue, 

37 wo_special_file_name, 

38) 

39from .._internal.io_packaging import FileSource_package, include_in_package 

40from .._internal.type_guards import is_sequence 

41from .._internal.types import ( 

42 DeprecatedLicenseId, 

43 FilePath, 

44 FileSource, 

45 LicenseId, 

46 NotEmpty, 

47) 

48from .._internal.types import Doi as Doi 

49from .._internal.types import OrcidId as OrcidId 

50from .._internal.types import RelativeFilePath as RelativeFilePath 

51from .._internal.url import HttpUrl as HttpUrl 

52from .._internal.validated_string import ValidatedString 

53from .._internal.validator_annotations import AfterValidator, RestrictCharacters 

54from .._internal.version_type import Version as Version 

55from ._v0_2_converter import convert_from_older_format as _convert_from_older_format 

56 

57 

58class ResourceId(ValidatedString): 

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

60 Annotated[ 

61 NotEmpty[str], 

62 AfterValidator(str.lower), # convert upper case on the fly 

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

64 annotated_types.Predicate( 

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

66 ), 

67 ] 

68 ] 

69 

70 

71KNOWN_SPECIFIC_RESOURCE_TYPES = ( 

72 "application", 

73 "collection", 

74 "dataset", 

75 "model", 

76 "notebook", 

77) 

78 

79VALID_COVER_IMAGE_EXTENSIONS = ( 

80 ".gif", 

81 ".jpeg", 

82 ".jpg", 

83 ".png", 

84 ".svg", 

85 ".tif", 

86 ".tiff", 

87) 

88 

89 

90_FileSource_cover = Annotated[ 

91 FileSource_package, 

92 WithSuffix(VALID_COVER_IMAGE_EXTENSIONS, case_sensitive=False), 

93] 

94 

95 

96class AttachmentsDescr(Node, extra="allow"): 

97 files: list[FileSource_package] = Field( 

98 default_factory=cast(Callable[[], List[FileSource]], list) 

99 ) 

100 """File attachments""" 

101 

102 

103def _remove_slashes(s: str): 

104 return s.replace("/", "").replace("\\", "") 

105 

106 

107class Uploader(Node): 

108 email: EmailStr 

109 """Email""" 

110 name: Annotated[str, AfterValidator(_remove_slashes)] | None = None 

111 """name""" 

112 

113 

114class _Person(Node): 

115 affiliation: str | None = None 

116 """Affiliation""" 

117 

118 email: EmailStr | None = None 

119 """Email""" 

120 

121 orcid: Annotated[OrcidId | None, Field(examples=["0000-0001-2345-6789"])] = None 

122 """An [ORCID iD](https://support.orcid.org/hc/en-us/sections/360001495313-What-is-ORCID 

123 ) in hyphenated groups of 4 digits, (and [valid]( 

124 https://support.orcid.org/hc/en-us/articles/360006897674-Structure-of-the-ORCID-Identifier 

125 ) as per ISO 7064 11,2.) 

126 """ 

127 

128 

129class Author(_Person): 

130 name: Annotated[str, AfterValidator(_remove_slashes)] 

131 github_user: str | None = None # TODO: validate github_user 

132 

133 

134class Maintainer(_Person): 

135 name: Annotated[str, AfterValidator(_remove_slashes)] | None = None 

136 github_user: str 

137 

138 

139class BadgeDescr(Node): 

140 """A custom badge""" 

141 

142 label: Annotated[str, Field(examples=["Open in Colab"])] 

143 """badge label to display on hover""" 

144 

145 icon: Annotated[ 

146 Annotated[ 

147 FilePath | RelativeFilePath, 

148 AfterValidator(wo_special_file_name), 

149 include_in_package, 

150 ] 

151 | HttpUrl 

152 | pydantic.HttpUrl 

153 | None, 

154 Field(examples=["https://colab.research.google.com/assets/colab-badge.svg"]), 

155 ] = None 

156 """badge icon (included in bioimage.io package if not a URL)""" 

157 

158 url: Annotated[ 

159 HttpUrl, 

160 Field( 

161 examples=[ 

162 "https://colab.research.google.com/github/HenriquesLab/ZeroCostDL4Mic/blob/master/Colab_notebooks/U-net_2D_ZeroCostDL4Mic.ipynb" 

163 ] 

164 ), 

165 ] 

166 """target URL""" 

167 

168 

169class CiteEntry(Node): 

170 text: str 

171 """free text description""" 

172 

173 doi: Doi | None = None 

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

175 See https://www.doi.org/ for details. (alternatively specify `url`)""" 

176 

177 @field_validator("doi", mode="before") 

178 @classmethod 

179 def accept_prefixed_doi(cls, doi: Any) -> Any: 

180 if isinstance(doi, str): 

181 for doi_prefix in ("https://doi.org/", "http://dx.doi.org/"): 

182 if doi.startswith(doi_prefix): 

183 doi = doi[len(doi_prefix) :] 

184 break 

185 

186 return doi 

187 

188 url: str | None = None 

189 """URL to cite (preferably specify a `doi` instead)""" 

190 

191 @model_validator(mode="after") 

192 def _check_doi_or_url(self) -> 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 LinkedResource(Node): 

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

201 

202 id: ResourceId 

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

204 

205 version_number: int | None = None 

206 """version number (n-th published version, not the semantic version) of linked resource""" 

207 

208 

209class GenericModelDescrBase(ResourceDescrBase): 

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

211 

212 name: Annotated[ 

213 NotEmpty[str], warn(Annotated[str, MaxLen(128)], "Longer than 128 characters.") 

214 ] 

215 """A human-friendly name of the resource description""" 

216 

217 description: str 

218 

219 covers: list[_FileSource_cover] = Field( 

220 default_factory=cast(Callable[[], List[_FileSource_cover]], list), 

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

222 description=( 

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

224 " ratio width to height of 2:1.\nThe supported image formats are:" 

225 f" {VALID_COVER_IMAGE_EXTENSIONS}" 

226 ), 

227 ) 

228 """Cover images. Please use an image smaller than 500KB and an aspect ratio width to height of 2:1.""" 

229 

230 id_emoji: ( 

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

232 | None 

233 ) = None 

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

235 

236 authors: list[Author] = Field( # pyright: ignore[reportUnknownVariableType] 

237 default_factory=list 

238 ) 

239 """The authors are the creators of the RDF and the primary points of contact.""" 

240 

241 @field_validator("authors", mode="before") 

242 @classmethod 

243 def accept_author_strings(cls, authors: Any | Sequence[Any]) -> Any: 

244 """we unofficially accept strings as author entries""" 

245 if is_sequence(authors): 

246 authors = [{"name": a} if isinstance(a, str) else a for a in authors] 

247 

248 if not authors: 

249 issue_warning("missing", value=authors, field="authors") 

250 

251 return authors 

252 

253 attachments: AttachmentsDescr | None = None 

254 """file and other attachments""" 

255 

256 cite: list[CiteEntry] = Field( # pyright: ignore[reportUnknownVariableType] 

257 default_factory=list 

258 ) 

259 """citations""" 

260 

261 @field_validator("cite", mode="after") 

262 @classmethod 

263 def _warn_empty_cite(cls, value: Any): 

264 if not value: 

265 issue_warning("missing", value=value, field="cite") 

266 

267 return value 

268 

269 config: Annotated[ 

270 dict[str, YamlValue], 

271 Field( 

272 examples=[ 

273 { 

274 "bioimageio": { 

275 "my_custom_key": 3837283, 

276 "another_key": {"nested": "value"}, 

277 }, 

278 "imagej": {"macro_dir": "path/to/macro/file"}, 

279 } 

280 ], 

281 ), 

282 ] = Field(default_factory=dict) 

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

284 This means you should not store, for example, a github repo URL in `config` since we already have the 

285 `git_repo` field defined in the spec. 

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

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

288 for example: 

289 ```yaml 

290 config: 

291 bioimageio: # here is the domain name 

292 my_custom_key: 3837283 

293 another_key: 

294 nested: value 

295 imagej: # config specific to ImageJ 

296 macro_dir: path/to/macro/file 

297 ``` 

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

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

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

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

302 

303 download_url: HttpUrl | None = None 

304 """URL to download the resource from (deprecated)""" 

305 

306 git_repo: Annotated[ 

307 str | None, 

308 Field( 

309 examples=[ 

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

311 ], 

312 ), 

313 ] = None 

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

315 

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

317 """An icon for illustration""" 

318 

319 links: Annotated[ 

320 list[str], 

321 Field( 

322 examples=[ 

323 ( 

324 "ilastik/ilastik", 

325 "deepimagej/deepimagej", 

326 "zero/notebook_u-net_3d_zerocostdl4mic", 

327 ) 

328 ], 

329 ), 

330 ] = Field(default_factory=list) 

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

332 

333 uploader: Uploader | None = None 

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

335 

336 # TODO: (py>3.8) remove pyright ignore 

337 maintainers: list[Maintainer] = Field( # pyright: ignore[reportUnknownVariableType] 

338 default_factory=list 

339 ) 

340 """Maintainers of this resource. 

341 If not specified `authors` are maintainers and at least some of them should specify their `github_user` name""" 

342 

343 rdf_source: FileSource | None = None 

344 """Resource description file (RDF) source; used to keep track of where an rdf.yaml was loaded from. 

345 Do not set this field in a YAML file.""" 

346 

347 tags: Annotated[ 

348 list[str], 

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

350 ] = Field(default_factory=list) 

351 """Associated tags""" 

352 

353 @as_warning 

354 @field_validator("tags") 

355 @classmethod 

356 def warn_about_tag_categories( 

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

358 ) -> list[str]: 

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

360 missing_categories: list[Mapping[str, Sequence[str]]] = [] 

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

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

363 missing_categories.append({cat: entries}) 

364 

365 if missing_categories: 

366 raise ValueError( 

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

368 ) 

369 

370 return value 

371 

372 version: Version | None = None 

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

374 

375 version_number: int | None = None 

376 """version number (n-th published version, not the semantic version)""" 

377 

378 

379class GenericDescrBase(GenericModelDescrBase): 

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

381 

382 implemented_format_version: ClassVar[Literal["0.2.4"]] = "0.2.4" 

383 if TYPE_CHECKING: 

384 format_version: Literal["0.2.4"] = "0.2.4" 

385 else: 

386 format_version: Literal["0.2.4"] 

387 """The format version of this resource specification 

388 (not the `version` of the resource description) 

389 When creating a new resource always use the latest micro/patch version described here. 

390 The `format_version` is important for any consumer software to understand how to parse the fields. 

391 """ 

392 

393 @model_validator(mode="before") 

394 @classmethod 

395 def _convert_from_older_format( 

396 cls, data: BioimageioYamlContent, / 

397 ) -> BioimageioYamlContent: 

398 _convert_from_older_format(data) 

399 return data 

400 

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

402 default_factory=list 

403 ) 

404 """badges associated with this resource""" 

405 

406 documentation: Annotated[ 

407 FileSource | None, 

408 Field( 

409 examples=[ 

410 "https://raw.githubusercontent.com/bioimage-io/spec-bioimage-io/main/example_descriptions/models/unet2d_nuclei_broad/README.md", 

411 "README.md", 

412 ], 

413 ), 

414 ] = None 

415 """URL or relative path to a markdown file with additional documentation. 

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

417 

418 license: Annotated[ 

419 LicenseId | DeprecatedLicenseId | str | None, 

420 Field(union_mode="left_to_right", examples=["CC0-1.0", "MIT", "BSD-2-Clause"]), 

421 ] = None 

422 """A [SPDX license identifier](https://spdx.org/licenses/). 

423 We do not support custom license beyond the SPDX license list, if you need that please 

424 [open a GitHub issue](https://github.com/bioimage-io/spec-bioimage-io/issues/new/choose 

425 ) to discuss your intentions with the community.""" 

426 

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

428 @classmethod 

429 def deprecated_spdx_license( 

430 cls, value: LicenseId | DeprecatedLicenseId | str | None 

431 ): 

432 if isinstance(value, LicenseId): 

433 pass 

434 elif value is None: 

435 issue_warning("missing", value=value, field="license") 

436 elif isinstance(value, DeprecatedLicenseId): 

437 issue_warning( 

438 "'{value}' is a deprecated license identifier.", 

439 value=value, 

440 field="license", 

441 ) 

442 elif isinstance(value, str): 

443 issue_warning( 

444 "'{value}' is an unknown license identifier.", 

445 value=value, 

446 field="license", 

447 ) 

448 else: 

449 assert_never(value) 

450 

451 return value 

452 

453 

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

455 

456 

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

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

459 

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

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

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

463 """ 

464 

465 type: Annotated[str, LowerCase, Field(frozen=True)] = "generic" 

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

467 

468 id: ( 

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

470 | None 

471 ) = None 

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

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

474 

475 source: HttpUrl | None = None 

476 """The primary source of the resource""" 

477 

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

479 @classmethod 

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

481 if value in KNOWN_SPECIFIC_RESOURCE_TYPES: 

482 raise ValueError( 

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

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

485 ) 

486 

487 return value