Skip to content

Commit b4c33bd

Browse files
authored
Merge pull request #108 from crs4/develop
Updates from upstream repository.
2 parents 6a6f98d + 8306911 commit b4c33bd

32 files changed

Lines changed: 3256 additions & 538 deletions

File tree

.github/workflows/release.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ jobs:
6666
steps:
6767
# Access the tag from the first workflow's outputs
6868
- name: ⬇️ Checkout code
69-
uses: actions/checkout@v4
69+
uses: actions/checkout@v6
7070
- name: 🐍 Set up Python
71-
uses: actions/setup-python@v5
71+
uses: actions/setup-python@v6
7272
with:
7373
python-version: "3.x"
7474
- name: 🚧 Set up Python Environment

.github/workflows/testing.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ jobs:
4646
steps:
4747
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
4848
- name: ⬇️ Checkout code
49-
uses: actions/checkout@v4
49+
uses: actions/checkout@v6
5050
- name: 🐍 Set up Python v${{ env.PYTHON_VERSION }}
51-
uses: actions/setup-python@v5
51+
uses: actions/setup-python@v6
5252
with:
5353
python-version: ${{ env.PYTHON_VERSION }}
5454
- name: 🔽 Install flake8
@@ -65,9 +65,9 @@ jobs:
6565
needs: [lint]
6666
steps:
6767
- name: ⬇️ Checkout
68-
uses: actions/checkout@v4
68+
uses: actions/checkout@v6
6969
- name: 🐍 Set up Python v${{ env.PYTHON_VERSION }}
70-
uses: actions/setup-python@v5
70+
uses: actions/setup-python@v6
7171
with:
7272
python-version: ${{ env.PYTHON_VERSION }}
7373
- name: 🔄 Upgrade pip

poetry.lock

