Implement RFC 30: Component metadata.

Co-authored-by: Catherine <whitequark@whitequark.org>
This commit is contained in:
Jean-François Nguyen 2023-12-01 17:00:48 +01:00 committed by Catherine
parent 1d2b9c309e
commit 496432edaa
12 changed files with 1024 additions and 9 deletions

146
amaranth/lib/meta.py Normal file
View file

@ -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}")

View file

@ -7,6 +7,7 @@ from .. import tracer
from ..hdl._ast import Shape, ShapeCastable, Const, Signal, Value, ValueCastable
from ..hdl._ir import Elaboratable
from .._utils import final
from .meta import Annotation, InvalidAnnotation
__all__ = ["In", "Out", "Signature", "PureInterface", "connect", "flipped", "Component"]
@ -682,7 +683,7 @@ class Signature(metaclass=SignatureMeta):
An interface object is a Python object that has a :py:`signature` attribute containing
a :class:`Signature` object, as well as an attribute for every member of its signature.
Signatures and interface objects are tightly linked: an interface object can be created out
of a signature, and the signature is used when :func:`connect` ing two interface objects
of a signature, and the signature is used when :func:`connect`\\ ing two interface objects
together. See the :ref:`introduction to interfaces <wiring-intro1>` for a more detailed
explanation of why this is useful.
@ -984,6 +985,21 @@ class Signature(metaclass=SignatureMeta):
"""
return PureInterface(self, path=path, src_loc_at=1 + src_loc_at)
def annotations(self, obj, /):
"""Annotate an interface object.
Subclasses of :class:`Signature` may override this method to provide annotations for
a corresponding interface object. The default implementation provides none.
See :mod:`amaranth.lib.meta` for details.
Returns
-------
iterable of :class:`~.meta.Annotation`
:py:`tuple()`
"""
return tuple()
def __repr__(self):
if type(self) is Signature:
return f"Signature({dict(self.members.items())})"
@ -1244,7 +1260,7 @@ class FlippedInterface:
Returns
-------
Signature
:class:`Signature`
:py:`unflipped.signature.flip()`
"""
return self.__unflipped.signature.flip()
@ -1254,7 +1270,7 @@ class FlippedInterface:
Returns
-------
bool
:class:`bool`
:py:`True` if :py:`other` is an instance :py:`FlippedInterface(other_unflipped)` where
:py:`unflipped == other_unflipped`, :py:`False` otherwise.
"""
@ -1676,7 +1692,7 @@ class Component(Elaboratable):
@property
def signature(self):
"""The signature of the component.
"""Signature of the component.
.. warning::
@ -1685,3 +1701,244 @@ class Component(Elaboratable):
can be used to customize a component's signature.
"""
return self.__signature
@property
def metadata(self):
"""Metadata attached to the component.
Returns
-------
:class:`ComponentMetadata`
"""
return ComponentMetadata(self)
class InvalidMetadata(Exception):
"""Exception raised by :meth:`ComponentMetadata.validate` when the JSON representation of
a component's metadata does not conform to its schema."""
class ComponentMetadata(Annotation):
"""Component metadata.
Component :ref:`metadata <meta>` describes the interface of a :class:`Component` and can be
exported to JSON for interoperability with non-Amaranth tooling.
Arguments
---------
origin : :class:`Component`
Component described by this metadata instance.
"""
#: :class:`dict`: Schema of component metadata, expressed in the `JSON Schema`_ language.
#:
#: A copy of this schema can be retrieved `from amaranth-lang.org
#: <https://amaranth-lang.org/schema/amaranth/0.5/component.json>`_.
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://amaranth-lang.org/schema/amaranth/0.5/component.json",
"$defs": {
"member": {
"oneOf": [
{ "$ref": "#/$defs/member-array" },
{ "$ref": "#/$defs/member-port" },
{ "$ref": "#/$defs/member-interface" },
]
},
"member-array": {
"type": "array",
"items": {
"$ref": "#/$defs/member",
},
},
"member-port": {
"type": "object",
"properties": {
"type": { "const": "port" },
"name": {
"type": "string",
"pattern": "^[A-Za-z][A-Za-z0-9_]*$",
},
"dir": { "enum": [ "in", "out" ] },
"width": {
"type": "integer",
"minimum": 0,
},
"signed": { "type": "boolean" },
"init": {
"type": "string",
"pattern": "^[+-]?[0-9]+$",
},
},
"additionalProperties": False,
"required": [
"type",
"name",
"dir",
"width",
"signed",
"init",
],
},
"member-interface": {
"type": "object",
"properties": {
"type": { "const": "interface" },
"members": {
"type": "object",
"patternProperties": {
"^[A-Za-z][A-Za-z0-9_]*$": {
"$ref": "#/$defs/member",
},
},
},
"annotations": {
"type": "object",
"additionalProperties": {
"type": "object",
},
},
},
"additionalProperties": False,
"required": [
"type",
"members",
"annotations",
],
},
},
"type": "object",
"properties": {
"interface": {
"type": "object",
"properties": {
"members": {
"type": "object",
"patternProperties": {
"^[A-Za-z][A-Za-z0-9_]*$": {
"$ref": "#/$defs/member",
},
},
},
"annotations": {
"type": "object",
"additionalProperties": {
"type": "object",
},
},
},
"additionalProperties": False,
"required": [
"members",
"annotations",
],
},
},
"additionalProperties": False,
"required": [
"interface",
],
}
def __init__(self, origin):
if not isinstance(origin, Component):
raise TypeError(f"Origin must be a component, not {origin!r}")
self._origin = origin
@property
def origin(self):
"""Component described by this metadata.
Returns
-------
:class:`Component`
"""
return self._origin
@classmethod
def validate(cls, instance):
"""Validate a JSON representation of component metadata against :attr:`schema`.
This method does not validate annotations of the interface members, and consequently does
not make network requests.
Arguments
---------
instance : :class:`dict`
JSON representation to validate, either previously returned by :meth:`as_json` or
retrieved from an external source.
Raises
------
:exc:`InvalidMetadata`
If :py:`instance` doesn't conform to :attr:`schema`.
"""
try:
super(cls, cls).validate(instance)
except InvalidAnnotation as e:
raise InvalidMetadata(e) from e
def as_json(self):
"""Translate to JSON.
Returns
-------
:class:`dict`
JSON representation of :attr:`origin` that describes its interface members and includes
their annotations.
"""
def translate_member(member, origin, *, path):
assert isinstance(member, Member)
if member.is_port:
cast_shape = Shape.cast(member.shape)
return {
"type": "port",
"name": "__".join(str(key) for key in path),
"dir": "in" if member.flow == In else "out",
"width": cast_shape.width,
"signed": cast_shape.signed,
"init": str(member._init_as_const.value),
}
elif member.is_signature:
return {
"type": "interface",
"members": {
sub_name: translate_dimensions(sub.dimensions, sub,
getattr(origin, sub_name),
path=(*path, sub_name))
for sub_name, sub in member.signature.members.items()
},
"annotations": {
annotation.schema["$id"]: annotation.as_json()
for annotation in member.signature.annotations(origin)
},
}
else:
assert False # :nocov:
def translate_dimensions(dimensions, member, origin, *, path):
if len(dimensions) == 0:
return translate_member(member, origin, path=path)
dimension, *rest_of_dimensions = dimensions
return [
translate_dimensions(rest_of_dimensions, member, origin[index],
path=(*path, index))
for index in range(dimension)
]
instance = {
"interface": {
"members": {
member_name: translate_dimensions(member.dimensions, member,
getattr(self.origin, member_name),
path=(member_name,))
for member_name, member in self.origin.signature.members.items()
},
"annotations": {
annotation.schema["$id"]: annotation.as_json()
for annotation in self.origin.signature.annotations(self.origin)
},
},
}
self.validate(instance)
return instance