Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions assets/validate/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
# Validation stylesheets (XSLT)

Default **`ASSETS_ROOT`**: XSLT 1.0 stylesheets for IVOA registry rules beyond XSD.
These files are required for validation; a missing or unloadable stylesheet fails hard
(see [`docs/schemas-and-validation-assets.md`](../../docs/schemas-and-validation-assets.md)).

| File | Role |
|------|------|
| `checkIVOAOAI.xsl` | IVOA harvest profile tests on OAI GET responses (phase 2) |
| `checkVOResource.xsl` | VOResource constraint tests on harvested records (phase 3) |
| `testsVOResource.xsl` | Shared VOResource rule templates (imported by the check stylesheets) |
| `validationCommon.xsl` | Shared helpers (imported by the check stylesheets) |
| `validateVocabularies.xsl` | Generated vocabulary-term checks (imported by `checkVOResource.xsl`) |
| `vocabularyControlled.csv` | Config for which VOResource paths are vocabulary-controlled |
Expand Down
2 changes: 1 addition & 1 deletion docs/regvalidate-functional-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ Logic and presentation artifacts live in **`ivoaharvest/src/main/resources/net/i
| `checkIVOAOAI.xsl` | IVOA OAI profile tests on harvested HTTP GET responses. |
| `checkVOResource.xsl` | VOR constraints beyond XSD. |
| `validationCommon.xsl` | Shared helpers. |
| `testsVOResource-v1_0.xsl` | Additional VOR tests (if used). |
| `testsVOResource.xsl` | Additional VOR tests (if used). |
| `Results-Harvest-html.xsl` | Harvest results HTML view. |
| `ResultsFrag-Harvest-html.xsl`, `SummaryFrag-Harvest-html.xsl` | UI fragments for browser transform. |
| `Results-VOResource-html.xsl` | Standalone VOR HTML view. |
Expand Down
5 changes: 2 additions & 3 deletions docs/schemas-and-validation-assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,12 @@ These stylesheets implement IVOA registry **business rules** that XSD alone does
|------|---------|------|
| `checkIVOAOAI.xsl` | Phase 2 | Profile tests on OAI GET responses (`Identify`, `ListMetadataFormats`, `ListSets`, `ListRecords`). Emits `<test item="RI3.1.1" …>` elements. |
| `checkVOResource.xsl` | Phase 3 | Additional constraints on harvested VOResource records. Emits `<test item="VRvalid" …>`. |
| `testsVOResource.xsl` | (imported) | VOResource business-rule templates (`coretests` / `restests` / `captests`) used by both check stylesheets |
| `validationCommon.xsl` | (imported) | Shared helpers for the check stylesheets |
| `validateVocabularies.xsl` | Phase 3 (imported) | Generated checks for vocabulary-controlled VOResource / VODataService fields |
| `vocabularyControlled.csv` | (generator input) | Paths and IVOA vocabulary URLs used to build `validateVocabularies.xsl` |

If XSLT processing fails or the stylesheet is missing, phase 2/3 fall back to simpler pass/fail heuristics (HTTP status and absence of OAI error codes).

**Note:** `checkIVOAOAI.xsl` references `testsVOResource-v1_0.xsl` from the legacy Java tree; that file is **not** shipped under `assets/validate/`. Import failures are caught and the code falls back to non-XSLT checks.
XSLT is **required** for phase 2/3 validation. If a stylesheet is missing or cannot be loaded (for example a broken `xsl:import`), Benson raises `XsltAssetsError` with guidance to fix `ASSETS_ROOT` / regenerate vocabulary XSLT. Silent fall-back to XSD-only or HTTP heuristics is intentionally not used: a local install missing assets would otherwise “pass” services that fail on a complete RofR deployment.

### Regenerating `validateVocabularies.xsl`

Expand Down
51 changes: 20 additions & 31 deletions src/benson/oai/phase2.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,13 @@ async def build_ivoa_harvest_validation(
) -> tuple[etree._Element, dict[str, str]]:
root = R.harvest_validation_root(endpoint.rstrip(), show_status)

check_xsl = settings.assets_root / "checkIVOAOAI.xsl"
check_xsl = xslt_eval.require_stylesheet(settings.assets_root / "checkIVOAOAI.xsl")
identify_state: dict[str, str] = {}
registration_defaults: dict[str, str] = {}

for role, qp in IVOA_CHECKS:
qp_body = qp.lstrip().lstrip("?")
status, raw, codes, parse_err = await fetch_oai(client, endpoint, qp_body, timeout=timeout)
status, raw, _codes, parse_err = await fetch_oai(client, endpoint, qp_body, timeout=timeout)

tq = etree.SubElement(root, "testQuery", name=role, options=qp, role=role)

Expand All @@ -140,7 +140,6 @@ async def build_ivoa_harvest_validation(
identify_state = extract_identify_state(parsed)
registration_defaults = identify_registration_defaults(parsed)

used_xslt = False
if violations:
tq.append(
R.ri_test(
Expand All @@ -150,33 +149,23 @@ async def build_ivoa_harvest_validation(
)
continue

if check_xsl.is_file():
xsl_params: dict[str, str] = {
"expectError": "false",
"queryType": role,
"queryName": role,
"baseurl": endpoint.rstrip().rstrip("?"),
"showStatus": show_status,
}
if role == "ListRecords" and identify_state:
xsl_params.update(identify_state)
# Always set last so Identify-derived params cannot clobber it.
xsl_params["rightnow"] = xslt_eval.rightnow()
try:
xout = xslt_eval.transform(
check_xsl,
parsed,
params=xsl_params,
)
for child in xout.getroot():
used_xslt = True
tq.append(child)
except Exception:
used_xslt = False

if not used_xslt:
ri_ok = not codes
msg = "OK" if ri_ok else _failure_message(role)
tq.append(R.ri_test(ri_ok, msg))
xsl_params: dict[str, str] = {
"expectError": "false",
"queryType": role,
"queryName": role,
"baseurl": endpoint.rstrip().rstrip("?"),
"showStatus": show_status,
}
if role == "ListRecords" and identify_state:
xsl_params.update(identify_state)
# Always set last so Identify-derived params cannot clobber it.
xsl_params["rightnow"] = xslt_eval.rightnow()
xout = xslt_eval.transform(
check_xsl,
parsed,
params=xsl_params,
)
for child in xout.getroot():
tq.append(child)

return root, registration_defaults
21 changes: 6 additions & 15 deletions src/benson/oai/phase3.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Phase 3: ListRecords harvest loop (ivo_vor / ivo_managed) with XSD + optional XSLT."""
"""Phase 3: ListRecords harvest loop (ivo_vor / ivo_managed) with XSD + XSLT."""

from __future__ import annotations

Expand Down Expand Up @@ -160,20 +160,11 @@ def validate_one_voresource(
errs = []

xsl_path = settings.assets_root / "checkVOResource.xsl"
if not xsl_path.is_file():
return errs

try:
xout = xslt_eval.transform(
xsl_path,
el,
params={"rightnow": xslt_eval.rightnow()},
)
except etree.LxmlError:
# Stylesheet missing imports / apply failure: fall back to XSD-only
# (see docs/schemas-and-validation-assets.md).
return errs

xout = xslt_eval.transform(
xsl_path,
el,
params={"rightnow": xslt_eval.rightnow()},
)
errs.extend(_xslt_fail_messages(xout))
return errs

Expand Down
28 changes: 27 additions & 1 deletion src/benson/xml/xslt_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,48 @@
from lxml import etree


class XsltAssetsError(RuntimeError):
"""Raised when a required validation stylesheet is missing or cannot be loaded."""


def rightnow() -> str:
"""UTC timestamp for stylesheet ``rightnow`` params (legacy Java format)."""
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")


def require_stylesheet(xsl_path: Path) -> Path:
"""Return ``xsl_path`` if it exists; otherwise raise with fix guidance."""
if not xsl_path.is_file():
raise XsltAssetsError(
f"Required XSLT stylesheet missing: {xsl_path}. "
"Set ASSETS_ROOT to the assets/validate directory shipped with Benson "
"(including checkIVOAOAI.xsl / checkVOResource.xsl and their imports: "
"testsVOResource.xsl, validationCommon.xsl, validateVocabularies.xsl). "
"If validateVocabularies.xsl is absent, run: benson generate-vocabulary-xsl"
)
return xsl_path


def transform(
xsl_path: Path,
source_doc: etree._ElementTree | etree._Element,
*,
params: dict[str, str] | None = None,
) -> etree._ElementTree:
require_stylesheet(xsl_path)
doc = (
source_doc
if isinstance(source_doc, etree._ElementTree)
else etree.ElementTree(source_doc)
)
tpl = etree.XSLT(etree.parse(str(xsl_path)))
try:
tpl = etree.XSLT(etree.parse(str(xsl_path)))
except etree.LxmlError as exc:
raise XsltAssetsError(
f"Failed to load XSLT stylesheet {xsl_path}: {exc}. "
"Ensure ASSETS_ROOT contains the stylesheet and its imports "
"(testsVOResource.xsl, validationCommon.xsl, validateVocabularies.xsl). "
"If vocabulary checks are involved, run: benson generate-vocabulary-xsl"
) from exc
kw = {k.replace(":", "_"): etree.XSLT.strparam(v) for k, v in (params or {}).items()}
return tpl(doc, **kw)
44 changes: 31 additions & 13 deletions tests/test_validate_one_voresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import os
from dataclasses import replace
from pathlib import Path
from unittest.mock import patch

Expand All @@ -15,6 +16,7 @@

from benson.config import Settings # noqa: E402
from benson.oai.phase3 import validate_one_voresource, validate_voresource_documents # noqa: E402
from benson.xml.xslt_eval import XsltAssetsError # noqa: E402

_RI = "http://www.ivoa.net/xml/RegistryInterface/v1.0"
_VR = "http://www.ivoa.net/xml/VOResource/v1.0"
Expand Down Expand Up @@ -66,20 +68,9 @@ def test_validate_one_surfaces_xsd_errors(settings: Settings) -> None:


def test_validate_one_skips_xsd_when_builtin_disabled(settings: Settings) -> None:
with patch("benson.oai.phase3.xslt_eval.transform", side_effect=etree.XSLTParseError("boom")):
errs = validate_one_voresource(
_MINIMAL_VOR,
builtin_schemas=False,
settings=settings,
)
assert errs == []


def test_validate_one_xslt_failure_falls_back(settings: Settings) -> None:
"""XSLT load/apply errors must not crash; fall back to XSD-only results."""
with patch(
"benson.oai.phase3.xslt_eval.transform",
side_effect=etree.XSLTParseError("Cannot resolve URI testsVOResource.xsl"),
return_value=_fake_xslt_tree(),
):
errs = validate_one_voresource(
_MINIMAL_VOR,
Expand All @@ -89,6 +80,33 @@ def test_validate_one_xslt_failure_falls_back(settings: Settings) -> None:
assert errs == []


def test_validate_one_missing_stylesheet_fails_hard(
settings: Settings,
tmp_path: Path,
) -> None:
"""Missing XSLT assets must fail hard, not silently skip rule checks."""
broken = replace(settings, assets_root=tmp_path)
with pytest.raises(XsltAssetsError, match="Required XSLT stylesheet missing"):
validate_one_voresource(_MINIMAL_VOR, builtin_schemas=False, settings=broken)


def test_validate_one_stylesheet_load_failure_fails_hard(
settings: Settings,
tmp_path: Path,
) -> None:
"""Unloadable stylesheets (e.g. broken imports) must fail hard with fix guidance."""
(tmp_path / "checkVOResource.xsl").write_text(
'<?xml version="1.0"?>\n'
'<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">\n'
' <xsl:import href="does-not-exist.xsl"/>\n'
"</xsl:stylesheet>\n",
encoding="utf-8",
)
broken = replace(settings, assets_root=tmp_path)
with pytest.raises(XsltAssetsError, match="Failed to load XSLT stylesheet"):
validate_one_voresource(_MINIMAL_VOR, builtin_schemas=False, settings=broken)


def test_validate_one_collects_xslt_fail_tests(settings: Settings) -> None:
fail = etree.Element("test", item="use-voc-content_type", status="fail")
fail.text = "#bogus not in vocabulary"
Expand Down Expand Up @@ -182,7 +200,7 @@ def test_validate_one_voc_exercise_xsd_reports_errors(settings: Settings) -> Non
blob = (_repo / "tests" / "fixtures" / "voc-exercise.vor").read_bytes()
with patch(
"benson.oai.phase3.xslt_eval.transform",
side_effect=etree.XSLTParseError("unavailable"),
return_value=_fake_xslt_tree(),
):
errs = validate_one_voresource(blob, builtin_schemas=True, settings=settings)
assert any("stats" in e for e in errs)
Expand Down
Loading