Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Example rendered file with two plugin contributions:
Schemas discovered through ``OPENEDX_AUTHZ_SCHEMA_FILES`` have no Python package or module. To satisfy the source-tracking requirements of `ADR 0025`_, their source record uses:

* ``distribution`` = ``<tutor-patch>`` — a sentinel value that is an invalid Python package name, guaranteeing no collision with any installed distribution.
* ``module`` = ``null`` — filesystem sources have no owning Python module, and using the absolute path would be inconsistent with the field's semantics elsewhere in the model.
* ``module`` = the absolute filesystem path of the schema file (e.g. ``/openedx/config/authz-schema.yml``). Filesystem sources have no owning Python module, but reusing the path here keeps the ``(distribution, module)`` unique constraint enforceable and avoids the ``NULL``-handling complications that a ``null`` value would introduce across the ORM, migrations, and source-identity logic.
* ``resource_path`` = the absolute filesystem path of the schema file (e.g. ``/openedx/config/authz-schema.yml``).
* ``distribution_version`` = ``0.0.0`` — a sentinel value, since filesystem sources carry no package version.

Expand Down
93 changes: 91 additions & 2 deletions src/openedx_authz/engine/schema/discovery.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
"""Discover static authz schema resources (the ``discover`` step, ADR 0019).

Providers register **directories** (not individual files); the loader reads
every ``.yaml`` file inside them. Two contribution sources are merged:
Providers register **directories** of ``.yaml`` files, or individual absolute
filesystem paths. Three contribution sources are merged:

1. The ``authz.schema`` entry-point group. Each registered callable returns
directory paths in the package-anchored format below (e.g.
openedx-authz's ``["openedx_authz/authz/schema"]``).
2. The ``OPENEDX_AUTHZ_SCHEMA_DIRECTORIES`` Django setting, a list of directory
path strings in the same format. This lets operators and CI contribute
directories without shipping a package entry point.
3. The ``OPENEDX_AUTHZ_SCHEMA_FILES`` Django setting, a list of absolute
filesystem paths each naming one schema file (ADR 0030). This is the route
used by the Tutor ``openedx-authz-schema`` patch to feed a rendered file
into discovery without anchoring it on a Python package.

Directory format: these are **not** filesystem paths (neither relative nor
absolute). Each is a package-anchored ``importlib.resources`` path: the first
Expand Down Expand Up @@ -36,12 +40,15 @@
from dataclasses import dataclass
from enum import StrEnum
from importlib import metadata, resources
from pathlib import Path

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured

ENTRY_POINT_GROUP = "authz.schema"
SETTINGS_DIRECTORIES_NAME = "OPENEDX_AUTHZ_SCHEMA_DIRECTORIES"
SETTINGS_FILE_SENTINEL = "<tutor-patch>"
SETTINGS_FILE_VERSION = "0.0.0"
SCHEMA_FILE_SUFFIXES = (".yaml", ".yml")

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -101,6 +108,45 @@ def read_bytes(self) -> bytes:
) from exc


@dataclass(frozen=True)
class DiscoveredFileResource(DiscoveredResource):
"""A schema file discovered via an absolute filesystem path (ADR 0030).

Unlike :class:`DiscoveredResource`, which resolves through
``importlib.resources``, this variant reads directly from the filesystem.
Fields are populated with sentinel values: ``package`` is
:data:`SETTINGS_FILE_SENTINEL` and both ``module`` and ``resource_path``
hold the absolute path.
"""

@classmethod
def from_path(cls, file_path: str) -> DiscoveredFileResource:
"""Build a resource from an absolute filesystem path.

Sentinel values are used for ``package`` and ``module`` because the
file is not anchored on any Python package.
"""
return cls(
package=SETTINGS_FILE_SENTINEL,
resource_path=file_path,
module=file_path,
origin=Origin.SETTINGS,
)

def read_bytes(self) -> bytes:
"""Read the file directly from the filesystem via :class:`pathlib.Path`.

Raises:
SchemaDiscoveryError: If the file cannot be read.
"""
try:
return Path(self.resource_path).read_bytes()
except OSError as exc:
raise SchemaDiscoveryError(
f"Could not read schema file {self.resource_path!r}: {exc}"
) from exc


class SchemaDiscoveryError(Exception):
"""Raised when a provider fails or a declared directory cannot be read."""

Expand Down Expand Up @@ -139,6 +185,7 @@ def discover(self) -> list[DiscoveredResource]:
found: list[DiscoveredResource] = []
found.extend(self._discover_entry_points())
found.extend(self._discover_settings_directories())
found.extend(self._discover_settings_files())
for directory in self._passed_in_directories:
found.extend(self._iter_directory(directory, origin=Origin.PASSED_IN))

Expand Down Expand Up @@ -184,6 +231,48 @@ def _discover_settings_directories(self) -> list[DiscoveredResource]:
discovered.extend(self._iter_directory(directory, origin=Origin.SETTINGS))
return discovered

def _discover_settings_files(self) -> list[DiscoveredResource]:
"""Read ``OPENEDX_AUTHZ_SCHEMA_FILES`` from Django settings (ADR 0030).

Each item is an absolute filesystem path to a single schema file.
Unlike ``_discover_settings_directories``, every listed path must exist
and have a supported extension; a missing or misnamed file is treated
as a misconfiguration and raises ``SchemaDiscoveryError``.

