Skip to content

Commit 23bb98c

Browse files
committed
Implement RFC 30: Component metadata.
1 parent 120375d commit 23bb98c

File tree

5 files changed

+503
-1
lines changed

5 files changed

+503
-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: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from ..hdl.ast import Shape, ShapeCastable, Const, Signal, Value, ValueCastable
99
from ..hdl.ir import Elaboratable
1010
from .._utils import final
11+
from .meta import Annotation
1112

1213

1314
__all__ = ["In", "Out", "Signature", "PureInterface", "connect", "flipped", "Component"]
@@ -359,6 +360,10 @@ def members(self, new_members):
359360
if new_members is not self.__members:
360361
raise AttributeError("property 'members' of 'Signature' object cannot be set")
361362

363+
@property
364+
def annotations(self):
365+
return ()
366+
362367
def __eq__(self, other):
363368
other_unflipped = other.flip() if type(other) is FlippedSignature else other
364369
if type(self) is type(other_unflipped) is Signature:
@@ -897,3 +902,175 @@ def signature(self):
897902
f"Component '{cls.__module__}.{cls.__qualname__}' does not have signature member "
898903
f"annotations")
899904
return signature
905+
906+
@property
907+
def metadata(self):
908+
return ComponentMetadata(self)
909+
910+
911+
class ComponentMetadata(Annotation):
912+
name = "org.amaranth-lang.amaranth.component"
913+
schema = {
914+
"$schema": "https://json-schema.org/draft/2020-12/schema",
915+
"$id": "https://amaranth-lang.org/schema/amaranth/0.5/component.json",
916+
"type": "object",
917+
"properties": {
918+
"interface": {
919+
"type": "object",
920+
"properties": {
921+
"members": {
922+
"type": "object",
923+
"patternProperties": {
924+
"^[A-Za-z][A-Za-z0-9_]*$": {
925+
"oneOf": [
926+
{
927+
"type": "object",
928+
"properties": {
929+
"type": {
930+
"enum": ["port"],
931+
},
932+
"name": {
933+
"type": "string",
934+
"pattern": "^[A-Za-z][A-Za-z0-9_]*$",
935+
},
936+
"dir": {
937+
"enum": ["in", "out"],
938+
},
939+
"width": {
940+
"type": "integer",
941+
"minimum": 0,
942+
},
943+
"signed": {
944+
"type": "boolean",
945+
},
946+
"reset": {
947+
"type": "string",
948+
"pattern": "^[+-]?[0-9]+$",
949+
},
950+
},
951+
"additionalProperties": False,
952+
"required": [
953+
"type",
954+
"name",
955+
"dir",
956+
"width",
957+
"signed",
958+
"reset",
959+
],
960+
},
961+
{
962+
"type": "object",
963+
"properties": {
964+
"type": {
965+
"enum": ["interface"],
966+
},
967+
"members": {
968+
"$ref": "#/properties/interface/properties/members",
969+
},
970+
"annotations": {
971+
"type": "object",
972+
},
973+
},
974+
"additionalProperties": False,
975+
"required": [
976+
"type",
977+
"members",
978+
"annotations",
979+
],
980+
},
981+
],
982+
},
983+
},
984+
"additionalProperties": False,
985+
},
986+
"annotations": {
987+
"type": "object",
988+
},
989+
},
990+
"additionalProperties": False,
991+
"required": [
992+
"members",
993+
"annotations",
994+
],
995+
},
996+
},
997+
"additionalProperties": False,
998+
"required": [
999+
"interface",
1000+
]
1001+
}
1002+
1003+
"""Component metadata.
1004+
1005+
A description of the interface and annotations of a :class:`Component`, which can be exported
1006+
as a JSON object.
1007+
1008+
Parameters
1009+
----------
1010+
origin : :class:`Component`
1011+
The component described by this metadata instance.
1012+
1013+
Raises
1014+
------
1015+
:exc:`TypeError`
1016+
If ``origin`` is not a :class:`Component`.
1017+
"""
1018+
def __init__(self, origin):
1019+
if not isinstance(origin, Component):
1020+
raise TypeError(f"Origin must be a Component object, not {origin!r}")
1021+
self._origin = origin
1022+
1023+
@property
1024+
def origin(self):
1025+
return self._origin
1026+
1027+
def as_json(self):
1028+
"""Translate to JSON.
1029+
1030+
Returns
1031+
-------
1032+
:class:`Mapping`
1033+
A JSON representation of :attr:`ComponentMetadata.origin`, with a hierarchical
1034+
description of its interface ports and annotations.
1035+
"""
1036+
def describe_member(member, *, path):
1037+
assert isinstance(member, Member)
1038+
if member.is_port:
1039+
cast_shape = Shape.cast(member.shape)
1040+
return {
1041+
"type": "port",
1042+
"name": "__".join(path),
1043+
"dir": "in" if member.flow == In else "out",
1044+
"width": cast_shape.width,
1045+
"signed": cast_shape.signed,
1046+
"reset": str(member._reset_as_const.value),
1047+
}
1048+
elif member.is_signature:
1049+
return {
1050+
"type": "interface",
1051+
"members": {
1052+
name: describe_member(sub_member, path=(*path, name))
1053+
for name, sub_member in member.signature.members.items()
1054+
},
1055+
"annotations": {
1056+
annotation.name: annotation.as_json()
1057+
for annotation in member.signature.annotations
1058+
},
1059+
}
1060+
else:
1061+
assert False # :nocov:
1062+
1063+
instance = {
1064+
"interface": {
1065+
"members": {
1066+
name: describe_member(member, path=(name,))
1067+
for name, member in self.origin.signature.members.items()
1068+
},
1069+
"annotations": {
1070+
annotation.name: annotation.as_json()
1071+
for annotation in self.origin.signature.annotations
1072+
},
1073+
},
1074+
}
1075+
self.validate(instance)
1076+
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)