Skip to content

Commit f64c7c3

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

File tree

5 files changed

+508
-1
lines changed

5 files changed

+508
-1
lines changed

amaranth/lib/meta.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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 and annotation names
19+
--------------------------------
20+
21+
An ``Annotation`` schema must have a ``"$id"`` property, which holds an URL that serves as its
22+
unique identifier. This URL should have the following format:
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+
An ``Annotation`` name must be retrievable from the ``"$id"`` URL. It is the concatenation
33+
of the following, separated by '.':
34+
* ``<domain>``, reversed (e.g. ``"org.amaranth-lang"``);
35+
* ``<package>``;
36+
* ``<path>``, split using '/' as separator.
37+
38+
For example, ``"https://example.github.io/schema/foo/1.0/bar/baz.json"`` is the URL of an
39+
annotation whose name is ``"io.github.example.foo.bar.baz"``.
40+
41+
Attributes
42+
----------
43+
name : :class:`str`
44+
Annotation name.
45+
schema : :class`Mapping`
46+
Annotation schema.
47+
"""
48+
49+
name = property(abstractmethod(lambda: None)) # :nocov:
50+
schema = property(abstractmethod(lambda: None)) # :nocov:
51+
52+
def __init_subclass__(cls, **kwargs):
53+
super().__init_subclass__(**kwargs)
54+
if not isinstance(cls.name, str):
55+
raise TypeError(f"Annotation name must be a string, not {cls.name!r}")
56+
if not isinstance(cls.schema, Mapping):
57+
raise TypeError(f"Annotation schema must be a dict, not {cls.schema!r}")
58+
59+
# The '$id' keyword is optional in JSON schemas, but we require it.
60+
if "$id" not in cls.schema:
61+
raise ValueError(f"'$id' keyword is missing from Annotation schema: {cls.schema}")
62+
jsonschema.Draft202012Validator.check_schema(cls.schema)
63+
64+
parsed_id = urlparse(cls.schema["$id"])
65+
if not parsed_id.path.startswith("/schema/"):
66+
raise ValueError(f"'$id' URL path must start with '/schema/' ('{cls.schema['$id']}')")
67+
if not parsed_id.path.endswith(".json"):
68+
raise ValueError(f"'$id' URL path must have a '.json' suffix ('{cls.schema['$id']}')")
69+
70+
_, _, package, version, *path = parsed_id.path[:-len(".json")].split("/")
71+
parsed_name = ".".join((*reversed(parsed_id.netloc.split(".")), package, *path))
72+
if cls.name != parsed_name:
73+
raise ValueError(f"Annotation name '{cls.name}' must be obtainable from the '$id' "
74+
f"URL ('{cls.schema['$id']}'), but does not match '{parsed_name}'")
75+
76+
@property
77+
@abstractmethod
78+
def origin(self):
79+
"""Annotation origin.
80+
81+
The Python object described by this :class:`Annotation` instance.
82+
"""
83+
pass # :nocov:
84+
85+
@abstractmethod
86+
def as_json(self):
87+
"""Translate to JSON.
88+
89+
Returns
90+
-------
91+
:class:`Mapping`
92+
A JSON representation of this :class:`Annotation` instance.
93+
"""
94+
pass # :nocov:
95+
96+
@classmethod
97+
def validate(cls, instance):
98+
"""Validate a JSON object.
99+
100+
Parameters
101+
----------
102+
instance : :class:`Mapping`
103+
The JSON object to validate.
104+
105+
Raises
106+
------
107+
:exc:`jsonschema.exceptions.ValidationError`
108+
If `instance` doesn't comply with :attr:`Annotation.schema`.
109+
"""
110+
jsonschema.validate(instance, schema=cls.schema)

amaranth/lib/wiring.py

Lines changed: 183 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,16 @@ def members(self):
705706
"""
706707
return self.__members
707708

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

0 commit comments

Comments
 (0)