Skip to content

Commit 496432e

Browse files
jfngwhitequark
andcommitted
Implement RFC 30: Component metadata.
Co-authored-by: Catherine <[email protected]>
1 parent 1d2b9c3 commit 496432e

File tree

12 files changed

+1024
-9
lines changed

12 files changed

+1024
-9
lines changed

Diff for: .github/workflows/main.yaml

+32
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ jobs:
126126
with:
127127
name: docs
128128
path: docs/_build
129+
- name: Extract schemas
130+
run: |
131+
pdm run extract-schemas
132+
- name: Upload schema archive
133+
uses: actions/upload-artifact@v4
134+
with:
135+
name: schema
136+
path: schema
129137

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

165+
publish-schemas:
166+
needs: document
167+
if: ${{ github.repository == 'amaranth-lang/amaranth' }}
168+
runs-on: ubuntu-latest
169+
steps:
170+
- name: Check out source code
171+
uses: actions/checkout@v4
172+
with:
173+
fetch-depth: 0
174+
- name: Download schema archive
175+
uses: actions/download-artifact@v4
176+
with:
177+
name: schema
178+
path: schema/
179+
- name: Publish development schemas
180+
if: ${{ github.event_name == 'push' && github.event.ref == 'refs/heads/main' }}
181+
uses: JamesIves/github-pages-deploy-action@releases/v4
182+
with:
183+
repository-name: amaranth-lang/amaranth-lang.github.io
184+
ssh-key: ${{ secrets.PAGES_DEPLOY_KEY }}
185+
branch: main
186+
folder: schema/
187+
target-folder: schema/amaranth/
188+
157189
publish-docs:
158190
needs: document
159191
if: ${{ github.repository == 'amaranth-lang/amaranth' }}

Diff for: .gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ __pycache__/
99
/.venv
1010
/pdm.lock
1111

12+
# metadata schemas
13+
/schema
14+
1215
# coverage
1316
/.coverage
1417
/htmlcov

Diff for: amaranth/lib/meta.py

