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

View file

@ -126,6 +126,14 @@ jobs:
with: with:
name: docs name: docs
path: docs/_build 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: check-links:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -154,6 +162,30 @@ jobs:
steps: steps:
- run: ${{ contains(needs.*.result, 'failure') && 'false' || 'true' }} - 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: publish-docs:
needs: document needs: document
if: ${{ github.repository == 'amaranth-lang/amaranth' }} if: ${{ github.repository == 'amaranth-lang/amaranth' }}

3
.gitignore vendored
View file

@ -9,6 +9,9 @@ __pycache__/
/.venv /.venv
/pdm.lock /pdm.lock
# metadata schemas
/schema
# coverage # coverage
/.coverage /.coverage
/htmlcov /htmlcov

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._ast import Shape, ShapeCastable, Const, Signal, Value, ValueCastable
from ..hdl._ir import Elaboratable from ..hdl._ir import Elaboratable
from .._utils import final from .._utils import final
from .meta import Annotation, InvalidAnnotation
__all__ = ["In", "Out", "Signature", "PureInterface", "connect", "flipped", "Component"] __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 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. 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 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 together. See the :ref:`introduction to interfaces <wiring-intro1>` for a more detailed
explanation of why this is useful. 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) 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): def __repr__(self):
if type(self) is Signature: if type(self) is Signature:
return f"Signature({dict(self.members.items())})" return f"Signature({dict(self.members.items())})"
@ -1244,7 +1260,7 @@ class FlippedInterface:
Returns Returns
------- -------
Signature :class:`Signature`
:py:`unflipped.signature.flip()` :py:`unflipped.signature.flip()`
""" """
return self.__unflipped.signature.flip() return self.__unflipped.signature.flip()
@ -1254,7 +1270,7 @@ class FlippedInterface:
Returns Returns
------- -------
bool :class:`bool`
:py:`True` if :py:`other` is an instance :py:`FlippedInterface(other_unflipped)` where :py:`True` if :py:`other` is an instance :py:`FlippedInterface(other_unflipped)` where
:py:`unflipped == other_unflipped`, :py:`False` otherwise. :py:`unflipped == other_unflipped`, :py:`False` otherwise.
""" """
@ -1676,7 +1692,7 @@ class Component(Elaboratable):
@property @property
def signature(self): def signature(self):
"""The signature of the component. """Signature of the component.
.. warning:: .. warning::
@ -1685,3 +1701,244 @@ class Component(Elaboratable):
can be used to customize a component's signature. can be used to customize a component's signature.
""" """
return self.__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

View file

@ -49,6 +49,7 @@ Implemented RFCs
.. _RFC 17: https://amaranth-lang.org/rfcs/0017-remove-log2-int.html .. _RFC 17: https://amaranth-lang.org/rfcs/0017-remove-log2-int.html
.. _RFC 27: https://amaranth-lang.org/rfcs/0027-simulator-testbenches.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 39: https://amaranth-lang.org/rfcs/0039-empty-case.html
.. _RFC 43: https://amaranth-lang.org/rfcs/0043-rename-reset-to-init.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 .. _RFC 45: https://amaranth-lang.org/rfcs/0045-lib-memory.html
@ -65,6 +66,7 @@ Implemented RFCs
* `RFC 17`_: Remove ``log2_int`` * `RFC 17`_: Remove ``log2_int``
* `RFC 27`_: Testbench processes for the simulator * `RFC 27`_: Testbench processes for the simulator
* `RFC 30`_: Component metadata
* `RFC 39`_: Change semantics of no-argument ``m.Case()`` * `RFC 39`_: Change semantics of no-argument ``m.Case()``
* `RFC 43`_: Rename ``reset=`` to ``init=`` * `RFC 43`_: Rename ``reset=`` to ``init=``
* `RFC 45`_: Move ``hdl.Memory`` to ``lib.Memory`` * `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. * 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.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: :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`_) * Deprecated: :mod:`amaranth.lib.coding`. (`RFC 63`_)
* Removed: (deprecated in 0.4) :mod:`amaranth.lib.scheduler`. (`RFC 19`_) * 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`_) * Removed: (deprecated in 0.4) :class:`amaranth.lib.fifo.FIFOInterface` with ``fwft=False``. (`RFC 20`_)

View file

@ -2,10 +2,11 @@ import os, sys
sys.path.insert(0, os.path.abspath(".")) sys.path.insert(0, os.path.abspath("."))
import time import time
import amaranth from importlib.metadata import version as package_version
project = "Amaranth language & toolchain" project = "Amaranth language & toolchain"
version = amaranth.__version__.replace(".editable", "") version = package_version('amaranth').replace(".editable", "")
release = version.split("+")[0] release = version.split("+")[0]
copyright = time.strftime("2020—%Y, Amaranth project contributors") copyright = time.strftime("2020—%Y, Amaranth project contributors")
@ -25,7 +26,9 @@ with open(".gitignore") as f:
root_doc = "cover" 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 todo_include_todos = True

View file

@ -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: 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`. 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`. 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/enum
stdlib/data stdlib/data
stdlib/wiring stdlib/wiring
stdlib/meta
stdlib/memory stdlib/memory
stdlib/cdc stdlib/cdc
stdlib/coding stdlib/coding

285
docs/stdlib/meta.rst Normal file
View file

@ -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 <wiring>`:
.. 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
<amaranth.lib.wiring.ComponentMetadata for ...Adder object at ...>
>>> 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::
<protocol>://<domain>/schema/<package>/<version>/<path>.json
where:
* ``<domain>`` is a domain name registered to the person or entity defining the annotation;
* ``<package>`` is the name of the Python package providing the :class:`Annotation` subclass;
* ``<version>`` is the version of that package;
* ``<path>`` 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": "...", ... }

