Skip to content

Commit

Permalink
Implement RFC 30: Component metadata.
Browse files Browse the repository at this point in the history
  • Loading branch information
jfng committed Dec 5, 2023
1 parent 120375d commit 23bb98c
Show file tree
Hide file tree
Showing 5 changed files with 503 additions and 1 deletion.
110 changes: 110 additions & 0 deletions amaranth/lib/meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from abc import abstractmethod, ABCMeta
from collections.abc import Mapping
from urllib.parse import urlparse

import jsonschema


__all__ = ["Annotation"]


class Annotation(metaclass=ABCMeta):
"""Signature annotation.
A container for metadata that can be attached to a :class:`~amaranth.lib.wiring.Signature`.
Annotation instances can be exported as JSON objects, whose structure is defined using the
`JSON Schema <https://json-schema.org>`_ language.
Schema URLs and annotation names
--------------------------------
An ``Annotation`` schema must have a ``"$id"`` property, which holds an URL that serves as its
unique identifier. This URL should have the following format:
<protocol>://<domain>/schema/<package>/<version>/<path>.json
where:
* ``<domain>`` is a domain name registered to the person or entity defining the annotation;
* ``<package>`` is the name of the Python package providing the ``Annotation`` subclass;
* ``<version>`` is the version of the aforementioned package;
* ``<path>`` is a non-empty string specific to the annotation.
An ``Annotation`` name must be retrievable from the ``"$id"`` URL. It is the concatenation
of the following, separated by '.':
* ``<domain>``, reversed (e.g. ``"org.amaranth-lang"``);
* ``<package>``;
* ``<path>``, split using '/' as separator.
For example, ``"https://example.github.io/schema/foo/1.0/bar/baz.json"`` is the URL of an
annotation whose name is ``"io.github.example.foo.bar.baz"``.
Attributes
----------
name : :class:`str`
Annotation name.
schema : :class`Mapping`
Annotation schema.
"""

name = property(abstractmethod(lambda: None)) # :nocov:
schema = property(abstractmethod(lambda: None)) # :nocov:

def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not isinstance(cls.name, str):
raise TypeError(f"Annotation name must be a string, not {cls.name!r}")
if not isinstance(cls.schema, Mapping):
raise TypeError(f"Annotation schema must be a dict, not {cls.schema!r}")

# The '$id' keyword is optional in JSON schemas, but we require it.
if "$id" not in cls.schema:
raise ValueError(f"'$id' keyword is missing from Annotation schema: {cls.schema}")
jsonschema.Draft202012Validator.check_schema(cls.schema)

parsed_id = urlparse(cls.schema["$id"])
if not parsed_id.path.startswith("/schema/"):
raise ValueError(f"'$id' URL path must start with '/schema/' ('{cls.schema['$id']}')")
if not parsed_id.path.endswith(".json"):
raise ValueError(f"'$id' URL path must have a '.json' suffix ('{cls.schema['$id']}')")

_, _, package, version, *path = parsed_id.path[:-len(".json")].split("/")
parsed_name = ".".join((*reversed(parsed_id.netloc.split(".")), package, *path))
if cls.name != parsed_name:
raise ValueError(f"Annotation name '{cls.name}' must be obtainable from the '$id' "
f"URL ('{cls.schema['$id']}'), but does not match '{parsed_name}'")

@property
@abstractmethod
def origin(self):
"""Annotation origin.
The Python object described by this :class:`Annotation` instance.
"""
pass # :nocov:

@abstractmethod
def as_json(self):
"""Translate to JSON.
Returns
-------
:class:`Mapping`
A JSON representation of this :class:`Annotation` instance.
"""
pass # :nocov:

@classmethod
def validate(cls, instance):
"""Validate a JSON object.
Parameters
----------
instance : :class:`Mapping`
The JSON object to validate.
Raises
------
:exc:`jsonschema.exceptions.ValidationError`
If `instance` doesn't comply with :attr:`Annotation.schema`.
"""
jsonschema.validate(instance, schema=cls.schema)
177 changes: 177 additions & 0 deletions amaranth/lib/wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from ..hdl.ast import Shape, ShapeCastable, Const, Signal, Value, ValueCastable
from ..hdl.ir import Elaboratable
from .._utils import final
from .meta import Annotation


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