+146
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import jschon
2+
import pprint
3+
import warnings
4+
from abc import abstractmethod, ABCMeta
5+
6+
7+
__all__ = ["InvalidSchema", "InvalidAnnotation", "Annotation"]
8+
9+
10+
class InvalidSchema(Exception):
11+
"""Exception raised when a subclass of :class:`Annotation` is defined with a non-conformant
12+
:data:`~Annotation.schema`."""
13+
14+
15+
class InvalidAnnotation(Exception):
16+
"""Exception raised by :meth:`Annotation.validate` when the JSON representation of
17+
an annotation does not conform to its schema."""
18+
19+
20+
class Annotation(metaclass=ABCMeta):
21+
"""Interface annotation.
22+
23+
Annotations are containers for metadata that can be retrieved from an interface object using
24+
the :meth:`Signature.annotations <.wiring.Signature.annotations>` method.
25+
26+
Annotations have a JSON representation whose structure is defined by the `JSON Schema`_
27+
language.
28+
"""
29+
30+
#: :class:`dict`: Schema of this annotation, expressed in the `JSON Schema`_ language.
31+
#:
32+
#: Subclasses of :class:`Annotation` must define this class attribute.
33+
schema = {}
34+
35+
@classmethod
36+
def __jschon_schema(cls):
37+
catalog = jschon.create_catalog("2020-12")
38+
return jschon.JSONSchema(cls.schema, catalog=catalog)
39+
40+
def __init_subclass__(cls, **kwargs):
41+
"""
42+
Defining a subclass of :class:`Annotation` causes its :data:`schema` to be validated.
43+
44+
Raises
45+
------
46+
:exc:`InvalidSchema`
47+
If :data:`schema` doesn't conform to the `2020-12` draft of `JSON Schema`_.
48+
:exc:`InvalidSchema`
49+
If :data:`schema` doesn't have a `"$id" keyword`_ at its root. This requirement is
50+
specific to :class:`Annotation` schemas.
51+
"""
52+
super().__init_subclass__(**kwargs)
53+
54+
if not isinstance(cls.schema, dict):
55+
raise TypeError(f"Annotation schema must be a dict, not {cls.schema!r}")
56+
57+
if "$id" not in cls.schema:
58+
raise InvalidSchema(f"'$id' keyword is missing from Annotation schema: {cls.schema}")
59+
60+
try:
61+
# TODO: Remove this. Ignore a deprecation warning from jschon's rfc3986 dependency.
62+
with warnings.catch_warnings():
63+
warnings.filterwarnings("ignore", category=DeprecationWarning)
64+
result = cls.__jschon_schema().validate()
65+
except jschon.JSONSchemaError as e:
66+
raise InvalidSchema(e) from e
67+
68+
if not result.valid:
69+
raise InvalidSchema("Invalid Annotation schema:\n" +
70+
pprint.pformat(result.output("basic")["errors"],
71+
sort_dicts=False))
72+
73+
@property
74+
@abstractmethod
75+
def origin(self):
76+
"""Python object described by this :class:`Annotation` instance.
77+
78+
Subclasses of :class:`Annotation` must implement this property.
79+
"""
80+
pass # :nocov:
81+
82+
@abstractmethod
83+
def as_json(self):
84+
"""Convert to a JSON representation.
85+
86+
Subclasses of :class:`Annotation` must implement this method.
87+
88+
JSON representation returned by this method must adhere to :data:`schema` and pass
89+
validation by :meth:`validate`.
90+
91+
Returns
92+
-------
93+
:class:`dict`
94+
JSON representation of this annotation, expressed in Python primitive types
95+
(:class:`dict`, :class:`list`, :class:`str`, :class:`int`, :class:`bool`).
96+
"""
97+
pass # :nocov:
98+
99+
@classmethod
100+
def validate(cls, instance):
101+
"""Validate a JSON representation against :attr:`schema`.
102+
103+
Arguments
104+
---------
105+
instance : :class:`dict`
106+
JSON representation to validate, either previously returned by :meth:`as_json`
107+
or retrieved from an external source.
108+
109+
Raises
110+
------
111+
:exc:`InvalidAnnotation`
112+
If :py:`instance` doesn't conform to :attr:`schema`.
113+
"""
114+
# TODO: Remove this. Ignore a deprecation warning from jschon's rfc3986 dependency.
115+
with warnings.catch_warnings():
116+
warnings.filterwarnings("ignore", category=DeprecationWarning)
117+
result = cls.__jschon_schema().evaluate(jschon.JSON(instance))
118+
119+
if not result.valid:
120+
raise InvalidAnnotation("Invalid instance:\n" +
121+
pprint.pformat(result.output("basic")["errors"],
122+
sort_dicts=False))
123+
124+
def __repr__(self):
125+
return f"<{type(self).__module__}.{type(self).__qualname__} for {self.origin!r}>"
126+
127+
128+
# For internal use only; we may consider exporting this function in the future.
129+
def _extract_schemas(package, *, base_uri, path="schema/"):
130+
import sys
131+
import json
132+
import pathlib
133+
from importlib.metadata import distribution
134+
135+
entry_points = distribution(package).entry_points
136+
for entry_point in entry_points.select(group="amaranth.lib.meta"):
137+
schema = entry_point.load().schema
138+
relative_path = entry_point.name # v0.5/component.json
139+
schema_filename = pathlib.Path(path) / relative_path
140+
assert schema["$id"] == f"{base_uri}/{relative_path}", \
141+
f"Schema $id {schema['$id']} must be {base_uri}/{relative_path}"
142+
143+
schema_filename.parent.mkdir(parents=True, exist_ok=True)
144+
with open(pathlib.Path(path) / relative_path, "wt") as schema_file:
145+
json.dump(schema, schema_file, indent=2)
146+
print(f"Extracted {schema['$id']} to {schema_filename}")

0 commit comments

Comments
 (0)