diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 2f9994ea0..d3a361b87 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -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 @@ -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' }} diff --git a/.gitignore b/.gitignore index 6e98694aa..155abf58c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ __pycache__/ /.venv /pdm.lock +# metadata schemas +/schema + # coverage /.coverage /htmlcov diff --git a/amaranth/lib/meta.py b/amaranth/lib/meta.py new file mode 100644 index 000000000..84c6afa06 --- /dev/null +++ b/amaranth/lib/meta.py @@ -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}") diff --git a/amaranth/lib/wiring.py b/amaranth/lib/wiring.py index 4352b3393..1bf963e96 100644 --- a/amaranth/lib/wiring.py +++ b/amaranth/lib/wiring.py @@ -7,6 +7,7 @@ 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 ` for a more detailed explanation of why this is useful. @@ -984,6 +985,21 @@ def my_property(self): """ 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 @@ def signature(self): Returns ------- - Signature + :class:`Signature` :py:`unflipped.signature.flip()` """ return self.__unflipped.signature.flip() @@ -1254,7 +1270,7 @@ def __eq__(self, other): 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 @@ def __init__(self, signature=None, *, src_loc_at=0): @property def signature(self): - """The signature of the component. + """Signature of the component. .. warning:: @@ -1685,3 +1701,244 @@ def signature(self): 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 ` 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 + #: `_. + 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 diff --git a/docs/changes.rst b/docs/changes.rst index 9d25389e0..e2e7ce065 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -49,6 +49,7 @@ Implemented RFCs .. _RFC 17: https://amaranth-lang.org/rfcs/0017-remove-log2-int.html .. _RFC 27: https://amaranth-lang.org/rfcs/0027-simulator-testbenches.html +.. _RFC 30: https://amaranth-lang.org/rfcs/0030-component-metadata.html .. _RFC 39: https://amaranth-lang.org/rfcs/0039-empty-case.html .. _RFC 43: https://amaranth-lang.org/rfcs/0043-rename-reset-to-init.html .. _RFC 45: https://amaranth-lang.org/rfcs/0045-lib-memory.html @@ -65,6 +66,7 @@ Implemented RFCs * `RFC 17`_: Remove ``log2_int`` * `RFC 27`_: Testbench processes for the simulator +* `RFC 30`_: Component metadata * `RFC 39`_: Change semantics of no-argument ``m.Case()`` * `RFC 43`_: Rename ``reset=`` to ``init=`` * `RFC 45`_: Move ``hdl.Memory`` to ``lib.Memory`` @@ -120,6 +122,7 @@ Standard library changes * Changed: :meth:`amaranth.lib.wiring.Signature.is_compliant` no longer rejects reset-less signals. * Added: :class:`amaranth.lib.io.SingleEndedPort`, :class:`amaranth.lib.io.DifferentialPort`. (`RFC 55`_) * Added: :class:`amaranth.lib.io.Buffer`, :class:`amaranth.lib.io.FFBuffer`, :class:`amaranth.lib.io.DDRBuffer`. (`RFC 55`_) +* Added: :mod:`amaranth.lib.meta`, :class:`amaranth.lib.wiring.ComponentMetadata`. (`RFC 30`_) * Deprecated: :mod:`amaranth.lib.coding`. (`RFC 63`_) * Removed: (deprecated in 0.4) :mod:`amaranth.lib.scheduler`. (`RFC 19`_) * Removed: (deprecated in 0.4) :class:`amaranth.lib.fifo.FIFOInterface` with ``fwft=False``. (`RFC 20`_) diff --git a/docs/conf.py b/docs/conf.py index 3dbf9f909..c36797a74 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -2,10 +2,11 @@ sys.path.insert(0, os.path.abspath(".")) import time -import amaranth +from importlib.metadata import version as package_version + project = "Amaranth language & toolchain" -version = amaranth.__version__.replace(".editable", "") +version = package_version('amaranth').replace(".editable", "") release = version.split("+")[0] copyright = time.strftime("2020—%Y, Amaranth project contributors") @@ -25,7 +26,9 @@ root_doc = "cover" -intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} todo_include_todos = True diff --git a/docs/stdlib.rst b/docs/stdlib.rst index 95a12aa43..add7c5136 100644 --- a/docs/stdlib.rst +++ b/docs/stdlib.rst @@ -3,7 +3,7 @@ Standard library The :mod:`amaranth.lib` module, also known as the standard library, provides modules that falls into one of the three categories: -1. Modules that will used by essentially all idiomatic Amaranth code, and are necessary for interoperability. This includes :mod:`amaranth.lib.enum` (enumerations), :mod:`amaranth.lib.data` (data structures), and :mod:`amaranth.lib.wiring` (interfaces and components). +1. Modules that will used by essentially all idiomatic Amaranth code, or which are necessary for interoperability. This includes :mod:`amaranth.lib.enum` (enumerations), :mod:`amaranth.lib.data` (data structures), :mod:`amaranth.lib.wiring` (interfaces and components), and :mod:`amaranth.lib.meta` (interface metadata). 2. Modules that abstract common functionality whose implementation differs between hardware platforms. This includes :mod:`amaranth.lib.cdc`, :mod:`amaranth.lib.memory`. 3. Modules that have essentially one correct implementation and are of broad utility in digital designs. This includes :mod:`amaranth.lib.coding`, :mod:`amaranth.lib.fifo`, and :mod:`amaranth.lib.crc`. @@ -17,6 +17,7 @@ The Amaranth standard library is separate from the Amaranth language: everything stdlib/enum stdlib/data stdlib/wiring + stdlib/meta stdlib/memory stdlib/cdc stdlib/coding diff --git a/docs/stdlib/meta.rst b/docs/stdlib/meta.rst new file mode 100644 index 000000000..de9b14a9a --- /dev/null +++ b/docs/stdlib/meta.rst @@ -0,0 +1,285 @@ +.. _meta: + +Interface metadata +################## + +.. py:module:: amaranth.lib.meta + +The :mod:`amaranth.lib.meta` module provides a way to annotate objects in an Amaranth design and exchange these annotations with external tools in a standardized format. + +.. _JSON Schema: https://json-schema.org + +.. _"$id" keyword: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01#name-the-id-keyword + +.. testsetup:: + + from amaranth import * + from amaranth.lib import wiring, meta + from amaranth.lib.wiring import In, Out + + +Introduction +------------ + +Many Amaranth designs stay entirely within the Amaranth ecosystem, using the facilities it provides to define, test, and build hardware. In this case, the design is available for exploration using Python code, and metadata is not necessary. However, if an Amaranth design needs to fit into an existing ecosystem, or, conversely, to integrate components developed for another ecosystem, metadata can be used to exchange structured information about the design. + +Consider a simple :ref:`component `: + +.. testcode:: + + class Adder(wiring.Component): + a: In(unsigned(32)) + b: In(unsigned(32)) + o: Out(unsigned(33)) + + def elaborate(self, platform): + m = Module() + m.d.comb += self.o.eq(self.a + self.b) + return m + +.. + TODO: link to Verilog backend doc when we have it + +While it can be easily converted to Verilog, external tools will find the interface of the resulting module opaque unless they parse its Verilog source (a difficult and unrewarding task), or are provided with a description of it. Components can describe their signature with JSON-based metadata: + +.. doctest:: + + >>> adder = Adder() + >>> adder.metadata # doctest: +ELLIPSIS + + >>> adder.metadata.as_json() # doctest: +SKIP + { + 'interface': { + 'members': { + 'a': { + 'type': 'port', + 'name': 'a', + 'dir': 'in', + 'width': 32, + 'signed': False, + 'init': '0' + }, + 'b': { + 'type': 'port', + 'name': 'b', + 'dir': 'in', + 'width': 32, + 'signed': False, + 'init': '0' + }, + 'o': { + 'type': 'port', + 'name': 'o', + 'dir': 'out', + 'width': 33, + 'signed': False, + 'init': '0' + } + }, + 'annotations': {} + } + } + +.. testcode:: + :hide: + + # The way doctest requires this object to be formatted is truly hideous, even with +NORMALIZE_WHITESPACE. + assert adder.metadata.as_json() == {'interface': {'members': {'a': {'type': 'port', 'name': 'a', 'dir': 'in', 'width': 32, 'signed': False, 'init': '0'}, 'b': {'type': 'port', 'name': 'b', 'dir': 'in', 'width': 32, 'signed': False, 'init': '0'}, 'o': {'type': 'port', 'name': 'o', 'dir': 'out', 'width': 33, 'signed': False, 'init': '0'}}, 'annotations': {}}} + + +All metadata in Amaranth must adhere to a schema in the `JSON Schema`_ language, which is integral to its definition, and can be used to validate the generated JSON: + +.. doctest:: + + >>> wiring.ComponentMetadata.validate(adder.metadata.as_json()) + +The built-in component metadata can be extended to provide arbitrary information about an interface through user-defined annotations. For example, a memory bus interface could provide the layout of any memory-mapped peripherals accessible through that bus. + + +Defining annotations +-------------------- + +Consider a simple control and status register (CSR) bus that provides the memory layout of the accessible registers via an annotation: + +.. testcode:: + + class CSRLayoutAnnotation(meta.Annotation): + schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://amaranth-lang.org/schema/example/0/csr-layout.json", + "type": "object", + "properties": { + "registers": { + "type": "object", + "patternProperties": { + "^.+$": { + "type": "integer", + "minimum": 0, + }, + }, + }, + }, + "requiredProperties": [ + "registers", + ], + } + + def __init__(self, origin): + self._origin = origin + + @property + def origin(self): + return self._origin + + def as_json(self): + instance = { + "registers": self.origin.registers, + } + # Validating the value returned by `as_json()` ensures its conformance. + self.validate(instance) + return instance + + + class CSRSignature(wiring.Signature): + def __init__(self): + super().__init__({ + "addr": Out(16), + "w_en": Out(1), + "w_data": Out(32), + "r_en": Out(1), + "r_data": In(32), + }) + + def annotations(self, obj, /): + # Unfortunately `super()` cannot be used in `wiring.Signature` subclasses; + # instead, use a direct call to a superclass method. In this case that is + # `wiring.Signature` itself, but in a more complex class hierarchy it could + # be different. + return wiring.Signature.annotations(self, obj) + (CSRLayoutAnnotation(obj),) + +A component that embeds a few CSR registers would define their addresses: + +.. testcode:: + + class MyPeripheral(wiring.Component): + csr_bus: In(CSRSignature()) + + def __init__(self): + super().__init__() + self.csr_bus.registers = { + "control": 0x0000, + "status": 0x0004, + "data": 0x0008, + } + +.. doctest:: + + >>> peripheral = MyPeripheral() + >>> peripheral.metadata.as_json() # doctest: +SKIP + { + 'interface': { + 'members': { + 'csr_bus': { + 'type': 'interface', + 'members': { + 'addr': { + 'type': 'port', + 'name': 'csr_bus__addr', + 'dir': 'in', + 'width': 16, + 'signed': False, + 'init': '0' + }, + 'w_en': { + 'type': 'port', + 'name': 'csr_bus__w_en', + 'dir': 'in', + 'width': 1, + 'signed': False, + 'init': '0' + }, + 'w_data': { + 'type': 'port', + 'name': 'csr_bus__w_data', + 'dir': 'in', + 'width': 32, + 'signed': False, + 'init': '0' + }, + 'r_en': { + 'type': 'port', + 'name': 'csr_bus__r_en', + 'dir': 'in', + 'width': 1, + 'signed': False, + 'init': '0' + }, + 'r_data': { + 'type': 'port', + 'name': 'csr_bus__r_data', + 'dir': 'out', + 'width': 32, + 'signed': False, + 'init': '0' + }, + }, + 'annotations': { + 'https://amaranth-lang.org/schema/example/0/csr-layout.json': { + 'registers': { + 'control': 0, + 'status': 4, + 'data': 8 + } + } + } + } + }, + 'annotations': {} + } + } + +.. testcode:: + :hide: + + # The way doctest requires this object to be formatted is truly hideous, even with +NORMALIZE_WHITESPACE. + assert peripheral.metadata.as_json() == {'interface': {'members': {'csr_bus': {'type': 'interface', 'members': {'addr': {'type': 'port', 'name': 'csr_bus__addr', 'dir': 'in', 'width': 16, 'signed': False, 'init': '0'}, 'w_en': {'type': 'port', 'name': 'csr_bus__w_en', 'dir': 'in', 'width': 1, 'signed': False, 'init': '0'}, 'w_data': {'type': 'port', 'name': 'csr_bus__w_data', 'dir': 'in', 'width': 32, 'signed': False, 'init': '0'}, 'r_en': {'type': 'port', 'name': 'csr_bus__r_en', 'dir': 'in', 'width': 1, 'signed': False, 'init': '0'}, 'r_data': {'type': 'port', 'name': 'csr_bus__r_data', 'dir': 'out', 'width': 32, 'signed': False, 'init': '0'}}, 'annotations': {'https://amaranth-lang.org/schema/example/0/csr-layout.json': {'registers': {'control': 0, 'status': 4, 'data': 8}}}}}, 'annotations': {}}} + + +Identifying schemas +------------------- + +An :class:`Annotation` schema must have a ``"$id"`` property, whose value is a URL that serves as its globally unique identifier. The suggested format of this URL is: + +.. code:: + + :///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 :class:`Annotation` subclass; + * ```` is the version of that package; + * ```` is a non-empty string specific to the annotation. + +.. note:: + + Annotations used in the Amaranth project packages are published under https://amaranth-lang.org/schema/ according to this URL format, and are covered by the usual compatibility commitment. + + Other projects that define additional Amaranth annotations are encouraged, but not required, to make their schemas publicly accessible; the only requirement is for the URL to be globally unique. + + +Reference +--------- + +.. autoexception:: InvalidSchema + +.. autoexception:: InvalidAnnotation + +.. autoclass:: Annotation + :no-members: + :members: validate, origin, as_json + + .. automethod:: __init_subclass__() + + .. autoattribute:: schema + :annotation: = { "$id": "...", ... } diff --git a/docs/stdlib/wiring.rst b/docs/stdlib/wiring.rst index 8978e56a3..5434563c1 100644 --- a/docs/stdlib/wiring.rst +++ b/docs/stdlib/wiring.rst @@ -599,4 +599,19 @@ Making connections Components ========== +.. _JSON Schema: https://json-schema.org + .. autoclass:: Component + + +Component metadata +================== + +.. autoexception:: InvalidMetadata + +.. autoclass:: ComponentMetadata + :no-members: + :members: validate, origin, as_json + + .. autoattribute:: schema + :annotation: = { "$id": "https://amaranth-lang.org/schema/amaranth/0.5/component.json", ... } diff --git a/pyproject.toml b/pyproject.toml index 2f861e47a..dcfdec680 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ license = {file = "LICENSE.txt"} requires-python = "~=3.8" dependencies = [ "importlib_resources; python_version<'3.9'", # for amaranth._toolchain.yosys + "jschon~=0.11.1", # for amaranth.lib.meta "pyvcd>=0.2.2,<0.5", # for amaranth.sim.pysim "Jinja2~=3.0", # for amaranth.build ] @@ -31,6 +32,9 @@ remote-build = ["paramiko~=2.7"] [project.scripts] amaranth-rpc = "amaranth.rpc:main" +[project.entry-points."amaranth.lib.meta"] +"0.5/component.json" = "amaranth.lib.wiring:ComponentMetadata" + [project.urls] "Homepage" = "https://amaranth-lang.org/" "Documentation" = "https://amaranth-lang.org/docs/amaranth/" # modified in pdm_build.py @@ -95,3 +99,5 @@ document-linkcheck.cmd = "sphinx-build docs/ docs/_linkcheck/ -b linkcheck" coverage-text.cmd = "python -m coverage report" coverage-html.cmd = "python -m coverage html" coverage-xml.cmd = "python -m coverage xml" + +extract-schemas.call = "amaranth.lib.meta:_extract_schemas('amaranth', base_uri='https://amaranth-lang.org/schema/amaranth')" diff --git a/tests/test_lib_meta.py b/tests/test_lib_meta.py new file mode 100644 index 000000000..a4f20ee78 --- /dev/null +++ b/tests/test_lib_meta.py @@ -0,0 +1,87 @@ +import unittest + +from amaranth import * +from amaranth.lib.meta import * + + +class AnnotationTestCase(unittest.TestCase): + def test_init_subclass(self): + class MyAnnotation(Annotation): + schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schema/test/0.1/my-annotation.json", + "type": "string", + } + + @property + def origin(self): + return "foo" + + @property + def as_json(self): + return "foo" + + self.assertRegex(repr(MyAnnotation()), r"<.+\.MyAnnotation for 'foo'>") + + def test_init_subclass_wrong_schema(self): + with self.assertRaisesRegex(TypeError, r"Annotation schema must be a dict, not 'foo'"): + class MyAnnotation(Annotation): + schema = "foo" + + def test_init_subclass_schema_missing_id(self): + with self.assertRaisesRegex(InvalidSchema, r"'\$id' keyword is missing from Annotation schema: {}"): + class MyAnnotation(Annotation): + schema = {} + + def test_init_subclass_schema_missing_schema(self): + with self.assertRaises(InvalidSchema): + class MyAnnotation(Annotation): + schema = { + "$id": "https://example.com/schema/test/0.1/my-annotation.json", + } + + def test_init_subclass_schema_error(self): + with self.assertRaises(InvalidSchema): + class MyAnnotation(Annotation): + schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schema/test/0.1/my-annotation.json", + "type": "foo", + } + + def test_validate(self): + class MyAnnotation(Annotation): + schema = { + "$schema": "https://json-schema.org/draft/2020-12/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): + schema = { + "$schema": "https://json-schema.org/draft/2020-12/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(InvalidAnnotation): + MyAnnotation.validate({"foo": "baz"}) diff --git a/tests/test_lib_wiring.py b/tests/test_lib_wiring.py index 4602f2713..d4b7d8cc5 100644 --- a/tests/test_lib_wiring.py +++ b/tests/test_lib_wiring.py @@ -8,8 +8,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, InvalidMetadata from amaranth.lib.wiring import ConnectionError, connect, flipped +from amaranth.lib.meta import Annotation class FlowTestCase(unittest.TestCase): @@ -336,6 +337,11 @@ 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)}) + iface = PureInterface(sig) + self.assertEqual(sig.annotations(iface), ()) + def test_eq(self): self.assertEqual(Signature({"a": In(1)}), Signature({"a": In(1)})) @@ -1156,3 +1162,174 @@ def __init__(self, width): with self.assertRaisesRegex(TypeError, r"^Object 4 is not a signature nor a dict$"): C(2) + + 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): + schema = { + "$schema": "https://json-schema.org/draft/2020-12/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), init=42).array(2).array(3), + "o": Out(signed(4)) + }) + + def annotations(self, obj): + return (*Signature.annotations(self, obj), Annotation1()) + + class Signature2(Signature): + def __init__(self): + super().__init__({ + "clk": In(1), + "foo": Out(Signature1()).array(4), + "oof": In(Signature1()) + }) + + def annotations(self, obj): + return (*Signature.annotations(self, obj), Annotation1()) + + class A(Component): + def __init__(self): + super().__init__(Signature2()) + + metadata = ComponentMetadata(A()) + self.assertEqual(metadata.as_json(), { + "interface": { + "members": { + "clk": { + "type": "port", + "name": "clk", + "dir": "in", + "width": 1, + "signed": False, + "init": "0", + }, + "foo": [{ + "type": "interface", + "members": { + "i": [[{ + "type": "port", + "name": f"foo__{x}__i__{y}__{z}", + "dir": "in", + "width": 8, + "signed": False, + "init": "42", + } for z in range(2)] for y in range(3)], + "o": { + "type": "port", + "name": f"foo__{x}__o", + "dir": "out", + "width": 4, + "signed": True, + "init": "0", + }, + }, + "annotations": { + "https://example.com/schema/foo/0.1/bar.json": { + "hello": True, + }, + }, + } for x in range(4)], + "oof": { + "type": "interface", + "members": { + "i": [[{ + "type": "port", + "name": f"oof__i__{y}__{z}", + "dir": "out", + "width": 8, + "signed": False, + "init": "42", + } for z in range(2)] for y in range(3)], + "o": { + "type": "port", + "name": "oof__o", + "dir": "in", + "width": 4, + "signed": True, + "init": "0", + }, + }, + "annotations": { + "https://example.com/schema/foo/0.1/bar.json": { + "hello": True, + }, + }, + }, + }, + "annotations": { + "https://example.com/schema/foo/0.1/bar.json": { + "hello": True, + }, + }, + }, + }) + + def test_validate(self): + ComponentMetadata.validate({ + "interface": { + "members": { + "i": { + "type": "port", + "name": "i", + "dir": "in", + "width": 1, + "signed": False, + "init": "0", + }, + "o": { + "type": "port", + "name": "o", + "dir": "out", + "width": 1, + "signed": False, + "init": "0", + }, + }, + "annotations": { + "https://example.com/schema/foo/0/foo.json": { + "foo": True, + }, + }, + }, + }) + + def test_validate_error(self): + with self.assertRaises(InvalidMetadata): + ComponentMetadata.validate({ + "interface": { + "members": { + "foo": True, + }, + "annotations": {}, + }, + }) + + def test_wrong_origin(self): + with self.assertRaisesRegex(TypeError, r"Origin must be a component, not 'foo'"): + ComponentMetadata("foo")