@property
def annotations(self):
return ()

def __eq__(self, other):
other_unflipped = other.flip() if type(other) is FlippedSignature else other
if type(self) is type(other_unflipped) is Signature:
Expand Down Expand Up @@ -897,3 +902,175 @@ def signature(self):
f"Component '{cls.__module__}.{cls.__qualname__}' does not have signature member "
f"annotations")
return signature

@property
def metadata(self):
return ComponentMetadata(self)


class ComponentMetadata(Annotation):
name = "org.amaranth-lang.amaranth.component"
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://amaranth-lang.org/schema/amaranth/0.5/component.json",
"type": "object",
"properties": {
"interface": {
"type": "object",
"properties": {
"members": {
"type": "object",
"patternProperties": {
"^[A-Za-z][A-Za-z0-9_]*$": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"enum": ["port"],
},
"name": {
"type": "string",
"pattern": "^[A-Za-z][A-Za-z0-9_]*$",
},
"dir": {
"enum": ["in", "out"],
},
"width": {
"type": "integer",
"minimum": 0,
},
"signed": {
"type": "boolean",
},
"reset": {
"type": "string",
"pattern": "^[+-]?[0-9]+$",
},
},
"additionalProperties": False,
"required": [
"type",
"name",
"dir",
"width",
"signed",
"reset",
],
},
{
"type": "object",
"properties": {
"type": {
"enum": ["interface"],
},
"members": {
"$ref": "#/properties/interface/properties/members",
},
"annotations": {
"type": "object",
},
},
"additionalProperties": False,
"required": [
"type",
"members",
"annotations",
],
},
],
},
},
"additionalProperties": False,
},
"annotations": {
"type": "object",
},
},
"additionalProperties": False,
"required": [
"members",
"annotations",
],
},
},
"additionalProperties": False,
"required": [
"interface",
]
}

"""Component metadata.
A description of the interface and annotations of a :class:`Component`, which can be exported
as a JSON object.
Parameters
----------
origin : :class:`Component`
The component described by this metadata instance.
Raises
------
:exc:`TypeError`
If ``origin`` is not a :class:`Component`.
"""
def __init__(self, origin):
if not isinstance(origin, Component):
raise TypeError(f"Origin must be a Component object, not {origin!r}")
self._origin = origin

@property
def origin(self):
return self._origin

def as_json(self):
"""Translate to JSON.
Returns
-------
:class:`Mapping`
A JSON representation of :attr:`ComponentMetadata.origin`, with a hierarchical
description of its interface ports and annotations.
"""
def describe_member(member, *, path):
assert isinstance(member, Member)
if member.is_port:
cast_shape = Shape.cast(member.shape)
return {
"type": "port",
"name": "__".join(path),
"dir": "in" if member.flow == In else "out",
"width": cast_shape.width,
"signed": cast_shape.signed,
"reset": str(member._reset_as_const.value),
}
elif member.is_signature:
return {
"type": "interface",
"members": {
name: describe_member(sub_member, path=(*path, name))
for name, sub_member in member.signature.members.items()
},
"annotations": {
annotation.name: annotation.as_json()
for annotation in member.signature.annotations
},
}
else:
assert False # :nocov:

instance = {
"interface": {
"members": {
name: describe_member(member, path=(name,))
for name, member in self.origin.signature.members.items()
},
"annotations": {
annotation.name: annotation.as_json()
for annotation in self.origin.signature.annotations
},
},
}
self.validate(instance)
return instance
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"importlib_resources; python_version<'3.9'", # for amaranth._toolchain.yosys
"pyvcd>=0.2.2,<0.5", # for amaranth.sim.pysim
"Jinja2~=3.0", # for amaranth.build
"jsonschema~=4.20.0", # for amaranth.lib.meta
]

[project.optional-dependencies]
Expand Down
Loading

0 comments on commit 23bb98c

Please sign in to comment.