Skip to content

Commit ef93bdd

Browse files
committed
feat(validation): add ValidationOutcome type and runner
- Add `app/validation/results.py` (`ValidationOutcome` with valid/invalid/error status, detail/error, serialisation) and `runner.py` wrapping rocrate_validator so both entry points always return an outcome; - Replaces the ValidationResult|str / isinstance pattern. Closes #176
1 parent 70ed8e7 commit ef93bdd

5 files changed

Lines changed: 308 additions & 0 deletions

File tree

app/validation/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""RO-Crate validation: a single outcome type and the runner that produces it."""

app/validation/results.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Defines an explicit result type for validation."""
2+
3+
import json
4+
5+
from dataclasses import dataclass
6+
from enum import Enum
7+
from typing import Optional
8+
9+
10+
class ValidationStatus(str, Enum):
11+
"""The outcome of a validation run."""
12+
13+
VALID = "valid"
14+
INVALID = "invalid"
15+
ERROR = "error"
16+
17+
18+
@dataclass(frozen=True)
19+
class ValidationOutcome:
20+
"""The result of validating a crate or its metadata.
21+
22+
``detail`` holds the validator's report for ``valid``/``invalid`` outcomes;
23+
``error`` holds the message for an ``error`` outcome. The two are mutually
24+
exclusive.
25+
"""
26+
27+
status: ValidationStatus
28+
profile: Optional[str] = None
29+
detail: Optional[dict] = None
30+
error: Optional[str] = None
31+
created_at: Optional[str] = None
32+
33+
@property
34+
def is_valid(self) -> bool:
35+
return self.status is ValidationStatus.VALID
36+
37+
def to_dict(self) -> dict:
38+
data = {
39+
"status": self.status.value,
40+
"profile": self.profile,
41+
"created_at": self.created_at,
42+
}
43+
if self.detail is not None:
44+
data["detail"] = self.detail
45+
if self.error is not None:
46+
data["error"] = self.error
47+
return data
48+
49+
def to_json(self) -> str:
50+
return json.dumps(self.to_dict())
51+
52+
@classmethod
53+
def from_validator_result(
54+
cls, result, profile: Optional[str] = None, created_at: Optional[str] = None
55+
) -> "ValidationOutcome":
56+
"""Build an outcome from a rocrate_validator ``ValidationResult``."""
57+
status = (
58+
ValidationStatus.INVALID if result.has_issues() else ValidationStatus.VALID
59+
)
60+
return cls(
61+
status=status,
62+
profile=profile,
63+
detail=json.loads(result.to_json()),
64+
created_at=created_at,
65+
)
66+
67+
@classmethod
68+
def from_error(
69+
cls,
70+
message: str,
71+
profile: Optional[str] = None,
72+
created_at: Optional[str] = None,
73+
) -> "ValidationOutcome":
74+
"""Build an error outcome from a failure message."""
75+
return cls(
76+
status=ValidationStatus.ERROR,
77+
profile=profile,
78+
error=message,
79+
created_at=created_at,
80+
)

