Skip to content
Merged
191 changes: 175 additions & 16 deletions src/openxdox/gate_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,25 +631,25 @@ def build_gate_action_record(


def _validate_contract_document(
document: Mapping[str, Any], *, schema_filename: str, label: str,
document: Mapping[str, Any], *, schema_text: str, label: str,
) -> None:
"""Validate one document against a repository-pinned released schema."""
"""Validate one document against a released schema's text, which its
caller reads where the schema's owner keeps it: `_packaged_schema_text`
or `_host_schema_text`, under "THE CONTRACT SCHEMAS THE CONSOLE VALIDATES
AGAINST" near the end of this module (plan 038 T021).

A schema that is not YAML, is not a valid JSON Schema, or cannot be
applied is refused there too, by `_schema_errors`, as `GateRefused`. So
a host's broken schema reaches a caller as a failing document does, and
never as another exception."""
try:
from jsonschema import Draft202012Validator, FormatChecker
except ImportError as exc: # pragma: no cover - release dependency guard
raise GateRefused(
f"jsonschema is required to validate {label}") from exc
schema_path = (Path(__file__).resolve().parents[2] / "contracts" / "schemas"
/ schema_filename)
try:
schema = yaml.safe_load(schema_path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise GateRefused(
f"the {label} schema could not be loaded from {schema_path}: "
f"{exc}") from exc
errors = sorted(
Draft202012Validator(schema, format_checker=FormatChecker()).iter_errors(
dict(document)),
_schema_errors(document, schema_text, label=label,
validator=Draft202012Validator, formats=FormatChecker),
key=lambda error: tuple(str(part) for part in error.absolute_path))
if errors:
detail = "; ".join(
Expand All @@ -661,8 +661,8 @@ def _validate_contract_document(

def validate_gate_action_record(record: Mapping[str, Any]) -> None:
"""Validate one record against the repository-pinned released schema."""
_validate_contract_document(
record, schema_filename="gate-action-record.schema.yaml",
_validate_contract_document(record, schema_text=_packaged_schema_text(
GATE_ACTION_RECORD_SCHEMA, label="gate-action record"),
label="gate-action record")
if record.get("action") != ACTION_CLEANUP_ABANDONED_BRANCH:
return
Expand Down Expand Up @@ -707,8 +707,8 @@ def validate_gate_action_record(record: Mapping[str, Any]) -> None:


def validate_demotion_execution_receipt(receipt: Mapping[str, Any]) -> None:
_validate_contract_document(
receipt, schema_filename="demotion-execution-receipt.schema.yaml",
_validate_contract_document(receipt, schema_text=_host_schema_text(
DEMOTION_RECEIPT_SCHEMA, label="demotion execution receipt"),
label="demotion execution receipt")
destination = receipt.get("destination")
if not isinstance(destination, Mapping) or str(destination.get("path") or "") \
Expand Down Expand Up @@ -2348,6 +2348,165 @@ def edit_project(self, project_id: str, *, add=None, remove=None,
at=at, records_dir=self.records_dir, provenance=provenance, **extra)


# --------------------------------------------------------------------------
# THE CONTRACT SCHEMAS THE CONSOLE VALIDATES AGAINST, AND WHERE EACH IS READ
# (plan 038 T021, U-2; R2Q8 (a); P4F-4, ruled with plan 038 on
# openxFactory#656 comment 6013547504; R1Q27 (a), comment 5851950767).
#
# Two documents are validated here, against two schemas with two owners:
# * a gate-action record, against openXdox-spec's
# `gate-action-record.schema.yaml`. This distribution packages it in
# `openxdox.contracts`, as one of the consumer validator's own three kinds,
# held to its digest by `copies.yaml`. So it is read from there, through
# `contracts.verified_bytes()`, wherever this package runs;
# * a demotion execution receipt, against openxFactory's
# `demotion-execution-receipt.schema.yaml`. That schema is the HOST's
# contract: openXdox-spec carries only its examples, and the copy record
# admits the validator's three kinds and no fourth
# (`tests/test_packaged_validator.py`), so nothing of it is vendored here.
# A host that writes receipts registers, once, at process start, the
# directory its schema is in, with `register_contract_schema_source()`.
# With none registered, validating a receipt refuses, naming that call:
# R1Q27 (a)'s "only where the tree it runs from supplies their schemas".
#
# Both used to be read from `parents[2] / "contracts" / "schemas"` beside
# this module. That was openxFactory's own tree before the carve. In this
# leg it is the checkout's root, which holds no `contracts/`.
# --------------------------------------------------------------------------

#: The gate-action record schema's copy id in `openxdox.contracts`.
GATE_ACTION_RECORD_SCHEMA = "gate-action-record"
#: The demotion execution receipt schema's file name in the host's source.
DEMOTION_RECEIPT_SCHEMA = "demotion-execution-receipt.schema.yaml"

_contract_schema_source: Path | None = None


class ContractSchemaSourceRefused(RuntimeError):
"""A contract schema source the gate console cannot take, refused when it
is registered rather than when a receipt is first validated."""


def register_contract_schema_source(source: str | Path) -> Path:
"""Register the host's contract schema directory. Returns it, resolved.

The directory must hold `demotion-execution-receipt.schema.yaml`, the one
schema the console reads from a host. Registering the same directory again
is a no-op, so an idempotent host start is not punished. A DIFFERENT one is
refused: one process whose receipts are validated against two schemas,
depending on which registration a caller reached, is the failure one
registration exists to prevent. Call `unregister_contract_schema_source()`
first if the swap is deliberate."""
global _contract_schema_source
directory = Path(source).resolve()
if not (directory / DEMOTION_RECEIPT_SCHEMA).is_file():
raise ContractSchemaSourceRefused(
f"{directory} holds no {DEMOTION_RECEIPT_SCHEMA}, so the gate "
"console cannot read a demotion execution receipt's schema from "
"it. Register the directory the host keeps its contract schemas "
"in, its contracts/schemas/.")
if (_contract_schema_source is not None
and _contract_schema_source != directory):
raise ContractSchemaSourceRefused(
f"a contract schema source is already registered "
f"({_contract_schema_source}), and {directory} would replace it. "
"Registration happens once, at process start. Call "
"openxdox.gate_console.unregister_contract_schema_source() first "
"if the swap is deliberate.")
_contract_schema_source = directory
return directory


def unregister_contract_schema_source() -> None:
"""Drop the registration. For test isolation and for a host tearing down."""
global _contract_schema_source
_contract_schema_source = None


def contract_schema_source_registered() -> bool:
"""Is a contract schema source registered, without reading anything?"""
return _contract_schema_source is not None


def _packaged_schema_text(copy_id: str, *, label: str) -> str:
"""A schema this distribution packages, once its digest has been checked."""
from . import contracts

try:
return contracts.verified_bytes(copy_id).decode("utf-8")
except (contracts.CopyRefused, UnicodeDecodeError) as exc:
raise GateRefused(
f"the {label} schema could not be read from openXdox's packaged "
f"copy: {exc}") from exc


def _schema_errors(document: Mapping[str, Any], schema_text: str, *,
label: str, validator: Any, formats: Any) -> list:
"""`document`'s errors against the schema `schema_text` holds.

The schema itself is checked first. ANY failure at any of the three steps
refuses as `GateRefused`, naming the step:
* parsed: not YAML the safe loader can read (a `YAMLError`, and what it
raises past one: the `RecursionError` of a nesting deeper than the
interpreter allows, the `ValueError` of an impossible date);
* checked: not a valid JSON Schema under the validator's own
meta-schema (an empty document, `required: 1`), or a schema the check
cannot finish (the `RecursionError` of a cyclic YAML alias);
* applied: applying it raises (an unresolvable `$ref`).
A host supplies the receipt schema, and the demotion verb writes its
receipt after it has moved files: it reports a `GateRefused` there as an
executed demotion whose receipt was not written, where any other exception
would end it with a traceback (Copilot on #40: r4194497045, r4197914632,
r4198638746)."""
from jsonschema.exceptions import SchemaError

try:
schema = yaml.safe_load(schema_text)
except Exception as exc: # noqa: BLE001 - any failure to read it refuses
raise GateRefused(
f"the {label} schema could not be parsed: "
f"{type(exc).__name__}: {exc}") from exc
try:
validator.check_schema(schema)
except SchemaError as exc:
raise GateRefused(
f"the {label} schema is not a valid JSON Schema: {exc.message}"
) from exc
Comment thread
brettheap marked this conversation as resolved.
Resolved
except Exception as exc: # noqa: BLE001 - any failure to check it refuses
raise GateRefused(
f"the {label} schema could not be checked: "
f"{type(exc).__name__}: {exc}") from exc
try:
return list(validator(schema, format_checker=formats()).iter_errors(
dict(document)))
except Exception as exc: # noqa: BLE001 - any failure to apply it refuses
raise GateRefused(
f"the {label} schema could not be applied: "
f"{type(exc).__name__}: {exc}") from exc


def _host_schema_text(filename: str, *, label: str) -> str:
"""A schema the host supplies, from the source it registered."""
source = _contract_schema_source
if source is None:
raise GateRefused(
f"the {label} cannot be validated: its schema, {filename}, is the "
"host's contract, and no contract schema source is registered. A "
"host registers the directory it keeps its contract schemas in, "
"once, at process start, with "
"openxdox.gate_console.register_contract_schema_source(<directory>)"
" (R1Q27 (a): a kind is validated only where the tree it runs "
"from supplies its schema).")
path = source / filename
try:
return path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
raise GateRefused(
f"the {label} schema could not be loaded from {path}: "
f"{exc}") from exc



# ---------------------------------------------------------------------------
# BACKWARD-COMPATIBLE MODULE ATTRIBUTES, RESOLVED ON FIRST ACCESS (PEP 562).
#
Expand Down
16 changes: 16 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -775,3 +775,19 @@ def _the_governed_host_for_the_governed_suites(request):
return
with a_hosts_plane(governed_host):
yield


# THE GATE CONSOLE'S CONTRACT SCHEMA SOURCE, REGISTERED AS A HOST REGISTERS IT
# (plan 038 T021, U-2; P4F-4). A demotion execution receipt's schema is
# openxFactory's contract, and the gate console reads it only from a source a
# host registers (`gate_console.register_contract_schema_source`). Where the
# run is composed, this registers openxFactory's `contracts/schemas/` from the
# composed tree, at process start, as openxFactory's own host wiring does from
# T030. A lone checkout registers none, and there a receipt refuses by name
# (R1Q27 (a)).
_OPENXFACTORY_HOST = openxfactory_host()
if _OPENXFACTORY_HOST is not None:
from openxdox import gate_console as _gate_console # noqa: E402

_gate_console.register_contract_schema_source(
Path(_OPENXFACTORY_HOST.__file__).resolve().parents[1] / "contracts" / "schemas")
Loading
Loading