Skip to content

Implement RFC 30: Component metadata. #978

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 10, 2024
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
32 changes: 32 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ jobs:
with:
name: docs
path: docs/_build
- name: Extract schemas
run: |
pdm run extract-schemas
- name: Upload schema archive
uses: actions/upload-artifact@v4
with:
name: schema
path: schema

check-links:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -154,6 +162,30 @@ jobs:
steps:
- run: ${{ contains(needs.*.result, 'failure') && 'false' || 'true' }}

publish-schemas:
needs: document
if: ${{ github.repository == 'amaranth-lang/amaranth' }}
runs-on: ubuntu-latest
steps:
- name: Check out source code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download schema archive
uses: actions/download-artifact@v4
with:
name: schema
path: schema/
- name: Publish development schemas
if: ${{ github.event_name == 'push' && github.event.ref == 'refs/heads/main' }}
uses: JamesIves/github-pages-deploy-action@releases/v4
with:
repository-name: amaranth-lang/amaranth-lang.github.io
ssh-key: ${{ secrets.PAGES_DEPLOY_KEY }}
branch: main
folder: schema/
target-folder: schema/amaranth/

publish-docs:
needs: document
if: ${{ github.repository == 'amaranth-lang/amaranth' }}
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ __pycache__/
/.venv
/pdm.lock

# metadata schemas
/schema

# coverage
/.coverage
/htmlcov
Expand Down
146 changes: 146 additions & 0 deletions amaranth/lib/meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import jschon
import pprint
import warnings
from abc import abstractmethod, ABCMeta


__all__ = ["InvalidSchema", "InvalidAnnotation", "Annotation"]


class InvalidSchema(Exception):
"""Exception raised when a subclass of :class:`Annotation` is defined with a non-conformant
:data:`~Annotation.schema`."""


class InvalidAnnotation(Exception):
"""Exception raised by :meth:`Annotation.validate` when the JSON representation of
an annotation does not conform to its schema."""


class Annotation(metaclass=ABCMeta):
"""Interface annotation.

Annotations are containers for metadata that can be retrieved from an interface object using
the :meth:`Signature.annotations <.wiring.Signature.annotations>` method.

Annotations have a JSON representation whose structure is defined by the `JSON Schema`_
language.
"""

#: :class:`dict`: Schema of this annotation, expressed in the `JSON Schema`_ language.
#:
#: Subclasses of :class:`Annotation` must define this class attribute.
schema = {}

@classmethod
def __jschon_schema(cls):
catalog = jschon.create_catalog("2020-12")
return jschon.JSONSchema(cls.schema, catalog=catalog)

def __init_subclass__(cls, **kwargs):
"""
Defining a subclass of :class:`Annotation` causes its :data:`schema` to be validated.

Raises
------
:exc:`InvalidSchema`
If :data:`schema` doesn't conform to the `2020-12` draft of `JSON Schema`_.
:exc:`InvalidSchema`
If :data:`schema` doesn't have a `"$id" keyword`_ at its root. This requirement is
specific to :class:`Annotation` schemas.
"""
super().__init_subclass__(**kwargs)

if not isinstance(cls.schema, dict):
raise TypeError(f"Annotation schema must be a dict, not {cls.schema!r}")

if "$id" not in cls.schema:
raise InvalidSchema(f"'$id' keyword is missing from Annotation schema: {cls.schema}")

try:
# TODO: Remove this. Ignore a deprecation warning from jschon's rfc3986 dependency.
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)
result = cls.__jschon_schema().validate()
except jschon.JSONSchemaError as e:
raise InvalidSchema(e) from e

if not result.valid:
raise InvalidSchema("Invalid Annotation schema:\n" +
pprint.pformat(result.output("basic")["errors"],
sort_dicts=False))

@property
@abstractmethod
def origin(self):
"""Python object described by this :class:`Annotation` instance.

Subclasses of :class:`Annotation` must implement this property.
"""
pass # :nocov:

@abstractmethod
def as_json(self):
"""Convert to a JSON representation.

Subclasses of :class:`Annotation` must implement this method.

JSON representation returned by this method must adhere to :data:`schema` and pass
validation by :meth:`validate`.

Returns
-------
:class:`dict`
JSON representation of this annotation, expressed in Python primitive types
(:class:`dict`, :class:`list`, :class:`str`, :class:`int`, :class:`bool`).
"""
pass # :nocov:

@classmethod
def validate(cls, instance):
"""Validate a JSON representation against :attr:`schema`.

Arguments
---------
instance : :class:`dict`
JSON representation to validate, either previously returned by :meth:`as_json`
or retrieved from an external source.

Raises
------
:exc:`InvalidAnnotation`
If :py:`instance` doesn't conform to :attr:`schema`.
"""
# TODO: Remove this. Ignore a deprecation warning from jschon's rfc3986 dependency.
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)
result = cls.__jschon_schema().evaluate(jschon.JSON(instance))

if not result.valid:
raise InvalidAnnotation("Invalid instance:\n" +
pprint.pformat(result.output("basic")["errors"],
sort_dicts=False))

def __repr__(self):
return f"<{type(self).__module__}.{type(self).__qualname__} for {self.origin!r}>"


# For internal use only; we may consider exporting this function in the future.
def _extract_schemas(package, *, base_uri, path="schema/"):
import sys
import json
import pathlib
from importlib.metadata import distribution

entry_points = distribution(package).entry_points
for entry_point in entry_points.select(group="amaranth.lib.meta"):
schema = entry_point.load().schema
relative_path = entry_point.name # v0.5/component.json
schema_filename = pathlib.Path(path) / relative_path
assert schema["$id"] == f"{base_uri}/{relative_path}", \
f"Schema $id {schema['$id']} must be {base_uri}/{relative_path}"

schema_filename.parent.mkdir(parents=True, exist_ok=True)
with open(pathlib.Path(path) / relative_path, "wt") as schema_file:
json.dump(schema, schema_file, indent=2)
print(f"Extracted {schema['$id']} to {schema_filename}")
Loading
Loading