Skip to content

Commit 553a8cd

Browse files
committed
Implement RFC 30: Component metadata.
1 parent db7e649 commit 553a8cd

File tree

5 files changed

+437
-1
lines changed

5 files changed

+437
-1
lines changed

amaranth/lib/meta.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from abc import abstractmethod, ABCMeta
2+
from collections.abc import Mapping
3+
from urllib.parse import urlparse
4+
5+
import jsonschema
6+
7+
8+
__all__ = ["Annotation"]
9+
10+
11+
class Annotation(metaclass=ABCMeta):
12+
"""Signature annotation.
13+
14+
A container for metadata that can be retrieved from a :class:`~amaranth.lib.wiring.Signature`
15+
object. Annotation instances can be exported as JSON objects, whose structure is defined using
16+
the `JSON Schema <https://json-schema.org>`_ language.
17+
18+
Schema URLs
19+
-----------
20+
21+
An ``Annotation`` schema must have a ``"$id"`` property, which holds an URL that serves as its
22+
unique identifier. The suggested format of this URL is:
23+
24+
<protocol>://<domain>/schema/<package>/<version>/<path>.json
25+
26+
where:
27+
* ``<domain>`` is a domain name registered to the person or entity defining the annotation;
28+
* ``<package>`` is the name of the Python package providing the ``Annotation`` subclass;
29+
* ``<version>`` is the version of the aforementioned package;
30+
* ``<path>`` is a non-empty string specific to the annotation.
31+
32+
Attributes
33+
----------
34+
schema : :class`Mapping`
35+
Annotation schema.
36+
"""
37+
38+
schema = property(abstractmethod(lambda: None)) # :nocov:
39+
40+
def __init_subclass__(cls, **kwargs):
41+
super().__init_subclass__(**kwargs)
42+
if not isinstance(cls.schema, Mapping):
43+
raise TypeError(f"Annotation schema must be a dict, not {cls.schema!r}")
44+
45+
# The '$id' keyword is optional in JSON schemas, but we require it.
46+
if "$id" not in cls.schema:
47+
raise ValueError(f"'$id' keyword is missing from Annotation schema: {cls.schema}")
48+
jsonschema.Draft202012Validator.check_schema(cls.schema)
49+
50+
@property
51+
@abstractmethod
52+
def origin(self):
53+
"""Annotation origin.
54+
55+
The Python object described by this :class:`Annotation` instance.
56+
"""
57+
pass # :nocov:
58+
59+
@abstractmethod
60+
def as_json(self):
61+
"""Translate to JSON.
62+
63+
Returns
64+
-------
65+
:class:`Mapping`
66+
A JSON representation of this :class:`Annotation` instance.
67+
"""
68+
pass # :nocov:
69+
70+
@classmethod
71+
def validate(cls, instance):
72+
"""Validate a JSON object.
73+
74+
Parameters
75+
----------
76+
instance : :class:`Mapping`
77+
The JSON object to validate.
78+
79+
Raises
80+
------
81+
:exc:`jsonschema.exceptions.ValidationError`
82+
If `instance` doesn't comply with :attr:`Annotation.schema`.
83+
"""
84+
jsonschema.validate(instance, schema=cls.schema)

amaranth/lib/wiring.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ..hdl.ast import Shape, ShapeCastable, Const, Signal, Value, ValueCastable
88
from ..hdl.ir import Elaboratable
99
from .._utils import final
10+
from .meta import Annotation
1011

1112

