Skip to content

Commit 2d329a1

Browse files
authored
Merge pull request #710 from bioimage-io/dev
FAIR fields and Hypha Upload
2 parents 4414212 + 58a5434 commit 2d329a1

File tree

18 files changed

+459
-103
lines changed

18 files changed

+459
-103
lines changed

bioimageio/spec/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
save_bioimageio_package_as_folder,
4242
save_bioimageio_package_to_stream,
4343
)
44+
from ._upload import upload
4445
from .application import AnyApplicationDescr, ApplicationDescr
4546
from .dataset import AnyDatasetDescr, DatasetDescr
4647
from .generic import AnyGenericDescr, GenericDescr
@@ -93,6 +94,7 @@
9394
"summary",
9495
"update_format",
9596
"update_hashes",
97+
"upload",
9698
"utils",
9799
"validate_format",
98100
"ValidationContext",

bioimageio/spec/_internal/_settings.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ def _expand_user(cls, value: Path):
4242
- If this endpoints fails, we fall back to **id_map**.
4343
"""
4444

45+
hypha_upload: str = (
46+
"https://hypha.aicell.io/public/services/artifact-manager/create"
47+
)
48+
"""URL to the upload endpoint for bioimageio resources."""
49+
50+
hypha_upload_token: Optional[str] = None
51+
"""Hypha API token to use for uploads.
52+
53+
By setting this token you agree to our terms of service at https://bioimage.io/#/toc.
54+
55+
How to obtain a token:
56+
1. Login to https://bioimage.io
57+
2. Generate a new token at https://bioimage.io/#/api?tab=hypha-rpc
58+
"""
59+
4560
id_map: str = (
4661
"https://uk1s3.embassy.ebi.ac.uk/public-datasets/bioimage.io/id_map.json"
4762
)

bioimageio/spec/_internal/common_nodes.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,12 +222,12 @@ def load(
222222
file_descrs = extract_file_descrs({k: v for k, v in data.items()})
223223
populate_cache(file_descrs) # TODO: add progress bar
224224

225-
with context:
225+
with context.replace(log_warnings=context.warning_level <= INFO):
226226
rd, errors, val_warnings = cls._load_impl(deepcopy_yaml_value(data))
227227

228228
if context.warning_level > INFO:
229229
all_warnings_context = context.replace(
230-
warning_level=INFO, log_warnings=False
230+
warning_level=INFO, log_warnings=False, raise_errors=False
231231
)
232232
# raise all validation warnings by reloading
233233
with all_warnings_context:

bioimageio/spec/_internal/field_warning.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@ def wrapper(value: Any, info: ValidationInfo) -> Any:
7272
except (AssertionError, ValueError) as e:
7373
issue_warning(
7474
msg or ",".join(e.args),
75-
value=value,
76-
severity=severity,
75+
field=info.field_name,
76+
log_depth=2,
7777
msg_context=msg_context,
78+
severity=severity,
79+
value=value,
7880
)
7981

8082
return value
@@ -135,10 +137,12 @@ def issue_warning(
135137
severity: WarningSeverity = WARNING,
136138
msg_context: Optional[Dict[str, Any]] = None,
137139
field: Optional[str] = None,
140+
log_depth: int = 1,
138141
):
139142
msg_context = {"value": value, "severity": severity, **(msg_context or {})}
143+
140144
if severity >= (ctxt := get_validation_context()).warning_level:
141145
raise PydanticCustomError("warning", msg, msg_context)
142146
elif ctxt.log_warnings:
143147
log_msg = (field + ": " if field else "") + (msg.format(**msg_context))
144-
logger.opt(depth=1).log(severity, log_msg)
148+
logger.opt(depth=log_depth).log(severity, log_msg)

bioimageio/spec/_internal/node.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@ class Node(
3232
frozen=False,
3333
model_title_generator=_node_title_generator,
3434
populate_by_name=True,
35-
revalidate_instances="never",
35+
revalidate_instances="always",
3636
use_attribute_docstrings=True,
3737
validate_assignment=True,
38-
validate_default=False,
39-
validate_return=True, # TODO: check if False here would bring a speedup and can still be safe
38+
validate_default=True,
39+
validate_return=True,
4040
):
4141
"""""" # empty docstring to remove all pydantic docstrings from the pdoc spec docs
4242

bioimageio/spec/_internal/types.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@
1010
from typing_extensions import Annotated, Literal
1111

1212
from .constants import DOI_REGEX, SI_UNIT_REGEX
13+
from .field_warning import AfterWarner
1314
from .io import FileSource, PermissiveFileSource, RelativeFilePath
1415
from .io_basics import AbsoluteDirectory, AbsoluteFilePath, FileName, FilePath, Sha256
1516
from .io_packaging import FileSource_
1617
from .license_id import DeprecatedLicenseId, LicenseId
18+
from .type_guards import is_sequence
1719
from .url import HttpUrl
1820
from .validated_string import ValidatedString
1921
from .validator_annotations import AfterValidator, BeforeValidator
2022
from .version_type import Version
23+
from .warning_levels import ALERT
2124

2225
UTC = timezone.utc
2326

@@ -45,8 +48,23 @@
4548
"Version",
4649
]
4750
S = TypeVar("S", bound=Sequence[Any])
51+
A = TypeVar("A", bound=Any)
4852
NotEmpty = Annotated[S, annotated_types.MinLen(1)]
4953

54+
55+
def _validate_fair(value: Any) -> Any:
56+
"""Raise trivial values."""
57+
if value is None or (is_sequence(value) and not value):
58+
raise ValueError("Needs to be filled for FAIR compliance")
59+
60+
return value
61+
62+
63+
FAIR = Annotated[
64+
A,
65+
AfterWarner(_validate_fair, severity=ALERT),
66+
]
67+
5068
ImportantFileSource = FileSource_
5169
"""DEPRECATED alias, use `FileSource` instead"""
5270

bioimageio/spec/_upload.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import collections.abc
2+
import io
3+
from typing import Union
4+
from zipfile import ZipFile
5+
6+
import httpx
7+
from loguru import logger
8+
9+
from bioimageio.spec._io import load_description
10+
11+
from ._description import (
12+
InvalidDescr,
13+
ResourceDescr,
14+
build_description,
15+
)
16+
from ._internal._settings import settings
17+
from ._internal.common_nodes import ResourceDescrBase
18+
from ._internal.io import BioimageioYamlContent, get_reader
19+
from ._internal.io_basics import BIOIMAGEIO_YAML
20+
from ._internal.io_utils import write_yaml
21+
from ._internal.validation_context import get_validation_context
22+
from ._package import get_resource_package_content
23+
from .common import HttpUrl, PermissiveFileSource
24+
25+
26+
# TODO: remove alpha stage warning
27+
def upload(
28+
source: Union[PermissiveFileSource, ZipFile, ResourceDescr, BioimageioYamlContent],
29+
/,
30+
) -> HttpUrl:
31+
"""Upload a new resource description (version) to the hypha server to be shared at bioimage.io.
32+
To edit an existing resource **version**, please login to https://bioimage.io and use the web interface.
33+
34+
WARNING: This upload function is in alpha stage and might change in the future.
35+
36+
Args:
37+
source: The resource description to upload.
38+
39+
Returns:
40+
A URL to the uploaded resource description.
41+
Note: It might take some time until the resource is processed and available for download from the returned URL.
42+
"""
43+
44+
if settings.hypha_upload_token is None:
45+
raise ValueError(
46+
"""
47+
Upload token is not set. Please set BIOIMAGEIO_HYPHA_UPLOAD_TOKEN in your environment variables.
48+
By setting this token you agree to our terms of service at https://bioimage.io/#/toc.
49+
50+
How to obtain a token:
51+
1. Login to https://bioimage.io
52+
2. Generate a new token at https://bioimage.io/#/api?tab=hypha-rpc
53+
"""
54+
)
55+
56+
if isinstance(source, ResourceDescrBase):
57+
# If source is already a ResourceDescr, we can use it directly
58+
descr = source
59+
elif isinstance(source, dict):
60+
descr = build_description(source)
61+
else:
62+
descr = load_description(source)
63+
64+
if isinstance(descr, InvalidDescr):
65+
raise ValueError("Uploading invalid resource descriptions is not allowed.")
66+
67+
if descr.type != "model":
68+
raise NotImplementedError(
69+
f"For now, only model resources can be uploaded (got type={descr.type})."
70+
)
71+
72+
if descr.id is not None:
73+
raise ValueError(
74+
"You cannot upload a resource with an id. Please remove the id from the description and make sure to upload a new non-existing resource. To edit an existing resource, please use the web interface at https://bioimage.io."
75+
)
76+
77+
content = get_resource_package_content(descr)
78+
79+
metadata = content[BIOIMAGEIO_YAML]
80+
assert isinstance(metadata, dict)
81+
manifest = dict(metadata)
82+
83+
# only admins can upload a resource with a version
84+
artifact_version = "stage" # if descr.version is None else str(descr.version)
85+
86+
# Create new model
87+
r = httpx.post(
88+
settings.hypha_upload,
89+
json={
90+
"parent_id": "bioimage-io/bioimage.io",
91+
"alias": (
92+
descr.id or "{animal_adjective}-{animal}"
93+
), # TODO: adapt for non-model uploads,
94+
"type": descr.type,
95+
"manifest": manifest,
96+
"version": artifact_version,
97+
},
98+
headers=(
99+
headers := {
100+
"Authorization": f"Bearer {settings.hypha_upload_token}",
101+
"Content-Type": "application/json",
102+
}
103+
),
104+
)
105+
106+
response = r.json()
107+
artifact_id = response.get("id")
108+
if artifact_id is None:
109+
try:
110+
logger.error("Response detail: {}", "".join(response["detail"]))
111+
except Exception:
112+
logger.error("Response: {}", response)
113+
114+
raise RuntimeError(f"Upload did not return resource id: {response}")
115+
else:
116+
logger.info("Uploaded resource description {}", artifact_id)
117+
118+
for file_name, file_source in content.items():
119+
# Get upload URL for a file
120+
response = httpx.post(
121+
settings.hypha_upload.replace("/create", "/put_file"),
122+
json={
123+
"artifact_id": artifact_id,
124+
"file_path": file_name,
125+
},
126+
headers=headers,
127+
)
128+
upload_url = response.raise_for_status().json()
129+
130+
# Upload file to the provided URL
131+
if isinstance(file_source, collections.abc.Mapping):
132+
buf = io.BytesIO()
133+
write_yaml(file_source, buf)
134+
files = {file_name: buf}
135+
else:
136+
files = {file_name: get_reader(file_source)}
137+
138+
response = httpx.put(
139+
upload_url,
140+
files=files, # pyright: ignore[reportArgumentType]
141+
# TODO: follow up on https://github.com/encode/httpx/discussions/3611
142+
headers={"Content-Type": ""}, # Important for S3 uploads
143+
)
144+
logger.info("Uploaded '{}' successfully", file_name)
145+
146+
# Update model status
147+
manifest["status"] = "request-review"
148+
response = httpx.post(
149+
settings.hypha_upload.replace("/create", "/edit"),
150+
json={
151+
"artifact_id": artifact_id,
152+
"version": artifact_version,
153+
"manifest": manifest,
154+
},
155+
headers=headers,
156+
)
157+
logger.info(
158+
"Updated status of {}/{} to 'request-review'", artifact_id, artifact_version
159+
)
160+
logger.warning(
161+
"Upload successfull. Please note that the uploaded resource might not be available for download immediately."
162+
)
163+
with get_validation_context().replace(perform_io_checks=False):
164+
return HttpUrl(
165+
f"https://hypha.aicell.io/bioimage-io/artifacts/{artifact_id}/files/rdf.yaml?version={artifact_version}"
166+
)

bioimageio/spec/application/v0_3.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from .._internal.io import FileDescr as FileDescr
77
from .._internal.io_basics import Sha256 as Sha256
8-
from .._internal.types import FileSource_
8+
from .._internal.types import FAIR, FileSource_
99
from .._internal.url import HttpUrl as HttpUrl
1010
from ..generic.v0_3 import VALID_COVER_IMAGE_EXTENSIONS as VALID_COVER_IMAGE_EXTENSIONS
1111
from ..generic.v0_3 import Author as Author
@@ -46,7 +46,7 @@ class ApplicationDescr(GenericDescrBase):
4646
"""The description from which this one is derived"""
4747

4848
source: Annotated[
49-
Optional[FileSource_],
49+
FAIR[Optional[FileSource_]],
5050
Field(description="URL or path to the source of the application"),
5151
] = None
5252
"""The primary source of the application"""

bioimageio/spec/dataset/v0_3.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .._internal.common_nodes import InvalidDescr
66
from .._internal.io import FileDescr as FileDescr
77
from .._internal.io_basics import Sha256 as Sha256
8+
from .._internal.types import FAIR
89
from .._internal.url import HttpUrl as HttpUrl
910
from ..generic.v0_3 import VALID_COVER_IMAGE_EXTENSIONS as VALID_COVER_IMAGE_EXTENSIONS
1011
from ..generic.v0_3 import Author as Author
@@ -53,7 +54,7 @@ class DatasetDescr(GenericDescrBase):
5354
parent: Optional[DatasetId] = None
5455
"""The description from which this one is derived"""
5556

56-
source: Optional[HttpUrl] = None
57+
source: FAIR[Optional[HttpUrl]] = None
5758
""""URL to the source of the dataset."""
5859

5960
@model_validator(mode="before")

0 commit comments

Comments
 (0)