Skip to content

Commit 39dfb27

Browse files
committed
Implement RFC 30: Component metadata.
1 parent ef5cfa7 commit 39dfb27

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