View file

@ -599,4 +599,19 @@ Making connections
Components Components
========== ==========
.. _JSON Schema: https://json-schema.org
.. autoclass:: Component .. 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", ... }

View file

@ -16,6 +16,7 @@ license = {file = "LICENSE.txt"}
requires-python = "~=3.8" requires-python = "~=3.8"
dependencies = [ dependencies = [
"importlib_resources; python_version<'3.9'", # for amaranth._toolchain.yosys "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 "pyvcd>=0.2.2,<0.5", # for amaranth.sim.pysim
"Jinja2~=3.0", # for amaranth.build "Jinja2~=3.0", # for amaranth.build
] ]
@ -31,6 +32,9 @@ remote-build = ["paramiko~=2.7"]
[project.scripts] [project.scripts]
amaranth-rpc = "amaranth.rpc:main" amaranth-rpc = "amaranth.rpc:main"
[project.entry-points."amaranth.lib.meta"]
"0.5/component.json" = "amaranth.lib.wiring:ComponentMetadata"
[project.urls] [project.urls]
"Homepage" = "https://amaranth-lang.org/" "Homepage" = "https://amaranth-lang.org/"
"Documentation" = "https://amaranth-lang.org/docs/amaranth/" # modified in pdm_build.py "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-text.cmd = "python -m coverage report"
coverage-html.cmd = "python -m coverage html" coverage-html.cmd = "python -m coverage html"
coverage-xml.cmd = "python -m coverage xml" coverage-xml.cmd = "python -m coverage xml"
extract-schemas.call = "amaranth.lib.meta:_extract_schemas('amaranth', base_uri='https://amaranth-lang.org/schema/amaranth')"

87
tests/test_lib_meta.py Normal file
View file

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

View file

@ -8,8 +8,9 @@ from amaranth.lib import data, enum
from amaranth.lib.wiring import Flow, In, Out, Member from amaranth.lib.wiring import Flow, In, Out, Member
from amaranth.lib.wiring import SignatureError, SignatureMembers, FlippedSignatureMembers from amaranth.lib.wiring import SignatureError, SignatureMembers, FlippedSignatureMembers
from amaranth.lib.wiring import Signature, FlippedSignature, PureInterface, FlippedInterface 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.wiring import ConnectionError, connect, flipped
from amaranth.lib.meta import Annotation
class FlowTestCase(unittest.TestCase): class FlowTestCase(unittest.TestCase):
@ -336,6 +337,11 @@ class SignatureTestCase(unittest.TestCase):
sig = Signature({"a": In(1)}) sig = Signature({"a": In(1)})
self.assertEqual(sig.members, SignatureMembers({"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): def test_eq(self):
self.assertEqual(Signature({"a": In(1)}), self.assertEqual(Signature({"a": In(1)}),
Signature({"a": In(1)})) Signature({"a": In(1)}))
@ -1156,3 +1162,174 @@ class ComponentTestCase(unittest.TestCase):
with self.assertRaisesRegex(TypeError, with self.assertRaisesRegex(TypeError,
r"^Object 4 is not a signature nor a dict$"): r"^Object 4 is not a signature nor a dict$"):
C(2) 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")