Skip to content

Commit 1abd2a1

Browse files
authored
Merge pull request crs4#177 from kikkomep/fix/issue-176
fix: support non-HTTP URI schemes for (Web)Data Entities (crs4#176)
2 parents 6f850d0 + 45b2f49 commit 1abd2a1

9 files changed

Lines changed: 562 additions & 101 deletions

File tree

rocrate_validator/errors.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,15 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from typing import Optional
15+
from __future__ import annotations
16+
17+
from pathlib import Path
18+
from typing import TYPE_CHECKING, Optional, Union
19+
20+
if TYPE_CHECKING:
21+
# Imported only for type-checking to avoid a circular import:
22+
# rocrate_validator.utils.uri imports this module at runtime.
23+
from rocrate_validator.utils.uri import URI
1624

1725

1826
class ROCValidatorError(Exception):
@@ -243,29 +251,34 @@ def __repr__(self):
243251
class ROCrateInvalidURIError(ROCValidatorError):
244252
"""Raised when an invalid URI is provided."""
245253

246-
def __init__(self, uri: str, message: Optional[str] = None):
254+
def __init__(self, uri: Union[str, Path, URI], message: Optional[str] = None):
247255
self._uri = uri
248256
self._message = message or self.default_error_message(uri)
249257

250258
@property
251-
def uri(self) -> Optional[str]:
252-
"""The invalid URI."""
259+
def uri(self) -> Union[str, Path, URI]:
260+
"""The invalid URI, as originally provided (str, Path, or URI)."""
253261
return self._uri
254262

255263
@property
256-
def message(self) -> Optional[str]:
264+
def uri_string(self) -> str:
265+
"""The invalid URI normalised to its string form."""
266+
return str(self._uri)
267+
268+
@property
269+
def message(self) -> str:
257270
"""The error message."""
258271
return self._message
259272

260273
def __str__(self) -> str:
261274
return self._message
262275

263-
def __repr__(self):
276+
def __repr__(self) -> str:
264277
return f"ROCrateInvalidURIError({self._uri!r})"
265278

266279
@classmethod
267-
def default_error_message(cls, uri: str) -> str:
268-
return f"\"{uri}\" is not a valid RO-Crate URI. "\
280+
def default_error_message(cls, uri: Union[str, Path, URI]) -> str:
281+
return f"\"{str(uri)}\" is not a valid RO-Crate URI. "\
269282
"It MUST be either a local path to the RO-Crate root directory or a local/remote RO-Crate ZIP file."
270283

271284

rocrate_validator/profiles/ro-crate/must/4_data_entity_metadata.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,13 @@ def check_availability(self, context: ValidationContext) -> bool:
3838
return True
3939
# Perform the check
4040
result = True
41+
# Web-based Data Entities (absolute URIs with any scheme other than `file`,
42+
# e.g. http://, https://, ftp://, scp://, s3://, ...) are not required to
43+
# be part of the local payload per the RO-Crate specification.
4144
for entity in context.ro_crate.metadata.get_data_entities(exclude_web_data_entities=True):
4245
assert entity.id is not None, "Entity has no @id"
4346
logger.debug("Ensure the presence of the Data Entity '%s' within the RO-Crate", entity.id)
4447
try:
45-
logger.debug("Ensure the presence of the Data Entity '%s' within the RO-Crate", entity.id)
4648
if entity.has_local_identifier():
4749
logger.debug(
4850
"Ignoring the Data Entity '%s' as it is a local entity with a local identifier. "

rocrate_validator/profiles/ro-crate/should/5_web_data_entity_metadata.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from rocrate_validator.models import ValidationContext
1717
from rocrate_validator.requirements.python import (PyFunctionCheck, check,
1818
requirement)
19+
from rocrate_validator.utils.uri import AvailabilityStatus
1920

2021
# set up logging
2122
logger = logging.getLogger(__name__)
@@ -32,13 +33,37 @@ class WebDataEntityRecommendedChecker(PyFunctionCheck):
3233
def check_availability(self, context: ValidationContext) -> bool:
3334
"""
3435
Check if the Web-based Data Entity is directly downloadable
35-
by a simple retrieval (e.g. HTTP GET) permitting redirection and HTTP/HTTPS URIs
36+
by a simple retrieval (e.g. HTTP GET) permitting redirection and HTTP/HTTPS URIs.
37+
38+
Resources that cannot be natively retrieved by the validator (e.g.
39+
`scp://`, `s3://`, `sftp://`) or that are protected by an authorization
40+
mechanism (HTTP 401/403) are reported as recommendation-level issues
41+
and logged as warnings, without invalidating the validation.
3642
"""
3743
result = True
3844
for entity in context.ro_crate.metadata.get_web_data_entities():
3945
assert entity.id is not None, "Entity has no @id"
4046
try:
41-
if not entity.is_available():
47+
status = entity.check_availability()
48+
if status == AvailabilityStatus.AVAILABLE:
49+
continue
50+
if status == AvailabilityStatus.UNAUTHORIZED:
51+
msg = (
52+
f"Web-based Data Entity {entity.id} is protected by an "
53+
f"authorization mechanism; availability could not be verified"
54+
)
55+
logger.warning(msg)
56+
context.result.add_issue(msg, self)
57+
elif status == AvailabilityStatus.UNCHECKABLE:
58+
scheme = entity.id_as_uri.scheme
59+
msg = (
60+
f"Web-based Data Entity {entity.id} uses scheme "
61+
f"'{scheme}' which is not natively supported by the "
62+
f"validator; availability could not be verified"
63+
)
64+
logger.warning(msg)
65+
context.result.add_issue(msg, self)
66+
else:
4267
context.result.add_issue(
4368
f'Web-based Data Entity {entity.id} is not available', self)
4469
result = False
@@ -59,6 +84,12 @@ def check_content_size(self, context: ValidationContext) -> bool:
5984
result = True
6085
for entity in context.ro_crate.metadata.get_web_data_entities():
6186
assert entity.id is not None, "Entity has no @id"
87+
# Skip entities whose scheme the validator cannot natively fetch
88+
# (e.g. scp://, s3://): without retrieving the content there is
89+
# no actual size to compare `contentSize` against. Reachability
90+
# is then checked separately via `is_available()` below.
91+
if not entity.id_as_uri.is_natively_checkable():
92+
continue
6293
if entity.is_available():
6394
content_size = entity.get_property("contentSize")
6495
if content_size and int(content_size) != context.ro_crate.get_external_file_size(entity.id):

rocrate_validator/rocrate.py

Lines changed: 57 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from rocrate_validator.errors import ROCrateInvalidURIError
3131
from rocrate_validator.utils.uri import validate_rocrate_uri
3232
from rocrate_validator.utils.http import HttpRequester
33-
from rocrate_validator.utils.uri import URI
33+
from rocrate_validator.utils.uri import URI, AvailabilityStatus, is_external_reference
3434

3535
# set up logging
3636
logger = logging.getLogger(__name__)
@@ -140,8 +140,17 @@ def id_as_path(self) -> Path:
140140
@classmethod
141141
def get_id_as_uri(cls, entity_id: str, ro_crate: ROCrate) -> URI:
142142
assert entity_id, "Entity ID cannot be None"
143-
if entity_id.startswith("http"):
143+
# Per RO-Crate 1.1 § 4.2.2, an `@id` is either a relative URI path or
144+
# an external URI/IRI (RFC 3986/3987). External references are used
145+
# as-is (without resolving them against the crate URI) so the entity
146+
# is classified as remote/web-based; this covers both authority-based
147+
# forms (``http://``, ``scp://``) and scheme-only ones (``urn:``,
148+
# ``doi:``, ``arcp:``).
149+
if is_external_reference(entity_id):
144150
return URI(entity_id)
151+
# Otherwise the `@id` is a relative path: if the RO-Crate itself is
152+
# remote, resolve it against the crate URI so the entity is still
153+
# classified as remote/web-based.
145154
if ro_crate.uri.is_remote_resource():
146155
if entity_id.startswith("./"):
147156
return URI(f"{ro_crate.uri}/{entity_id[2:]}")
@@ -208,58 +217,66 @@ def raw_data(self) -> object:
208217
def is_local(self) -> bool:
209218
return not self.is_remote()
210219

211-
def is_available(self) -> bool:
220+
def check_availability(self) -> AvailabilityStatus:
221+
"""
222+
Return a fine-grained availability status for this entity.
223+
224+
This is the primary check; :meth:`is_available` is the boolean
225+
shortcut built on top of it. The status distinguishes definitely
226+
unavailable resources, auth-protected ones, and remote URIs whose
227+
scheme the validator cannot natively check (scp://, s3://, ...).
228+
"""
212229
try:
213-
# check if the entity points to an external file
214-
if self.id.startswith("http"):
230+
entity_uri = self.id_as_uri
231+
# Remote entities with a scheme we can natively reach are checked
232+
# by inspecting the remote response status.
233+
if entity_uri.is_natively_checkable():
215234
logger.debug("Checking the availability of a remote entity")
216-
return self.ro_crate.get_external_file_size(self.id) > 0
217-
218-
# check if the entity is part of the local RO-Crate
235+
return entity_uri.check_availability()
236+
237+
# Remote entities with a non-natively-checkable scheme cannot be
238+
# verified (scp://, sftp://, s3://, ...): report UNCHECKABLE so
239+
# callers can warn without invalidating the validation.
240+
if entity_uri.is_remote_resource():
241+
logger.debug(
242+
"Cannot natively verify availability for entity '%s' (scheme '%s')",
243+
self.id,
244+
entity_uri.scheme,
245+
)
246+
return AvailabilityStatus.UNCHECKABLE
247+
248+
# Local entity: locate it inside the (local or remote) RO-Crate.
219249
if self.ro_crate.uri.is_local_resource():
220-
# check if the file exists in the local file system
221250
if isinstance(self.ro_crate, ROCrateLocalFolder):
222-
logger.debug(
223-
"Checking the availability of a local entity in a local folder"
224-
)
225-
return self.ro_crate.has_file(
226-
self.id_as_path
227-
) or self.ro_crate.has_directory(self.id_as_path)
228-
# check if the file exists in the local zip file
251+
found = self.ro_crate.has_file(self.id_as_path) or self.ro_crate.has_directory(self.id_as_path)
252+
return AvailabilityStatus.AVAILABLE if found else AvailabilityStatus.UNAVAILABLE
229253
if isinstance(self.ro_crate, ROCrateLocalZip):
230-
logger.debug(
231-
"Checking the availability of a local entity in a local zip file"
232-
)
233-
# Skip the check for the root of a ZIP archive
234254
if self.id == "./":
235-
logger.debug(
236-
"Skipping the check for the presence of the Data Entity '%s' within the RO-Crate "
237-
"as it is the root of a ZIP archive",
238-
self.id,
239-
)
240-
return True
241-
return self.ro_crate.has_directory(
255+
return AvailabilityStatus.AVAILABLE
256+
found = self.ro_crate.has_directory(unquote(str(self.id))) or self.ro_crate.has_file(
242257
unquote(str(self.id))
243-
) or self.ro_crate.has_file(unquote(str(self.id)))
258+
)
259+
return AvailabilityStatus.AVAILABLE if found else AvailabilityStatus.UNAVAILABLE
244260

245-
# check if the entity is part of the remote RO-Crate
246-
logger.debug(
247-
"Checking the availability of a remote entity in a remote RO-Crate"
248-
)
249261
if self.ro_crate.uri.is_remote_resource():
250262
if self.id == "./":
251-
return self.ro_crate.get_file_size(Path(self.id_as_uri())) > 0
252-
return self.ro_crate.has_directory(
253-
unquote(str(self.id))
254-
) or self.ro_crate.has_file(unquote(str(self.id)))
263+
found = self.ro_crate.get_file_size(Path(self.id_as_uri())) > 0
264+
else:
265+
found = self.ro_crate.has_directory(unquote(str(self.id))) or self.ro_crate.has_file(
266+
unquote(str(self.id))
267+
)
268+
return AvailabilityStatus.AVAILABLE if found else AvailabilityStatus.UNAVAILABLE
255269
except Exception as e:
256270
if logger.isEnabledFor(logging.DEBUG):
257271
logger.exception(e)
258-
return False
272+
return AvailabilityStatus.UNAVAILABLE
259273

260-
raise ROCrateInvalidURIError(
261-
uri=self.id, message="Could not determine the availability of the entity"
262-
)
274+
# Fallthrough: the crate URI is neither a recognized local nor a
275+
# remote resource — the entity location cannot be determined.
276+
raise ROCrateInvalidURIError(uri=self.id, message="Could not determine the availability of the entity")
277+
278+
def is_available(self) -> bool:
279+
return self.check_availability() == AvailabilityStatus.AVAILABLE
263280

264281
def get_size(self) -> int:
265282
try:

0 commit comments

Comments
 (0)