app/validation/runner.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Runs rocrate_validator and adapts its output to a ValidationOutcome.
2+
3+
This is the boundary to the external validator. Both entry points always
4+
return a :class:`ValidationOutcome` - a validator exception becomes an ``error``
5+
outcome rather than a string, so callers never have to type-check the result.
6+
"""
7+
8+
import logging
9+
10+
from typing import Optional
11+
12+
from rocrate_validator import services
13+
14+
from app.validation.results import ValidationOutcome
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
def validate_crate_path(
20+
rocrate_uri: str,
21+
profile_name: Optional[str] = None,
22+
profiles_path: Optional[str] = None,
23+
skip_checks: Optional[list] = None,
24+
created_at: Optional[str] = None,
25+
) -> ValidationOutcome:
26+
"""Validate a crate on disk (a directory or zip) at ``rocrate_uri``."""
27+
return _run(
28+
{"rocrate_uri": rocrate_uri},
29+
profile_name=profile_name,
30+
profiles_path=profiles_path,
31+
skip_checks=skip_checks,
32+
created_at=created_at,
33+
)
34+
35+
36+
def validate_metadata(
37+
metadata: dict,
38+
profile_name: Optional[str] = None,
39+
profiles_path: Optional[str] = None,
40+
skip_checks: Optional[list] = None,
41+
created_at: Optional[str] = None,
42+
) -> ValidationOutcome:
43+
"""Validate an in-memory RO-Crate metadata graph."""
44+
return _run(
45+
{"metadata_only": True, "metadata_dict": metadata},
46+
profile_name=profile_name,
47+
profiles_path=profiles_path,
48+
skip_checks=skip_checks,
49+
created_at=created_at,
50+
)
51+
52+
53+
def _run(
54+
base_settings: dict,
55+
profile_name: Optional[str],
56+
profiles_path: Optional[str],
57+
skip_checks: Optional[list],
58+
created_at: Optional[str],
59+
) -> ValidationOutcome:
60+
options = dict(base_settings)
61+
if profile_name:
62+
options["profile_identifier"] = profile_name
63+
if profiles_path:
64+
options["profiles_path"] = profiles_path
65+
if skip_checks:
66+
options["skip_checks"] = skip_checks
67+
68+
try:
69+
settings = services.ValidationSettings(**options)
70+
result = services.validate(settings)
71+
except (
72+
Exception
73+
) as error: # noqa: BLE001 - adapt any validator failure to an outcome
74+
logger.error("Validation failed: %s", error)
75+
return ValidationOutcome.from_error(
76+
str(error), profile=profile_name, created_at=created_at
77+
)
78+
79+
return ValidationOutcome.from_validator_result(
80+
result, profile=profile_name, created_at=created_at
81+
)

tests/test_validation_outcome.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Tests for the ValidationOutcome result type."""
2+
3+
import json
4+
5+
from app.validation.results import ValidationOutcome, ValidationStatus
6+
7+
8+
class FakeResult:
9+
"""Stand-in for a rocrate_validator ValidationResult."""
10+
11+
def __init__(self, has_issues: bool, report: dict):
12+
self._has_issues = has_issues
13+
self._report = report
14+
15+
def has_issues(self) -> bool:
16+
return self._has_issues
17+
18+
def to_json(self) -> str:
19+
return json.dumps(self._report)
20+
21+
22+
def test_from_validator_result_without_issues_is_valid():
23+
outcome = ValidationOutcome.from_validator_result(
24+
FakeResult(False, {"report": "ok"}), profile="ro-crate"
25+
)
26+
assert outcome.status is ValidationStatus.VALID
27+
assert outcome.is_valid is True
28+
assert outcome.profile == "ro-crate"
29+
assert outcome.detail == {"report": "ok"}
30+
assert outcome.error is None
31+
32+
33+
def test_from_validator_result_with_issues_is_invalid():
34+
outcome = ValidationOutcome.from_validator_result(FakeResult(True, {"issues": [1]}))
35+
assert outcome.status is ValidationStatus.INVALID
36+
assert outcome.is_valid is False
37+
assert outcome.detail == {"issues": [1]}
38+
39+
40+
def test_from_error_records_message_and_has_no_detail():
41+
outcome = ValidationOutcome.from_error("boom", profile="ro-crate")
42+
assert outcome.status is ValidationStatus.ERROR
43+
assert outcome.is_valid is False
44+
assert outcome.error == "boom"
45+
assert outcome.detail is None
46+
47+
48+
def test_to_dict_serialises_status_as_string_and_omits_absent_fields():
49+
outcome = ValidationOutcome.from_validator_result(FakeResult(False, {"r": 1}))
50+
data = outcome.to_dict()
51+
assert data["status"] == "valid"
52+
assert data["detail"] == {"r": 1}
53+
assert "error" not in data
54+
55+
56+
def test_to_json_round_trips():
57+
outcome = ValidationOutcome.from_error("nope")
58+
parsed = json.loads(outcome.to_json())
59+
assert parsed["status"] == "error"
60+
assert parsed["error"] == "nope"
61+
62+
63+
def test_created_at_is_propagated_when_provided():
64+
outcome = ValidationOutcome.from_error("x", created_at="2026-06-16T00:00:00Z")
65+
assert outcome.created_at == "2026-06-16T00:00:00Z"
66+
assert outcome.to_dict()["created_at"] == "2026-06-16T00:00:00Z"

