From 23bb98c470fa5f22c02b7806d1da4fe5e37cc7c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Nguyen?= Date: Fri, 1 Dec 2023 17:00:48 +0100 Subject: [PATCH] Implement RFC 30: Component metadata. --- amaranth/lib/meta.py | 110 ++++++++++++++++++++++++ amaranth/lib/wiring.py | 177 +++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_lib_meta.py | 102 ++++++++++++++++++++++ tests/test_lib_wiring.py | 114 ++++++++++++++++++++++++- 5 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 amaranth/lib/meta.py create mode 100644 tests/test_lib_meta.py diff --git a/amaranth/lib/meta.py b/amaranth/lib/meta.py new file mode 100644 index 0000000000..2d03c0a814 --- /dev/null +++ b/amaranth/lib/meta.py @@ -0,0 +1,110 @@ +from abc import abstractmethod, ABCMeta +from collections.abc import Mapping +from urllib.parse import urlparse + +import jsonschema + + +__all__ = ["Annotation"] + + +class Annotation(metaclass=ABCMeta): + """Signature annotation. + + A container for metadata that can be attached to a :class:`~amaranth.lib.wiring.Signature`. + Annotation instances can be exported as JSON objects, whose structure is defined using the + `JSON Schema `_ language. + + Schema URLs and annotation names + -------------------------------- + + An ``Annotation`` schema must have a ``"$id"`` property, which holds an URL that serves as its + unique identifier. This URL should have the following format: + + :///schema///.json + + where: + * ```` is a domain name registered to the person or entity defining the annotation; + * ```` is the name of the Python package providing the ``Annotation`` subclass; + * ```` is the version of the aforementioned package; + * ```` is a non-empty string specific to the annotation. + + An ``Annotation`` name must be retrievable from the ``"$id"`` URL. It is the concatenation + of the following, separated by '.': + * ````, reversed (e.g. ``"org.amaranth-lang"``); + * ````; + * ````, split using '/' as separator. + + For example, ``"https://example.github.io/schema/foo/1.0/bar/baz.json"`` is the URL of an + annotation whose name is ``"io.github.example.foo.bar.baz"``. + + Attributes + ---------- + name : :class:`str` + Annotation name. + schema : :class`Mapping` + Annotation schema. + """ + + name = property(abstractmethod(lambda: None)) # :nocov: + schema = property(abstractmethod(lambda: None)) # :nocov: + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if not isinstance(cls.name, str): + raise TypeError(f"Annotation name must be a string, not {cls.name!r}") + if not isinstance(cls.schema, Mapping): + raise TypeError(f"Annotation schema must be a dict, not {cls.schema!r}") + + # The '$id' keyword is optional in JSON schemas, but we require it. + if "$id" not in cls.schema: + raise ValueError(f"'$id' keyword is missing from Annotation schema: {cls.schema}") + jsonschema.Draft202012Validator.check_schema(cls.schema) + + parsed_id = urlparse(cls.schema["$id"]) + if not parsed_id.path.startswith("/schema/"): + raise ValueError(f"'$id' URL path must start with '/schema/' ('{cls.schema['$id']}')") + if not parsed_id.path.endswith(".json"): + raise ValueError(f"'$id' URL path must have a '.json' suffix ('{cls.schema['$id']}')") + + _, _, package, version, *path = parsed_id.path[:-len(".json")].split("/") + parsed_name = ".".join((*reversed(parsed_id.netloc.split(".")), package, *path)) + if cls.name != parsed_name: + raise ValueError(f"Annotation name '{cls.name}' must be obtainable from the '$id' " + f"URL ('{cls.schema['$id']}'), but does not match '{parsed_name}'") + + @property + @abstractmethod + def origin(self): + """Annotation origin. + + The Python object described by this :class:`Annotation` instance. + """ + pass # :nocov: + + @abstractmethod + def as_json(self): + """Translate to JSON. + + Returns + ------- + :class:`Mapping` + A JSON representation of this :class:`Annotation` instance. + """ + pass # :nocov: + + @classmethod + def validate(cls, instance): + """Validate a JSON object. + + Parameters + ---------- + instance : :class:`Mapping` + The JSON object to validate. + + Raises + ------ + :exc:`jsonschema.exceptions.ValidationError` + If `instance` doesn't comply with :attr:`Annotation.schema`. + """ + jsonschema.validate(instance, schema=cls.schema) diff --git a/amaranth/lib/wiring.py b/amaranth/lib/wiring.py index 0d94201890..748b07e262 100644 --- a/amaranth/lib/wiring.py +++ b/amaranth/lib/wiring.py @@ -8,6 +8,7 @@ from ..hdl.ast import Shape, ShapeCastable, Const, Signal, Value, ValueCastable from ..hdl.ir import Elaboratable from .._utils import final +from .meta import Annotation __all__ = ["In", "Out", "Signature", "PureInterface", "connect", "flipped", "Component"] @@ -359,6 +360,10 @@ def members(self, new_members): if new_members is not self.__members: raise AttributeError("property 'members' of 'Signature' object cannot be set") + @property + def annotations(self): + return () + def __eq__(self, other): other_unflipped = other.flip() if type(other) is FlippedSignature else other if type(self) is type(other_unflipped) is Signature: @@ -897,3 +902,175 @@ def signature(self): f"Component '{cls.__module__}.{cls.__qualname__}' does not have signature member " f"annotations") return signature + + @property + def metadata(self): + return ComponentMetadata(self) + + +class ComponentMetadata(Annotation): + name = "org.amaranth-lang.amaranth.component" + schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://amaranth-lang.org/schema/amaranth/0.5/component.json", + "type": "object", + "properties": { + "interface": { + "type": "object", + "properties": { + "members": { + "type": "object", + "patternProperties": { + "^[A-Za-z][A-Za-z0-9_]*$": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "enum": ["port"], + }, + "name": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_]*$", + }, + "dir": { + "enum": ["in", "out"], + }, + "width": { + "type": "integer", + "minimum": 0, + }, + "signed": { + "type": "boolean", + }, + "reset": { + "type": "string", + "pattern": "^[+-]?[0-9]+$", + }, + }, + "additionalProperties": False, + "required": [ + "type", + "name", + "dir", + "width", + "signed", + "reset", + ], + }, + { + "type": "object", + "properties": { + "type": { + "enum": ["interface"], + }, + "members": { + "$ref": "#/properties/interface/properties/members", + }, + "annotations": { + "type": "object", + }, + }, + "additionalProperties": False, + "required": [ + "type", + "members", + "annotations", + ], + }, + ], + }, + }, + "additionalProperties": False, + }, + "annotations": { + "type": "object", + }, + }, + "additionalProperties": False, + "required": [ + "members", + "annotations", + ], + }, + }, + "additionalProperties": False, + "required": [ + "interface", + ] + } + + """Component metadata. + + A description of the interface and annotations of a :class:`Component`, which can be exported + as a JSON object. + + Parameters + ---------- + origin : :class:`Component` + The component described by this metadata instance. + + Raises + ------ + :exc:`TypeError` + If ``origin`` is not a :class:`Component`. + """ + def __init__(self, origin): + if not isinstance(origin, Component): + raise TypeError(f"Origin must be a Component object, not {origin!r}") + self._origin = origin + + @property + def origin(self): + return self._origin + + def as_json(self): + """Translate to JSON. + + Returns + ------- + :class:`Mapping` + A JSON representation of :attr:`ComponentMetadata.origin`, with a hierarchical + description of its interface ports and annotations. + """ + def describe_member(member, *, path): + assert isinstance(member, Member) + if member.is_port: + cast_shape = Shape.cast(member.shape) + return { + "type": "port", + "name": "__".join(path), + "dir": "in" if member.flow == In else "out", + "width": cast_shape.width, + "signed": cast_shape.signed, + "reset": str(member._reset_as_const.value), + } + elif member.is_signature: + return { + "type": "interface", + "members": { + name: describe_member(sub_member, path=(*path, name)) + for name, sub_member in member.signature.members.items() + }, + "annotations": { + annotation.name: annotation.as_json() + for annotation in member.signature.annotations + }, + } + else: + assert False # :nocov: + + instance = { + "interface": { + "members": { + name: describe_member(member, path=(name,)) + for name, member in self.origin.signature.members.items() + }, + "annotations": { + annotation.name: annotation.as_json() + for annotation in self.origin.signature.annotations + }, + }, + } + self.validate(instance) + return instance diff --git a/pyproject.toml b/pyproject.toml index 4e07062660..71aea8dae6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "importlib_resources; python_version<'3.9'", # for amaranth._toolchain.yosys "pyvcd>=0.2.2,<0.5", # for amaranth.sim.pysim "Jinja2~=3.0", # for amaranth.build + "jsonschema~=4.20.0", # for amaranth.lib.meta ] [project.optional-dependencies] diff --git a/tests/test_lib_meta.py b/tests/test_lib_meta.py new file mode 100644 index 0000000000..60d612142c --- /dev/null +++ b/tests/test_lib_meta.py @@ -0,0 +1,102 @@ +import unittest +import jsonschema + +from amaranth import * +from amaranth.lib.meta import * + + +class AnnotationTestCase(unittest.TestCase): + def test_init_subclass(self): + class MyAnnotation(Annotation): + name = "com.example.test.my-annotation" + schema = { + "$id": "https://example.com/schema/test/0.1/my-annotation.json", + } + + def test_init_subclass_wrong_name(self): + with self.assertRaisesRegex(TypeError, r"Annotation name must be a string, not 1"): + class MyAnnotation(Annotation): + name = 1 + schema = { + "$id": "https://example.com/schema/test/0.1/my-annotation.json", + } + + def test_init_subclass_wrong_schema(self): + with self.assertRaisesRegex(TypeError, r"Annotation schema must be a dict, not 'foo'"): + class MyAnnotation(Annotation): + name = "com.example.test.my-annotation" + schema = "foo" + + def test_init_subclass_schema_missing_id(self): + with self.assertRaisesRegex(ValueError, r"'\$id' keyword is missing from Annotation schema: {}"): + class MyAnnotation(Annotation): + name = "com.example.test.my-annotation" + schema = {} + + def test_init_subclass_schema_id_path_start(self): + with self.assertRaisesRegex(ValueError, + r"'\$id' URL path must start with '/schema/' " + r"\('https://example.com/test/0.1/my-annotation.json'\)"): + class MyAnnotation(Annotation): + name = "com.example.test.my-annotation" + schema = { + "$id": "https://example.com/test/0.1/my-annotation.json", + } + + def test_init_subclass_schema_id_path_suffix(self): + with self.assertRaisesRegex(ValueError, + r"'\$id' URL path must have a '.json' suffix " + r"\('https://example.com/schema/test/0.1/my-annotation'\)"): + class MyAnnotation(Annotation): + name = "com.example.test.my-annotation" + schema = { + "$id": "https://example.com/schema/test/0.1/my-annotation", + } + + def test_init_subclass_name_from_schema_id(self): + with self.assertRaisesRegex(ValueError, + r"Annotation name 'com.example.test.foo.my-annotation' must be obtainable from " + r"the '\$id' URL \('https://example.com/schema/test/0.1/my-annotation.json'\), " + r"but does not match 'com.example.test.my-annotation'"): + class MyAnnotation(Annotation): + name = "com.example.test.foo.my-annotation" + schema = { + "$id": "https://example.com/schema/test/0.1/my-annotation.json", + } + + def test_validate(self): + class MyAnnotation(Annotation): + name = "com.example.test.my-annotation" + schema = { + "$id": "https://example.com/schema/test/0.1/my-annotation.json", + "type": "object", + "properties": { + "foo": { + "enum": [ "bar" ], + }, + }, + "additionalProperties": False, + "required": [ + "foo", + ], + } + MyAnnotation.validate({"foo": "bar"}) + + def test_validate_error(self): + class MyAnnotation(Annotation): + name = "com.example.test.my-annotation" + schema = { + "$id": "https://example.com/schema/test/0.1/my-annotation.json", + "type": "object", + "properties": { + "foo": { + "enum": [ "bar" ], + }, + }, + "additionalProperties": False, + "required": [ + "foo", + ], + } + with self.assertRaises(jsonschema.exceptions.ValidationError): + MyAnnotation.validate({"foo": "baz"}) diff --git a/tests/test_lib_wiring.py b/tests/test_lib_wiring.py index ac6e2f0eca..eccbec17cd 100644 --- a/tests/test_lib_wiring.py +++ b/tests/test_lib_wiring.py @@ -9,8 +9,9 @@ from amaranth.lib.wiring import Flow, In, Out, Member from amaranth.lib.wiring import SignatureError, SignatureMembers, FlippedSignatureMembers from amaranth.lib.wiring import Signature, FlippedSignature, PureInterface, FlippedInterface -from amaranth.lib.wiring import Component +from amaranth.lib.wiring import Component, ComponentMetadata from amaranth.lib.wiring import ConnectionError, connect, flipped +from amaranth.lib.meta import Annotation class FlowTestCase(unittest.TestCase): @@ -359,6 +360,10 @@ def test_create(self): sig = Signature({"a": In(1)}) self.assertEqual(sig.members, SignatureMembers({"a": In(1)})) + def test_annotations_empty(self): + sig = Signature({"a": In(1)}) + self.assertEqual(sig.annotations, ()) + def test_eq(self): self.assertEqual(Signature({"a": In(1)}), Signature({"a": In(1)})) @@ -1119,3 +1124,110 @@ class C(B): c = C() self.assertEqual(c.signature, Signature({"clk": In(1), "rst": In(1)})) + + def test_metadata_origin(self): + class A(Component): + clk: In(1) + + a = A() + self.assertIsInstance(a.metadata, ComponentMetadata) + self.assertIs(a.metadata.origin, a) + + +class ComponentMetadataTestCase(unittest.TestCase): + def test_as_json(self): + class Annotation1(Annotation): + name = "com.example.foo.bar" + schema = { + "$id": "https://example.com/schema/foo/0.1/bar.json", + "type": "object", + "properties": { + "hello": { "type": "boolean" }, + }, + } + + def origin(self): + return object() + + def as_json(self): + instance = { "hello": True } + self.validate(instance) + return instance + + class Signature1(Signature): + def __init__(self): + super().__init__({ + "i": In(unsigned(8), reset=42), + "o": Out(signed(4)) + }) + + @property + def annotations(self): + return (*super().annotations, Annotation1()) + + class Signature2(Signature): + def __init__(self): + super().__init__({ + "clk": In(1), + "foo": Out(Signature1()), + }) + + @property + def annotations(self): + return (*super().annotations, Annotation1()) + + class A(Component): + @property + def signature(self): + return Signature2() + + metadata = ComponentMetadata(A()) + self.assertEqual(metadata.as_json(), { + "interface": { + "members": { + "clk": { + "type": "port", + "name": "clk", + "dir": "in", + "width": 1, + "signed": False, + "reset": "0", + }, + "foo": { + "type": "interface", + "members": { + "i": { + "type": "port", + "name": "foo__i", + "dir": "in", + "width": 8, + "signed": False, + "reset": "42", + }, + "o": { + "type": "port", + "name": "foo__o", + "dir": "out", + "width": 4, + "signed": True, + "reset": "0", + }, + }, + "annotations": { + "com.example.foo.bar": { + "hello": True, + }, + }, + }, + }, + "annotations": { + "com.example.foo.bar": { + "hello": True, + }, + }, + }, + }) + + def test_wrong_origin(self): + with self.assertRaisesRegex(TypeError, r"Origin must be a Component object, not 'foo'"): + ComponentMetadata("foo")