Skip to content

Commit 6af7e05

Browse files
jfngwhitequark
authored andcommitted
Implement RFC 30: Component metadata.
1 parent e3324e1 commit 6af7e05

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"]
@@ -711,6 +712,18 @@ def members(self):
711712
"""
712713
return self.__members
713714

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

pyproject.toml

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

2223
[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)