Skip to content

Commit ac84ed2

Browse files
jfngwhitequark
andcommitted
Implement RFC 30: Component metadata.
Co-authored-by: Catherine <whitequark@whitequark.org>
1 parent 1d2b9c3 commit ac84ed2

File tree

12 files changed

+1022
-11
lines changed

12 files changed

+1022
-11
lines changed

.github/workflows/main.yaml

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,6 @@ jobs:
113113
git fetch --tags https://github.com/amaranth-lang/amaranth.git
114114
- name: Set up PDM
115115
uses: pdm-project/setup-pdm@v4
116-
with:
117-
python-version: '3.12'
118116
- name: Install dependencies
119117
run: |
120118
pdm install --dev
@@ -126,6 +124,14 @@ jobs:
126124
with:
127125
name: docs
128126
path: docs/_build
127+
- name: Extract schemas
128+
run: |
129+
pdm run extract-schemas
130+
- name: Upload schema archive
131+
uses: actions/upload-artifact@v4
132+
with:
133+
name: schema
134+
path: schema
129135

130136
check-links:
131137
runs-on: ubuntu-latest
@@ -154,6 +160,30 @@ jobs:
154160
steps:
155161
- run: ${{ contains(needs.*.result, 'failure') && 'false' || 'true' }}
156162

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

.gitignore

Lines changed: 3 additions & 0 deletions
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

amaranth/lib/meta.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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 json
131+
import pathlib
132+
from importlib.metadata import distribution
133+
134+
entry_points = distribution(package).entry_points
135+
for entry_point in entry_points.select(group="amaranth.lib.meta"):
136+
schema = entry_point.load().schema
137+
relative_path = entry_point.name # v0.5/component.json
138+
schema_filename = pathlib.Path(path) / relative_path
139+
assert schema["$id"] == f"{base_uri}/{relative_path}", \
140+
f"Schema $id {schema['$id']} must be {base_uri}/{relative_path}"
141+
142+
schema_filename.parent.mkdir(parents=True, exist_ok=True)
143+
with open(pathlib.Path(path) / relative_path, "wt") as schema_file:
144+
json.dump(schema, schema_file, indent=2)

0 commit comments

Comments
 (0)