Absent, empty, or unconfigured settings yield nothing. As with
``_discover_settings_directories``, ``ImproperlyConfigured`` degrades
to no contribution so CI can run outside a Django process.
"""
try:
file_paths = getattr(settings, "OPENEDX_AUTHZ_SCHEMA_FILES", None) or []
except ImproperlyConfigured:
return []
if not isinstance(file_paths, list):
raise SchemaDiscoveryError(
"OPENEDX_AUTHZ_SCHEMA_FILES must be a list of filesystem paths, "
f"got {type(file_paths).__name__!r}."
)
discovered: list[DiscoveredResource] = []
for file_path in file_paths:
if not file_path.endswith(SCHEMA_FILE_SUFFIXES):
raise SchemaDiscoveryError(
f"Schema file {file_path!r} contributed through "
"OPENEDX_AUTHZ_SCHEMA_FILES has an unsupported file extension, "
f"accepted extensions: {SCHEMA_FILE_SUFFIXES}"
)

path = Path(file_path)
if not path.is_file():
raise SchemaDiscoveryError(
f"Schema file {file_path!r} contributed through "
"OPENEDX_AUTHZ_SCHEMA_FILES is not an actual file"
)

discovered.append(
DiscoveredFileResource.from_path(file_path)
)
return discovered

def _iter_directory(self, directory: str, *, origin: Origin) -> list[DiscoveredResource]:
"""Resolve a directory path and return one resource per ``.yaml`` file.

Expand Down
45 changes: 28 additions & 17 deletions src/openedx_authz/engine/schema/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@

import yaml

from openedx_authz.engine.schema.discovery import DiscoveredResource
from openedx_authz.engine.schema.discovery import (
SETTINGS_FILE_VERSION,
DiscoveredFileResource,
DiscoveredResource,
)
from openedx_authz.engine.schema.exceptions import SchemaLoadError
from openedx_authz.engine.schema.types import ParsedSchemaDocument, SourceRecord
from openedx_authz.engine.schema.types import ParsedSchemaDocument, SourceFileRecord, SourceRecord

logger = logging.getLogger(__name__)

Expand All @@ -42,17 +46,19 @@ def parse(self, resources: list[DiscoveredResource]) -> list[ParsedSchemaDocumen
documents = []
for resource in resources:
contents = resource.read_bytes()
data = self.parse_yaml(contents, resource)
schema_version = str(data.get("schema_version", "")) if isinstance(data, dict) else ""
source = self.build_source_record(resource, contents, schema_version)
documents.append(ParsedSchemaDocument(source=source, data=data))
for data in self.parse_yamls(contents, resource):
schema_version = str(data.get("schema_version", "")) if isinstance(data, dict) else ""
source = self.build_source_record(resource, contents, schema_version)
documents.append(ParsedSchemaDocument(source=source, data=data))
return documents

@staticmethod
def parse_yaml(contents: bytes, resource: DiscoveredResource) -> object:
"""Parse YAML bytes and preserve the resulting root value."""
def parse_yamls(contents: bytes, resource: DiscoveredResource) -> list[object]:
"""Parse YAML bytes as a multi-document stream, skipping empty
documents, and preserve the resulting root value.
"""
try:
return yaml.safe_load(contents)
return [doc for doc in yaml.safe_load_all(contents) if doc is not None]
except yaml.YAMLError as exc:
raise SchemaLoadError(f"Invalid YAML in {resource.package}:{resource.resource_path}: {exc}") from exc

Expand All @@ -66,14 +72,17 @@ def build_source_record(self, resource: DiscoveredResource, contents: bytes, sch
"""
distribution, version = self.resolve_distribution(resource)
content_digest = hashlib.sha256(contents).hexdigest()
return SourceRecord(
distribution=distribution,
distribution_version=version,
module=resource.module,
resource_path=resource.resource_path,
schema_version=schema_version,
content_digest=content_digest,
)
source_record = {
'distribution': distribution,
'distribution_version': version,
'module': resource.module,
'resource_path': resource.resource_path,
'schema_version': schema_version,
'content_digest': content_digest,
}
if isinstance(resource, DiscoveredFileResource):
return SourceFileRecord(**source_record)
return SourceRecord(**source_record)

@classmethod
def resolve_distribution(cls, resource: DiscoveredResource) -> tuple[str, str]:
Expand All @@ -88,6 +97,8 @@ def resolve_distribution(cls, resource: DiscoveredResource) -> tuple[str, str]:
the one that actually ships this resource's file, and only fall back to
a deterministic (sorted) choice when no single owner can be identified.
"""
if isinstance(resource, DiscoveredFileResource):
return resource.package, SETTINGS_FILE_VERSION
top_level = resource.package.split(".", 1)[0]
try:
mapping = metadata.packages_distributions()
Expand Down
2 changes: 2 additions & 0 deletions src/openedx_authz/engine/schema/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from openedx_authz.engine.schema.types.loading import (
ParsedSchemaDocument,
SchemaDocument,
SourceFileRecord,
SourceRecord,
)

Expand All @@ -49,6 +50,7 @@
# loading
"SourceRecord",
"ParsedSchemaDocument",
"SourceFileRecord",
"SchemaDocument",
# compilation
"RelationshipSource",
Expand Down
16 changes: 16 additions & 0 deletions src/openedx_authz/engine/schema/types/loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ def source_id(self) -> str:
return f"{self.distribution}:{module_path}/{filename}"


@dataclass(frozen=True)
class SourceFileRecord(SourceRecord):
"""Source record for a filesystem-path schema resource (ADR 0030).

Overrides :attr:`source_id` to use the absolute path directly, since the
parent's ``module.replace(".", "/")`` convention assumes dotted Python
module paths and would corrupt a filesystem path containing a file
extension.
"""

@property
def source_id(self) -> str:
"""Return ``"<distribution>:<absolute_path>"``."""
return f"{self.distribution}:{self.resource_path}"


@dataclass(frozen=True)
class ParsedSchemaDocument:
"""One parsed YAML value and the provenance of the resource that supplied it."""
Expand Down
Loading
Loading