Skip to content

Commit 71464aa

Browse files
committed
Implement RFC 30: Component metadata.
1 parent bf8faea commit 71464aa

File tree

5 files changed

+428
-1
lines changed

5 files changed

+428
-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 attached to a :class:`~amaranth.lib.wiring.Signature`.
15+
Annotation instances can be exported as JSON objects, whose structure is defined using the
16+
`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: 175 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,9 @@ def members(self):
705706
"""
706707
return self.__members
707708

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