1213
__all__ = ["In", "Out", "Signature", "PureInterface", "connect", "flipped", "Component"]
@@ -705,6 +706,18 @@ def members(self):
705706
"""
706707
return self.__members
707708

709+
def annotations(self, obj, /):
710+
"""Get annotations of an interface object.
711+
712+
Subclasses of :class:`Signature` may override this method to return annotations attached
713+
to an interface object compatible with this signature.
714+
715+
Returns
716+
-------
717+
iterator of :class:`meta.Annotation`
718+
"""
719+
return ()
720+
708721
def __eq__(self, other):
709722
"""Compare this signature with another.
710723
@@ -1657,3 +1670,174 @@ def signature(self):
16571670
can be used to customize a component's signature.
16581671
"""
16591672
return self.__signature
1673+
1674+
@property
1675+
def metadata(self):
1676+
return ComponentMetadata(self)
1677+
1678+
1679+
class ComponentMetadata(Annotation):
1680+
schema = {
1681+
"$schema": "https://json-schema.org/draft/2020-12/schema",
1682+
"$id": "https://amaranth-lang.org/schema/amaranth/0.5/component.json",
1683+
"type": "object",
1684+
"properties": {
1685+
"interface": {
1686+
"type": "object",
1687+
"properties": {
1688+
"members": {
1689+
"type": "object",
1690+
"patternProperties": {
1691+
"^[A-Za-z][A-Za-z0-9_]*$": {
1692+
"oneOf": [
1693+
{
1694+
"type": "object",
1695+
"properties": {
1696+
"type": {
1697+
"enum": ["port"],
1698+
},
1699+
"name": {
1700+
"type": "string",
1701+
"pattern": "^[A-Za-z][A-Za-z0-9_]*$",
1702+
},
1703+
"dir": {
1704+
"enum": ["in", "out"],
1705+
},
1706+
"width": {
1707+
"type": "integer",
1708+
"minimum": 0,
1709+
},
1710+
"signed": {
1711+
"type": "boolean",
1712+
},
1713+
"reset": {
1714+
"type": "string",
1715+
"pattern": "^[+-]?[0-9]+$",
1716+
},
1717+
},
1718+
"additionalProperties": False,
1719+
"required": [
1720+
"type",
1721+
"name",
1722+
"dir",
1723+
"width",
1724+
"signed",
1725+
"reset",
1726+
],
1727+
},
1728+
{
1729+
"type": "object",
1730+
"properties": {
1731+
"type": {
1732+
"enum": ["interface"],
1733+
},
1734+
"members": {
1735+
"$ref": "#/properties/interface/properties/members",
1736+
},
1737+
"annotations": {
1738+
"type": "object",
1739+
},
1740+
},
1741+
"additionalProperties": False,
1742+
"required": [
1743+
"type",
1744+
"members",
1745+
"annotations",
1746+
],
1747+
},
1748+
],
1749+
},
1750+
},
1751+
"additionalProperties": False,
1752+
},
1753+
"annotations": {
1754+
"type": "object",
1755+
},
1756+
},
1757+
"additionalProperties": False,
1758+
"required": [
1759+
"members",
1760+
"annotations",
1761+
],
1762+
},
1763+
},
1764+
"additionalProperties": False,
1765+
"required": [
1766+
"interface",
1767+
]
1768+
}
1769+
1770+
"""Component metadata.
1771+
1772+
A description of the interface and annotations of a :class:`Component`, which can be exported
1773+
as a JSON object.
1774+
1775+
Parameters
1776+
----------
1777+
origin : :class:`Component`
1778+
The component described by this metadata instance.
1779+
1780+
Raises
1781+
------
1782+
:exc:`TypeError`
1783+
If ``origin`` is not a :class:`Component`.
1784+
"""
1785+
def __init__(self, origin):
1786+
if not isinstance(origin, Component):
1787+
raise TypeError(f"Origin must be a Component object, not {origin!r}")
1788+
self._origin = origin
1789+
1790+
@property
1791+
def origin(self):
1792+
return self._origin
1793+
1794+
def as_json(self):
1795+
"""Translate to JSON.
1796+
1797+
Returns
1798+
-------
1799+
:class:`Mapping`
1800+
A JSON representation of :attr:`ComponentMetadata.origin`, with a hierarchical
1801+
description of its interface ports and annotations.
1802+
"""
1803+
def describe_member(member, *, path):
1804+
assert isinstance(member, Member)
1805+
if member.is_port:
1806+
cast_shape = Shape.cast(member.shape)
1807+
return {
1808+
"type": "port",
1809+
"name": "__".join(path),
1810+
"dir": "in" if member.flow == In else "out",
1811+
"width": cast_shape.width,
1812+
"signed": cast_shape.signed,
1813+
"reset": str(member._reset_as_const.value),
1814+
}
1815+
elif member.is_signature:
1816+
return {
1817+
"type": "interface",
1818+
"members": {
1819+
name: describe_member(sub_member, path=(*path, name))
1820+
for name, sub_member in member.signature.members.items()
1821+
},
1822+
"annotations": {
1823+
annotation.schema["$id"]: annotation.as_json()
1824+
for annotation in member.signature.annotations(member)
1825+
},
1826+
}
1827+
else:
1828+
assert False # :nocov:
1829+
1830+
instance = {
1831+
"interface": {
1832+
"members": {
1833+
name: describe_member(member, path=(name,))
1834+
for name, member in self.origin.signature.members.items()
1835+
},
1836+
"annotations": {
1837+
annotation.schema["$id"]: annotation.as_json()
1838+
for annotation in self.origin.signature.annotations(self.origin)
1839+
},
1840+
},
1841+
}
1842+
self.validate(instance)
1843+
return instance

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
"importlib_resources; python_version<'3.9'", # for amaranth._toolchain.yosys
1717
"pyvcd>=0.2.2,<0.5", # for amaranth.sim.pysim
1818
"Jinja2~=3.0", # for amaranth.build
19+
"jsonschema~=4.20.0", # for amaranth.lib.meta
1920
]
2021

2122
[project.optional-dependencies]

tests/test_lib_meta.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import unittest
2+
import jsonschema
3+
4+
from amaranth import *
5+
from amaranth.lib.meta import *
6+
7+
8+
class AnnotationTestCase(unittest.TestCase):
9+
def test_init_subclass(self):
10+
class MyAnnotation(Annotation):
11+
schema = {
12+
"$id": "https://example.com/schema/test/0.1/my-annotation.json",
13+
}
14+
15+
def test_init_subclass_wrong_schema(self):
16+
with self.assertRaisesRegex(TypeError, r"Annotation schema must be a dict, not 'foo'"):
17+
class MyAnnotation(Annotation):
18+
schema = "foo"
19+
20+
def test_init_subclass_schema_missing_id(self):
21+
with self.assertRaisesRegex(ValueError, r"'\$id' keyword is missing from Annotation schema: {}"):
22+
class MyAnnotation(Annotation):
23+
schema = {}
24+
25+
def test_validate(self):
26+
class MyAnnotation(Annotation):
27+
schema = {
28+
"$id": "https://example.com/schema/test/0.1/my-annotation.json",
29+
"type": "object",
30+
"properties": {
31+
"foo": {
32+
"enum": [ "bar" ],
33+
},
34+
},
35+
"additionalProperties": False,
36+
"required": [
37+
"foo",
38+
],
39+
}
40+
MyAnnotation.validate({"foo": "bar"})
41+
42+
def test_validate_error(self):
43+
class MyAnnotation(Annotation):
44+
schema = {
45+
"$id": "https://example.com/schema/test/0.1/my-annotation.json",
46+
"type": "object",
47+
"properties": {
48+
"foo": {
49+
"enum": [ "bar" ],
50+
},
51+
},
52+
"additionalProperties": False,
53+
"required": [
54+
"foo",
55+
],
56+
}
57+
with self.assertRaises(jsonschema.exceptions.ValidationError):
58+
MyAnnotation.validate({"foo": "baz"})

0 commit comments

Comments
 (0)