tests/test_validation_runner.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Tests for the validation runner that wraps rocrate_validator."""
2+
3+
import json
4+
5+
import pytest
6+
7+
from app.validation import runner
8+
from app.validation.results import ValidationStatus
9+
10+
11+
class FakeResult:
12+
def __init__(self, has_issues: bool):
13+
self._has_issues = has_issues
14+
15+
def has_issues(self) -> bool:
16+
return self._has_issues
17+
18+
def to_json(self) -> str:
19+
return json.dumps({"issues": self._has_issues})
20+
21+
22+
class FakeServices:
23+
"""A stand-in for rocrate_validator.services."""
24+
25+
def __init__(self, result=None, raises=None):
26+
self._result = result
27+
self._raises = raises
28+
self.last_settings = None
29+
30+
def ValidationSettings(self, **kwargs): # noqa: N802 - mirrors the real API
31+
self.last_settings = kwargs
32+
return kwargs
33+
34+
def validate(self, settings):
35+
if self._raises is not None:
36+
raise self._raises
37+
return self._result
38+
39+
40+
def test_validate_metadata_success_is_valid(monkeypatch):
41+
fake = FakeServices(result=FakeResult(has_issues=False))
42+
monkeypatch.setattr(runner, "services", fake)
43+
44+
outcome = runner.validate_metadata({"@graph": []}, profile_name="ro-crate")
45+
46+
assert outcome.status is ValidationStatus.VALID
47+
assert outcome.profile == "ro-crate"
48+
assert fake.last_settings["metadata_only"] is True
49+
assert fake.last_settings["metadata_dict"] == {"@graph": []}
50+
51+
52+
def test_validate_metadata_with_issues_is_invalid(monkeypatch):
53+
monkeypatch.setattr(runner, "services", FakeServices(result=FakeResult(True)))
54+
outcome = runner.validate_metadata({"@graph": []})
55+
assert outcome.status is ValidationStatus.INVALID
56+
57+
58+
def test_validate_metadata_exception_becomes_error_outcome(monkeypatch):
59+
monkeypatch.setattr(runner, "services", FakeServices(raises=RuntimeError("kaboom")))
60+
outcome = runner.validate_metadata({"@graph": []}, profile_name="ro-crate")
61+
assert outcome.status is ValidationStatus.ERROR
62+
assert "kaboom" in outcome.error
63+
assert outcome.profile == "ro-crate"
64+
65+
66+
def test_validate_crate_path_success(monkeypatch):
67+
fake = FakeServices(result=FakeResult(has_issues=False))
68+
monkeypatch.setattr(runner, "services", fake)
69+
70+
outcome = runner.validate_crate_path("/tmp/crate", profile_name="ro-crate")
71+
72+
assert outcome.status is ValidationStatus.VALID
73+
assert fake.last_settings["rocrate_uri"] == "/tmp/crate"
74+
75+
76+
def test_validate_crate_path_exception_becomes_error_outcome(monkeypatch):
77+
monkeypatch.setattr(runner, "services", FakeServices(raises=ValueError("bad crate")))
78+
outcome = runner.validate_crate_path("/tmp/crate")
79+
assert outcome.status is ValidationStatus.ERROR
80+
assert "bad crate" in outcome.error

0 commit comments

Comments
 (0)