Lines changed: 506 additions & 378 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "roc-validator"
3-
version = "0.8.1"
3+
version = "0.9.0"
44
description = "A Python package to validate RO-Crates"
55
authors = [
66
"Marco Enrico Piras <kikkomep@crs4.it>",

rocrate_validator/cli/commands/validate.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from rich.rule import Rule
2626

2727
from rocrate_validator.utils import log as logging
28-
from rocrate_validator import services
28+
from rocrate_validator import constants, services
2929
from rocrate_validator.cli.commands.errors import handle_error
3030
from rocrate_validator.cli.main import cli
3131
from rocrate_validator.cli.ui.text.validate import ValidationCommandView
@@ -205,6 +205,29 @@ def validate_uri(ctx, param, value):
205205
show_default=True,
206206
help="Width of the output line",
207207
)
208+
@click.option(
209+
'--cache-max-age',
210+
type=click.INT,
211+
default=constants.DEFAULT_HTTP_CACHE_MAX_AGE,
212+
show_default=True,
213+
help="Maximum age of the HTTP cache in seconds ([bold green]-1[/bold green] for no expiration)",
214+
)
215+
@click.option(
216+
'--cache-path',
217+
type=click.Path(),
218+
default=None,
219+
show_default=True,
220+
help="Path to the HTTP cache directory",
221+
)
222+
@click.option(
223+
'-nc',
224+
'--no-cache',
225+
is_flag=True,
226+
help="Disable the HTTP cache",
227+
default=False,
228+
show_default=True,
229+
hidden=True
230+
)
208231
@click.pass_context
209232
def validate(ctx,
210233
profiles_path: Path = DEFAULT_PROFILES_PATH,
@@ -223,7 +246,10 @@ def validate(ctx,
223246
verbose: bool = False,
224247
output_format: str = "text",
225248
output_file: Optional[Path] = None,
226-
output_line_width: Optional[int] = None):
249+
output_line_width: Optional[int] = None,
250+
cache_max_age: int = constants.DEFAULT_HTTP_CACHE_MAX_AGE,
251+
cache_path: Optional[Path] = None,
252+
no_cache: bool = False):
227253
"""
228254
[magenta]rocrate-validator:[/magenta] Validate a RO-Crate against a profile
229255
"""
@@ -247,6 +273,11 @@ def validate(ctx,
247273
logger.debug("fail_fast: %s", fail_fast)
248274
logger.debug("no fail fast: %s", not fail_fast)
249275

276+
# Cache settings
277+
logger.debug("cache_max_age: %s", cache_max_age)
278+
logger.debug("cache_path: %s", os.path.abspath(cache_path) if cache_path else None)
279+
logger.debug("no_cache: %s", no_cache)
280+
250281
if rocrate_uri:
251282
logger.debug("rocrate_path: %s", os.path.abspath(rocrate_uri))
252283

@@ -282,7 +313,9 @@ def validate(ctx,
282313
"rocrate_relative_root_path": relative_root_path,
283314
"abort_on_first": fail_fast,
284315
"skip_checks": skip_checks_list,
285-
"metadata_only": metadata_only
316+
"metadata_only": metadata_only,
317+
"cache_max_age": cache_max_age if not no_cache else -1,
318+
"cache_path": cache_path
286319
}
287320

288321
# Print the application header

rocrate_validator/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,5 +87,5 @@
8787
JSON_OUTPUT_FORMAT_VERSION = "0.2"
8888

8989
# Http Cache Settings
90-
DEFAULT_HTTP_CACHE_TIMEOUT = 60
90+
DEFAULT_HTTP_CACHE_MAX_AGE = 300 # in seconds
9191
DEFAULT_HTTP_CACHE_PATH_PREFIX = '/tmp/rocrate_validator_cache'

rocrate_validator/models.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@
3131
import enum_tools
3232
from rdflib import RDF, RDFS, Graph, Namespace, URIRef
3333

34-
from rocrate_validator.utils import log as logging
3534
from rocrate_validator import __version__
36-
from rocrate_validator.constants import (DEFAULT_ONTOLOGY_FILE,
35+
from rocrate_validator.constants import (DEFAULT_HTTP_CACHE_MAX_AGE,
36+
DEFAULT_ONTOLOGY_FILE,
3737
DEFAULT_PROFILE_IDENTIFIER,
3838
DEFAULT_PROFILE_README_FILE,
3939
IGNORED_PROFILE_DIRECTORIES,
@@ -48,11 +48,13 @@
4848
ROCrateMetadataNotFoundError)
4949
from rocrate_validator.events import Event, EventType, Publisher, Subscriber
5050
from rocrate_validator.rocrate import ROCrate
51-
from rocrate_validator.utils.collections import (MapIndex)
51+
from rocrate_validator.utils import log as logging
52+
from rocrate_validator.utils.collections import MapIndex, MultiIndexMap
53+
from rocrate_validator.utils.http import HttpRequester
5254
from rocrate_validator.utils.paths import get_profiles_path
53-
from rocrate_validator.utils.python_helpers import get_requirement_name_from_file
55+
from rocrate_validator.utils.python_helpers import \
56+
get_requirement_name_from_file
5457
from rocrate_validator.utils.uri import URI
55-
from rocrate_validator.utils.collections import MultiIndexMap
5658

5759
# set the default profiles path
5860
DEFAULT_PROFILES_PATH = get_profiles_path()
@@ -1774,7 +1776,7 @@ def update(self, event: Event, ctx: Optional[ValidationContext] = None) -> None:
17741776
logger.debug("Validation ended with result: %s", event.validation_result)
17751777

17761778
def to_dict(self) -> dict:
1777-
""""
1779+
"""
17781780
Get the computed validation statistics as a dictionary
17791781
"""
17801782
return {
@@ -2388,11 +2390,19 @@ class ValidationSettings:
23882390
metadata_dict: dict = None
23892391
#: Verbose output
23902392
verbose: bool = False
2393+
#: Cache max age in seconds
2394+
cache_max_age: Optional[int] = DEFAULT_HTTP_CACHE_MAX_AGE
2395+
#: Cache path
2396+
cache_path: Optional[Path] = None
23912397

23922398
def __post_init__(self):
23932399
# if requirement_severity is a str, convert to Severity
23942400
if isinstance(self.requirement_severity, str):
23952401
self.requirement_severity = Severity[self.requirement_severity]
2402+
# initialize the HTTP cache
2403+
HttpRequester.initialize_cache(cache_path=self.cache_path, cache_max_age=self.cache_max_age)
2404+
logger.debug("HTTP cache initialized at %s with max age %s seconds",
2405+
self.cache_path, self.cache_max_age)
23962406

23972407
def to_dict(self):
23982408
"""
@@ -2637,7 +2647,7 @@ def detect_rocrate_profiles(self) -> list[Profile]:
26372647
if len(unmatched_profiles) > 0:
26382648
logger.warning(
26392649
"The conformance to the following profiles could not be verified: %s",
2640-
unmatched_profiles,
2650+
", ".join(unmatched_profiles),
26412651
)
26422652
return candidate_profiles
26432653

rocrate_validator/profiles/ro-crate/must/0_file_descriptor_format.py

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

15+
import re
1516
from typing import Any
17+
from urllib.parse import urljoin
1618

17-
from rocrate_validator.utils import log as logging
1819
from rocrate_validator.models import ValidationContext
1920
from rocrate_validator.requirements.python import (PyFunctionCheck, check,
2021
requirement)
22+
from rocrate_validator.utils import log as logging
2123
from rocrate_validator.utils.http import HttpRequester
2224

2325
# set up logging
@@ -86,17 +88,80 @@ class FileDescriptorJsonLdFormat(PyFunctionCheck):
8688
The file descriptor MUST be a valid JSON-LD file
8789
"""
8890

91+
def __get_remote_context__(self, context_uri: str) -> object:
92+
raw_data = HttpRequester().get(context_uri, headers={"Accept": "application/ld+json, application/json"})
93+
if raw_data.status_code != 200:
94+
raise RuntimeError(f"Unable to retrieve the JSON-LD context '{context_uri}'", self)
95+
logger.debug(f"Retrieved context from {context_uri}")
96+
97+
# Check if the response header contains the correct content type
98+
content_type = raw_data.headers.get("Content-Type", "")
99+
is_valid_content_type = "application/ld+json" in content_type or "application/json" in content_type
100+
# If the content type is not application/ld+json or application/json,
101+
# try to find an alternate link for the JSON-LD context in the response header
102+
if not is_valid_content_type:
103+
logger.debug(
104+
f"The retrieved context from {context_uri} "
105+
f"does not have a Content-Type of application/ld+json or application/json: "
106+
f"the actual Content-Type is {content_type}. "
107+
)
108+
# check if the response header contains an alternate link location for the JSON-LD context
109+
# (https headers are case-insensitive, according to RFC 7230,
110+
# so we can use .get() without worrying about the case)
111+
link_header = raw_data.headers.get("Link", "")
112+
logger.debug(f"Checking Link header for alternate JSON-LD context: {link_header}")
113+
has_alternate_link = ('rel="alternate"' in link_header and
114+
('type="application/ld+json"' in link_header or
115+
'type="application/json"' in link_header))
116+
117+
if has_alternate_link:
118+
logger.debug(f"Found alternate link for JSON-LD context in Link header: {link_header}")
119+
# extract the URL of the alternate link
120+
match = re.search(r'<([^>]+)>;\s*rel="alternate";\s*type="application/(ld\+json|json)"', link_header)
121+
if match:
122+
alternate_url = match.group(1)
123+
# If the alternate URL is relative, resolve it against the original context URI
124+
if not alternate_url.startswith("http"):
125+
alternate_url = urljoin(context_uri, alternate_url)
126+
logger.debug(f"Trying to retrieve JSON-LD context from alternate URL: {alternate_url}")
127+
raw_data = HttpRequester().get(alternate_url, headers={
128+
"Accept": "application/ld+json, application/json"})
129+
if raw_data.status_code != 200:
130+
raise RuntimeError(
131+
f"Unable to retrieve the JSON-LD context from alternate URL '{alternate_url}'", self)
132+
logger.debug(f"Retrieved context from alternate URL {alternate_url}")
133+
content_type = raw_data.headers.get("Content-Type", "")
134+
if "application/ld+json" not in content_type and "application/json" not in content_type:
135+
raise RuntimeError(
136+
f"The retrieved context from alternate URL {alternate_url} "
137+
"does not have a Content-Type of application/ld+json or application/json: "
138+
f"the actual Content-Type is {content_type}. ", self)
139+
else:
140+
logger.debug(f"No valid alternate link found in Link header: {link_header}")
141+
raise RuntimeError(
142+
f"Unable to retrieve the JSON-LD context from {context_uri} and no valid "
143+
f"alternate link found in Link header: {link_header}", self)
144+
else:
145+
logger.debug(f"No alternate link for JSON-LD context found in Link header: {link_header}")
146+
raise RuntimeError(
147+
f"Unable to retrieve the JSON-LD context from {context_uri} "
148+
f"and no alternate link found in Link header: {link_header}", self)
149+
150+
# Try to parse the JSON-LD and access the context
151+
jsonLD = raw_data.json()["@context"]
152+
# logger.warning(f"Retrieved JSON-LD context: {jsonLD}")
153+
assert isinstance(jsonLD, dict)
154+
# return the JSON-LD context
155+
return jsonLD
156+
89157
def __check_remote_context__(self, context_uri: str) -> bool:
90158
# Try to retrieve the context
91159
try:
92-
raw_data = HttpRequester().get(context_uri, headers={"Accept": "application/ld+json"})
93-
if raw_data.status_code != 200:
94-
raise RuntimeError(f"Unable to retrieve the JSON-LD context '{context_uri}'", self)
95-
logger.debug(f"Retrieved context from {context_uri}")
96-
97160
# Try to parse the JSON-LD and access the context
98-
jsonLD = raw_data.json()["@context"]
99-
assert isinstance(jsonLD, dict)
161+
jsonLD = self.__get_remote_context__(context_uri)
162+
assert isinstance(
163+
jsonLD, dict), f"The retrieved context from {context_uri} is not \
164+
a valid JSON-LD context: it is not a dictionary"
100165
return True
101166
except Exception as e:
102167
if logger.isEnabledFor(logging.DEBUG):
@@ -306,16 +371,8 @@ def __get_remote_context_keys__(self, context_uri: str) -> set:
306371
""" Get the keys of the context URI """
307372

308373
logger.debug(f"Retrieving context from {context_uri}...")
309-
# Try to retrieve the context
310-
raw_data = HttpRequester().get(context_uri, headers={"Accept": "application/ld+json"})
311-
if raw_data.status_code != 200:
312-
raise RuntimeError(f"Unable to retrieve the JSON-LD context '{context_uri}'")
313-
314-
logger.debug(f"Retrieved context from {context_uri}")
315-
316374
# Get the keys of the context
317-
jsonLD = raw_data.json()
318-
jsonLD_ctx = jsonLD["@context"]
375+
jsonLD_ctx = self.__get_remote_context__(context_uri)
319376
if not isinstance(jsonLD_ctx, dict):
320377
raise RuntimeError("The context is not a dictionary", self)
321378
return set(jsonLD_ctx.keys())
@@ -339,9 +396,27 @@ def add_unexpected_key(k: str, u_keys: dict) -> None:
339396
# If the entity is a dictionary, check each key
340397
if isinstance(entity, dict):
341398
for k, v in entity.items():
342-
if k not in context_keys and k not in SKIP_KEYS:
399+
# If the key is in the skip keys, skip it
400+
if k in SKIP_KEYS:
401+
logger.debug(f"Key {k} is a reserved JSON-LD keyword, skipping")
402+
403+
# If the key is not in the context keys,
404+
# it can be used in compacted format only if it is a valid compact IRI
405+
# with a prefix that is in the context
406+
elif k not in context_keys:
343407
logger.debug(f"Key {k} not in context keys")
344-
add_unexpected_key(k, unexpected_keys)
408+
409+
# Try to get the prefix of the compact IRI, if it has one
410+
prefix = k.split(":", 1)[0] if ":" in k else None
411+
logger.debug(f"Checking prefix {prefix} of key {k}")
412+
# If the key does not have a prefix (no colon) or the prefix is not in the context keys,
413+
# it cannot be used as a key in compacted format
414+
if prefix is None or prefix not in context_keys:
415+
logger.debug(
416+
f"Key {k} does not have a valid prefix in context keys, adding to unexpected keys")
417+
add_unexpected_key(k, unexpected_keys)
418+
419+
# If the value is a dictionary or a list, check its keys recursively
345420
if isinstance(v, (dict, list)):
346421
self.__check_entity_keys__(v, context_keys, unexpected_keys)
347422

@@ -382,7 +457,7 @@ def check_compaction(self, context: ValidationContext) -> bool:
382457
# Check if k is a term or a URI
383458
if k.startswith("http"):
384459
context.result.add_issue(
385-
f'The The {v} occurrence{suffix} of the "{k}" URI cannot be used as a key{suffix} "'
460+
f'The {v} occurrence{suffix} of the "{k}" URI cannot be used as a key{suffix} "'
386461
'because the compacted format requires simple terms as keys '
387462
'(see https://www.w3.org/TR/json-ld-api/#compaction for more details).', self)
388463
else:

0 commit comments

Comments
 (0)