Repository: amaranth-lang/amaranth
Pull request #978 (2023-12-01 16:04:12)

Implement RFC 30: Component metadata.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/978?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang) Report
Attention: `1 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`bf8faea`)](https://app.codecov.io/gh/amaranth-lang/amaranth/commit/bf8faea51e92364564562cd02bf6e9743289e679?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang) 86.60% compared to head [(`f64c7c3`)](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/978?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang) 86.71%.

| [Files](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/978?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang) | Patch % | Lines |
|---|---|---|
| [amaranth/lib/wiring.py](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/978?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang#diff-YW1hcmFudGgvbGliL3dpcmluZy5weQ==) | 96.96% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/978?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #978      +/-   ##
==========================================
+ Coverage   86.60%   86.71%   +0.10%     
==========================================
  Files          41       42       +1     
  Lines        7091     7156      +65     
  Branches     1688     1708      +20     
==========================================
+ Hits         6141     6205      +64     
+ Misses        788      787       -1     
- Partials      162      164       +2     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/978?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang).

jfng: Update to track [changes](https://github.com/amaranth-lang/rfcs/pull/30#issuecomment-1893644341) to the RFC.
jfng: Rebased on current main.

Updated `Signature.annotations(self, obj)` to `Signature.annotations(self, obj, /)`; This prevents `obj` from being set as a kwarg, which allows subclasses to rename it (e.g. to `interface`).
jfng: > reference

Agreed.
whitequark: I've added a task list to the tracking issue: https://github.com/amaranth-lang/amaranth/issues/1047#issue-2094460017
jfng: Updated with documentation and changelog entry.

The CI link check will fail until the `ComponentMetadata` schema is published to https://amaranth-lang.org/schema/amaranth/0.5/component.json.

Patch:
diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml
index 2f9994e..d3a361b 100644
--- a/.github/workflows/main.yaml
+++ b/.github/workflows/main.yaml
@@ -126,6 +126,14 @@ jobs:
         with:
           name: docs
           path: docs/_build
+      - name: Extract schemas
+        run: |
+          pdm run extract-schemas
+      - name: Upload schema archive
+        uses: actions/upload-artifact@v4
+        with:
+          name: schema
+          path: schema
 
   check-links:
     runs-on: ubuntu-latest
@@ -154,6 +162,30 @@ jobs:
     steps:
       - run: ${{ contains(needs.*.result, 'failure') && 'false' || 'true' }}
 
+  publish-schemas:
+    needs: document
+    if: ${{ github.repository == 'amaranth-lang/amaranth' }}
+    runs-on: ubuntu-latest
+    steps:
+      - name: Check out source code
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+      - name: Download schema archive
+        uses: actions/download-artifact@v4
+        with:
+          name: schema
+          path: schema/
+      - name: Publish development schemas
+        if: ${{ github.event_name == 'push' && github.event.ref == 'refs/heads/main' }}
+        uses: JamesIves/github-pages-deploy-action@releases/v4
+        with:
+          repository-name: amaranth-lang/amaranth-lang.github.io
+          ssh-key: ${{ secrets.PAGES_DEPLOY_KEY }}
+          branch: main
+          folder: schema/
+          target-folder: schema/amaranth/
+
   publish-docs:
     needs: document
     if: ${{ github.repository == 'amaranth-lang/amaranth' }}
diff --git a/.gitignore b/.gitignore
index 6e98694..155abf5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,9 @@ __pycache__/
 /.venv
 /pdm.lock
 
+# metadata schemas
+/schema
+
 # coverage
 /.coverage
 /htmlcov
diff --git a/amaranth/lib/meta.py b/amaranth/lib/meta.py
new file mode 100644
index 0000000..84c6afa
--- /dev/null
+++ b/amaranth/lib/meta.py
@@ -0,0 +1,146 @@
+import jschon
+import pprint
+import warnings
+from abc import abstractmethod, ABCMeta
+
+
+__all__ = ["InvalidSchema", "InvalidAnnotation", "Annotation"]
+
+
+class InvalidSchema(Exception):
+    """Exception raised when a subclass of :class:`Annotation` is defined with a non-conformant
+    :data:`~Annotation.schema`."""
+
+
+class InvalidAnnotation(Exception):
+    """Exception raised by :meth:`Annotation.validate` when the JSON representation of
+    an annotation does not conform to its schema."""
+
+
+class Annotation(metaclass=ABCMeta):
+    """Interface annotation.
+
+    Annotations are containers for metadata that can be retrieved from an interface object using
+    the :meth:`Signature.annotations <.wiring.Signature.annotations>` method.
+
+    Annotations have a JSON representation whose structure is defined by the `JSON Schema`_
+    language.
+    """
+
+    #: :class:`dict`: Schema of this annotation, expressed in the `JSON Schema`_ language.
+    #:
+    #: Subclasses of :class:`Annotation` must define this class attribute.
+    schema = {}
+
+    @classmethod
+    def __jschon_schema(cls):
+        catalog = jschon.create_catalog("2020-12")
+        return jschon.JSONSchema(cls.schema, catalog=catalog)
+
+    def __init_subclass__(cls, **kwargs):
+        """
+        Defining a subclass of :class:`Annotation` causes its :data:`schema` to be validated.
+
+        Raises
+        ------
+        :exc:`InvalidSchema`
+            If :data:`schema` doesn't conform to the `2020-12` draft of `JSON Schema`_.
+        :exc:`InvalidSchema`
+            If :data:`schema` doesn't have a  `"$id" keyword`_ at its root. This requirement is
+            specific to :class:`Annotation` schemas.
+        """
+        super().__init_subclass__(**kwargs)
+
+        if not isinstance(cls.schema, dict):
+            raise TypeError(f"Annotation schema must be a dict, not {cls.schema!r}")
+
+        if "$id" not in cls.schema:
+            raise InvalidSchema(f"'$id' keyword is missing from Annotation schema: {cls.schema}")
+
+        try:
+            # TODO: Remove this. Ignore a deprecation warning from jschon's rfc3986 dependency.
+            with warnings.catch_warnings():
+                warnings.filterwarnings("ignore", category=DeprecationWarning)
+                result = cls.__jschon_schema().validate()
+        except jschon.JSONSchemaError as e:
+            raise InvalidSchema(e) from e
+
+        if not result.valid:
+            raise InvalidSchema("Invalid Annotation schema:\n" +
+                                pprint.pformat(result.output("basic")["errors"],
+                                               sort_dicts=False))
+
+    @property
+    @abstractmethod
+    def origin(self):
+        """Python object described by this :class:`Annotation` instance.
+
+        Subclasses of :class:`Annotation` must implement this property.
+        """
+        pass # :nocov:
+
+    @abstractmethod
+    def as_json(self):
+        """Convert to a JSON representation.
+
+        Subclasses of :class:`Annotation` must implement this method.
+
+        JSON representation returned by this method must adhere to :data:`schema` and pass
+        validation by :meth:`validate`.
+
+        Returns
+        -------
+        :class:`dict`
+            JSON representation of this annotation, expressed in Python primitive types
+            (:class:`dict`, :class:`list`, :class:`str`, :class:`int`, :class:`bool`).
+        """
+        pass # :nocov:
+
+    @classmethod
+    def validate(cls, instance):
+        """Validate a JSON representation against :attr:`schema`.
+
+        Arguments
+        ---------
+        instance : :class:`dict`
+            JSON representation to validate, either previously returned by :meth:`as_json`
+            or retrieved from an external source.
+
+        Raises
+        ------
+        :exc:`InvalidAnnotation`
+            If :py:`instance` doesn't conform to :attr:`schema`.
+        """
+        # TODO: Remove this. Ignore a deprecation warning from jschon's rfc3986 dependency.
+        with warnings.catch_warnings():
+            warnings.filterwarnings("ignore", category=DeprecationWarning)
+            result = cls.__jschon_schema().evaluate(jschon.JSON(instance))
+
+        if not result.valid:
+            raise InvalidAnnotation("Invalid instance:\n" +
+                                    pprint.pformat(result.output("basic")["errors"],
+                                                   sort_dicts=False))
+
+    def __repr__(self):
+        return f"<{type(self).__module__}.{type(self).__qualname__} for {self.origin!r}>"
+
+
+# For internal use only; we may consider exporting this function in the future.
+def _extract_schemas(package, *, base_uri, path="schema/"):
+    import sys
+    import json
+    import pathlib
+    from importlib.metadata import distribution
+
+    entry_points = distribution(package).entry_points
+    for entry_point in entry_points.select(group="amaranth.lib.meta"):
+        schema = entry_point.load().schema
+        relative_path = entry_point.name # v0.5/component.json
+        schema_filename = pathlib.Path(path) / relative_path
+        assert schema["$id"] == f"{base_uri}/{relative_path}", \
+            f"Schema $id {schema['$id']} must be {base_uri}/{relative_path}"
+
+        schema_filename.parent.mkdir(parents=True, exist_ok=True)
+        with open(pathlib.Path(path) / relative_path, "wt") as schema_file:
+            json.dump(schema, schema_file, indent=2)
+        print(f"Extracted {schema['$id']} to {schema_filename}")
diff --git a/amaranth/lib/wiring.py b/amaranth/lib/wiring.py
index 4352b33..1bf963e 100644
--- a/amaranth/lib/wiring.py
+++ b/amaranth/lib/wiring.py
@@ -7,6 +7,7 @@ from .. import tracer
 from ..hdl._ast import Shape, ShapeCastable, Const, Signal, Value, ValueCastable
 from ..hdl._ir import Elaboratable
 from .._utils import final
+from .meta import Annotation, InvalidAnnotation
 
 
 __all__ = ["In", "Out", "Signature", "PureInterface", "connect", "flipped", "Component"]
@@ -682,7 +683,7 @@ class Signature(metaclass=SignatureMeta):
     An interface object is a Python object that has a :py:`signature` attribute containing
     a :class:`Signature` object, as well as an attribute for every member of its signature.
     Signatures and interface objects are tightly linked: an interface object can be created out
-    of a signature, and the signature is used when :func:`connect` ing two interface objects
+    of a signature, and the signature is used when :func:`connect`\\ ing two interface objects
     together. See the :ref:`introduction to interfaces <wiring-intro1>` for a more detailed
     explanation of why this is useful.
 
@@ -984,6 +985,21 @@ class Signature(metaclass=SignatureMeta):
         """
         return PureInterface(self, path=path, src_loc_at=1 + src_loc_at)
 
+    def annotations(self, obj, /):
+        """Annotate an interface object.
+
+        Subclasses of :class:`Signature` may override this method to provide annotations for
+        a corresponding interface object. The default implementation provides none.
+
+        See :mod:`amaranth.lib.meta` for details.
+
+        Returns
+        -------
+        iterable of :class:`~.meta.Annotation`
+            :py:`tuple()`
+        """
+        return tuple()
+
     def __repr__(self):
         if type(self) is Signature:
             return f"Signature({dict(self.members.items())})"
@@ -1244,7 +1260,7 @@ class FlippedInterface:
 
         Returns
         -------
-        Signature
+        :class:`Signature`
             :py:`unflipped.signature.flip()`
         """
         return self.__unflipped.signature.flip()
@@ -1254,7 +1270,7 @@ class FlippedInterface:
 
         Returns
         -------
-        bool
+        :class:`bool`
             :py:`True` if :py:`other` is an instance :py:`FlippedInterface(other_unflipped)` where
             :py:`unflipped == other_unflipped`, :py:`False` otherwise.
         """
@@ -1676,7 +1692,7 @@ class Component(Elaboratable):
 
     @property
     def signature(self):
-        """The signature of the component.
+        """Signature of the component.
 
         .. warning::
 
@@ -1685,3 +1701,244 @@ class Component(Elaboratable):
             can be used to customize a component's signature.
         """
         return self.__signature
+
+    @property
+    def metadata(self):
+        """Metadata attached to the component.
+
+        Returns
+        -------
+        :class:`ComponentMetadata`
+        """
+        return ComponentMetadata(self)
+
+
+class InvalidMetadata(Exception):
+    """Exception raised by :meth:`ComponentMetadata.validate` when the JSON representation of
+    a component's metadata does not conform to its schema."""
+
+
+class ComponentMetadata(Annotation):
+    """Component metadata.
+
+    Component :ref:`metadata <meta>` describes the interface of a :class:`Component` and can be
+    exported to JSON for interoperability with non-Amaranth tooling.
+
+    Arguments
+    ---------
+    origin : :class:`Component`
+        Component described by this metadata instance.
+    """
+
+    #: :class:`dict`: Schema of component metadata, expressed in the `JSON Schema`_ language.
+    #:
+    #: A copy of this schema can be retrieved `from amaranth-lang.org
+    #: <https://amaranth-lang.org/schema/amaranth/0.5/component.json>`_.
+    schema = {
+        "$schema": "https://json-schema.org/draft/2020-12/schema",
+        "$id": "https://amaranth-lang.org/schema/amaranth/0.5/component.json",
+        "$defs": {
+            "member": {
+                "oneOf": [
+                    { "$ref": "#/$defs/member-array" },
+                    { "$ref": "#/$defs/member-port" },
+                    { "$ref": "#/$defs/member-interface" },
+                ]
+            },
+            "member-array": {
+                "type": "array",
+                "items": {
+                    "$ref": "#/$defs/member",
+                },
+            },
+            "member-port": {
+                "type": "object",
+                "properties": {
+                    "type": { "const": "port" },
+                    "name": {
+                        "type": "string",
+                        "pattern": "^[A-Za-z][A-Za-z0-9_]*$",
+                    },
+                    "dir": { "enum": [ "in", "out" ] },
+                    "width": {
+                        "type": "integer",
+                        "minimum": 0,
+                    },
+                    "signed": { "type": "boolean" },
+                    "init": {
+                        "type": "string",
+                        "pattern": "^[+-]?[0-9]+$",
+                    },
+                },
+                "additionalProperties": False,
+                "required": [
+                    "type",
+                    "name",
+                    "dir",
+                    "width",
+                    "signed",
+                    "init",
+                ],
+            },
+            "member-interface": {
+                "type": "object",
+                "properties": {
+                    "type": { "const": "interface" },
+                    "members": {
+                        "type": "object",
+                        "patternProperties": {
+                            "^[A-Za-z][A-Za-z0-9_]*$": {
+                                "$ref": "#/$defs/member",
+                            },
+                        },
+                    },
+                    "annotations": {
+                        "type": "object",
+                        "additionalProperties": {
+                            "type": "object",
+                        },
+                    },
+                },
+                "additionalProperties": False,
+                "required": [
+                    "type",
+                    "members",
+                    "annotations",
+                ],
+            },
+        },
+        "type": "object",
+        "properties": {
+            "interface": {
+                "type": "object",
+                "properties": {
+                    "members": {
+                        "type": "object",
+                        "patternProperties": {
+                            "^[A-Za-z][A-Za-z0-9_]*$": {
+                                "$ref": "#/$defs/member",
+                            },
+                        },
+                    },
+                    "annotations": {
+                        "type": "object",
+                        "additionalProperties": {
+                            "type": "object",
+                        },
+                    },
+                },
+                "additionalProperties": False,
+                "required": [
+                    "members",
+                    "annotations",
+                ],
+            },
+        },
+        "additionalProperties": False,
+        "required": [
+            "interface",
+        ],
+    }
+
+    def __init__(self, origin):
+        if not isinstance(origin, Component):
+            raise TypeError(f"Origin must be a component, not {origin!r}")
+        self._origin = origin
+
+    @property
+    def origin(self):
+        """Component described by this metadata.
+
+        Returns
+        -------
+        :class:`Component`
+        """
+        return self._origin
+
+    @classmethod
+    def validate(cls, instance):
+        """Validate a JSON representation of component metadata against :attr:`schema`.
+
+        This method does not validate annotations of the interface members, and consequently does
+        not make network requests.
+
+        Arguments
+        ---------
+        instance : :class:`dict`
+            JSON representation to validate, either previously returned by :meth:`as_json` or
+            retrieved from an external source.
+
+        Raises
+        ------
+        :exc:`InvalidMetadata`
+            If :py:`instance` doesn't conform to :attr:`schema`.
+        """
+        try:
+            super(cls, cls).validate(instance)
+        except InvalidAnnotation as e:
+            raise InvalidMetadata(e) from e
+
+    def as_json(self):
+        """Translate to JSON.
+
+        Returns
+        -------
+        :class:`dict`
+            JSON representation of :attr:`origin` that describes its interface members and includes
+            their annotations.
+        """
+        def translate_member(member, origin, *, path):
+            assert isinstance(member, Member)
+            if member.is_port:
+                cast_shape = Shape.cast(member.shape)
+                return {
+                    "type": "port",
+                    "name": "__".join(str(key) for key in path),
+                    "dir": "in" if member.flow == In else "out",
+                    "width": cast_shape.width,
+                    "signed": cast_shape.signed,
+                    "init": str(member._init_as_const.value),
+                }
+            elif member.is_signature:
+                return {
+                    "type": "interface",
+                    "members": {
+                        sub_name: translate_dimensions(sub.dimensions, sub,
+                                                       getattr(origin, sub_name),
+                                                       path=(*path, sub_name))
+                        for sub_name, sub in member.signature.members.items()
+                    },
+                    "annotations": {
+                        annotation.schema["$id"]: annotation.as_json()
+                        for annotation in member.signature.annotations(origin)
+                    },
+                }
+            else:
+                assert False # :nocov:
+
+        def translate_dimensions(dimensions, member, origin, *, path):
+            if len(dimensions) == 0:
+                return translate_member(member, origin, path=path)
+            dimension, *rest_of_dimensions = dimensions
+            return [
+                translate_dimensions(rest_of_dimensions, member, origin[index],
+                                     path=(*path, index))
+                for index in range(dimension)
+            ]
+
+        instance = {
+            "interface": {
+                "members": {
+                    member_name: translate_dimensions(member.dimensions, member,
+                                                      getattr(self.origin, member_name),
+                                                      path=(member_name,))
+                    for member_name, member in self.origin.signature.members.items()
+                },
+                "annotations": {
+                    annotation.schema["$id"]: annotation.as_json()
+                    for annotation in self.origin.signature.annotations(self.origin)
+                },
+            },
+        }
+        self.validate(instance)
+        return instance
diff --git a/docs/changes.rst b/docs/changes.rst
index 9d25389..e2e7ce0 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -49,6 +49,7 @@ Implemented RFCs
 
 .. _RFC 17: https://amaranth-lang.org/rfcs/0017-remove-log2-int.html
 .. _RFC 27: https://amaranth-lang.org/rfcs/0027-simulator-testbenches.html
+.. _RFC 30: https://amaranth-lang.org/rfcs/0030-component-metadata.html
 .. _RFC 39: https://amaranth-lang.org/rfcs/0039-empty-case.html
 .. _RFC 43: https://amaranth-lang.org/rfcs/0043-rename-reset-to-init.html
 .. _RFC 45: https://amaranth-lang.org/rfcs/0045-lib-memory.html
@@ -65,6 +66,7 @@ Implemented RFCs
 
 * `RFC 17`_: Remove ``log2_int``
 * `RFC 27`_: Testbench processes for the simulator
+* `RFC 30`_: Component metadata
 * `RFC 39`_: Change semantics of no-argument ``m.Case()``
 * `RFC 43`_: Rename ``reset=`` to ``init=``
 * `RFC 45`_: Move ``hdl.Memory`` to ``lib.Memory``
@@ -120,6 +122,7 @@ Standard library changes
 * Changed: :meth:`amaranth.lib.wiring.Signature.is_compliant` no longer rejects reset-less signals.
 * Added: :class:`amaranth.lib.io.SingleEndedPort`, :class:`amaranth.lib.io.DifferentialPort`. (`RFC 55`_)
 * Added: :class:`amaranth.lib.io.Buffer`, :class:`amaranth.lib.io.FFBuffer`, :class:`amaranth.lib.io.DDRBuffer`. (`RFC 55`_)
+* Added: :mod:`amaranth.lib.meta`, :class:`amaranth.lib.wiring.ComponentMetadata`. (`RFC 30`_)
 * Deprecated: :mod:`amaranth.lib.coding`. (`RFC 63`_)
 * Removed: (deprecated in 0.4) :mod:`amaranth.lib.scheduler`. (`RFC 19`_)
 * Removed: (deprecated in 0.4) :class:`amaranth.lib.fifo.FIFOInterface` with ``fwft=False``. (`RFC 20`_)
diff --git a/docs/conf.py b/docs/conf.py
index 3dbf9f9..c36797a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -2,10 +2,11 @@ import os, sys
 sys.path.insert(0, os.path.abspath("."))
 
 import time
-import amaranth
+from importlib.metadata import version as package_version
+
 
 project = "Amaranth language & toolchain"
-version = amaranth.__version__.replace(".editable", "")
+version = package_version('amaranth').replace(".editable", "")
 release = version.split("+")[0]
 copyright = time.strftime("2020—%Y, Amaranth project contributors")
 
@@ -25,7 +26,9 @@ with open(".gitignore") as f:
 
 root_doc = "cover"
 
-intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
+intersphinx_mapping = {
+    "python": ("https://docs.python.org/3", None),
+}
 
 todo_include_todos = True
 
diff --git a/docs/stdlib.rst b/docs/stdlib.rst
index 95a12aa..add7c51 100644
--- a/docs/stdlib.rst
+++ b/docs/stdlib.rst
@@ -3,7 +3,7 @@ Standard library
 
 The :mod:`amaranth.lib` module, also known as the standard library, provides modules that falls into one of the three categories:
 
-1. Modules that will used by essentially all idiomatic Amaranth code, and are necessary for interoperability. This includes :mod:`amaranth.lib.enum` (enumerations), :mod:`amaranth.lib.data` (data structures), and :mod:`amaranth.lib.wiring` (interfaces and components).
+1. Modules that will used by essentially all idiomatic Amaranth code, or which are necessary for interoperability. This includes :mod:`amaranth.lib.enum` (enumerations), :mod:`amaranth.lib.data` (data structures), :mod:`amaranth.lib.wiring` (interfaces and components), and :mod:`amaranth.lib.meta` (interface metadata).
 2. Modules that abstract common functionality whose implementation differs between hardware platforms. This includes :mod:`amaranth.lib.cdc`, :mod:`amaranth.lib.memory`.
 3. Modules that have essentially one correct implementation and are of broad utility in digital designs. This includes :mod:`amaranth.lib.coding`, :mod:`amaranth.lib.fifo`, and :mod:`amaranth.lib.crc`.
 
@@ -17,6 +17,7 @@ The Amaranth standard library is separate from the Amaranth language: everything
    stdlib/enum
    stdlib/data
    stdlib/wiring
+   stdlib/meta
    stdlib/memory
    stdlib/cdc
    stdlib/coding
diff --git a/docs/stdlib/meta.rst b/docs/stdlib/meta.rst
new file mode 100644
index 0000000..de9b14a
--- /dev/null
+++ b/docs/stdlib/meta.rst
@@ -0,0 +1,285 @@
+.. _meta:
+
+Interface metadata
+##################
+
+.. py:module:: amaranth.lib.meta
+
+The :mod:`amaranth.lib.meta` module provides a way to annotate objects in an Amaranth design and exchange these annotations with external tools in a standardized format.
+
+.. _JSON Schema: https://json-schema.org
+
+.. _"$id" keyword: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01#name-the-id-keyword
+
+.. testsetup::
+
+    from amaranth import *
+    from amaranth.lib import wiring, meta
+    from amaranth.lib.wiring import In, Out
+
+
+Introduction
+------------
+
+Many Amaranth designs stay entirely within the Amaranth ecosystem, using the facilities it provides to define, test, and build hardware. In this case, the design is available for exploration using Python code, and metadata is not necessary. However, if an Amaranth design needs to fit into an existing ecosystem, or, conversely, to integrate components developed for another ecosystem, metadata can be used to exchange structured information about the design.
+
+Consider a simple :ref:`component <wiring>`:
+
+.. testcode::
+
+    class Adder(wiring.Component):
+        a: In(unsigned(32))
+        b: In(unsigned(32))
+        o: Out(unsigned(33))
+
+        def elaborate(self, platform):
+            m = Module()
+            m.d.comb += self.o.eq(self.a + self.b)
+            return m
+
+..
+    TODO: link to Verilog backend doc when we have it
+
+While it can be easily converted to Verilog, external tools will find the interface of the resulting module opaque unless they parse its Verilog source (a difficult and unrewarding task), or are provided with a description of it. Components can describe their signature with JSON-based metadata:
+
+.. doctest::
+
+    >>> adder = Adder()
+    >>> adder.metadata # doctest: +ELLIPSIS
+    <amaranth.lib.wiring.ComponentMetadata for ...Adder object at ...>
+    >>> adder.metadata.as_json() # doctest: +SKIP
+    {
+        'interface': {
+            'members': {
+                'a': {
+                    'type': 'port',
+                    'name': 'a',
+                    'dir': 'in',
+                    'width': 32,
+                    'signed': False,
+                    'init': '0'
+                },
+                'b': {
+                    'type': 'port',
+                    'name': 'b',
+                    'dir': 'in',
+                    'width': 32,
+                    'signed': False,
+                    'init': '0'
+                },
+                'o': {
+                    'type': 'port',
+                    'name': 'o',
+                    'dir': 'out',
+                    'width': 33,
+                    'signed': False,
+                    'init': '0'
+                }
+            },
+            'annotations': {}
+        }
+    }
+
+.. testcode::
+    :hide:
+
+    # The way doctest requires this object to be formatted is truly hideous, even with +NORMALIZE_WHITESPACE.
+    assert adder.metadata.as_json() == {'interface': {'members': {'a': {'type': 'port', 'name': 'a', 'dir': 'in', 'width': 32, 'signed': False, 'init': '0'}, 'b': {'type': 'port', 'name': 'b', 'dir': 'in', 'width': 32, 'signed': False, 'init': '0'}, 'o': {'type': 'port', 'name': 'o', 'dir': 'out', 'width': 33, 'signed': False, 'init': '0'}}, 'annotations': {}}}
+
+
+All metadata in Amaranth must adhere to a schema in the `JSON Schema`_ language, which is integral to its definition, and can be used to validate the generated JSON:
+
+.. doctest::
+
+    >>> wiring.ComponentMetadata.validate(adder.metadata.as_json())
+
+The built-in component metadata can be extended to provide arbitrary information about an interface through user-defined annotations. For example, a memory bus interface could provide the layout of any memory-mapped peripherals accessible through that bus.
+
+
+Defining annotations
+--------------------
+
+Consider a simple control and status register (CSR) bus that provides the memory layout of the accessible registers via an annotation:
+
+.. testcode::
+
+    class CSRLayoutAnnotation(meta.Annotation):
+        schema = {
+            "$schema": "https://json-schema.org/draft/2020-12/schema",
+            "$id": "https://amaranth-lang.org/schema/example/0/csr-layout.json",
+            "type": "object",
+            "properties": {
+                "registers": {
+                    "type": "object",
+                    "patternProperties": {
+                        "^.+$": {
+                            "type": "integer",
+                            "minimum": 0,
+                        },
+                    },
+                },
+            },
+            "requiredProperties": [
+                "registers",
+            ],
+        }
+
+        def __init__(self, origin):
+            self._origin = origin
+
+        @property
+        def origin(self):
+            return self._origin
+
+        def as_json(self):
+            instance = {
+                "registers": self.origin.registers,
+            }
+            # Validating the value returned by `as_json()` ensures its conformance.
+            self.validate(instance)
+            return instance
+
+
+    class CSRSignature(wiring.Signature):
+        def __init__(self):
+            super().__init__({
+                "addr":     Out(16),
+                "w_en":     Out(1),
+                "w_data":   Out(32),
+                "r_en":     Out(1),
+                "r_data":   In(32),
+            })
+
+        def annotations(self, obj, /):
+            # Unfortunately `super()` cannot be used in `wiring.Signature` subclasses;
+            # instead, use a direct call to a superclass method. In this case that is
+            # `wiring.Signature` itself, but in a more complex class hierarchy it could
+            # be different.
+            return wiring.Signature.annotations(self, obj) + (CSRLayoutAnnotation(obj),)
+
+A component that embeds a few CSR registers would define their addresses:
+
+.. testcode::
+
+    class MyPeripheral(wiring.Component):
+        csr_bus: In(CSRSignature())
+
+        def __init__(self):
+            super().__init__()
+            self.csr_bus.registers = {
+                "control": 0x0000,
+                "status":  0x0004,
+                "data":    0x0008,
+            }
+
+.. doctest::
+
+    >>> peripheral = MyPeripheral()
+    >>> peripheral.metadata.as_json() # doctest: +SKIP
+    {
+        'interface': {
+            'members': {
+                'csr_bus': {
+                    'type': 'interface',
+                    'members': {
+                        'addr': {
+                            'type': 'port',
+                            'name': 'csr_bus__addr',
+                            'dir': 'in',
+                            'width': 16,
+                            'signed': False,
+                            'init': '0'
+                        },
+                        'w_en': {
+                            'type': 'port',
+                            'name': 'csr_bus__w_en',
+                            'dir': 'in',
+                            'width': 1,
+                            'signed': False,
+                            'init': '0'
+                        },
+                        'w_data': {
+                            'type': 'port',
+                            'name': 'csr_bus__w_data',
+                            'dir': 'in',
+                            'width': 32,
+                            'signed': False,
+                            'init': '0'
+                        },
+                        'r_en': {
+                            'type': 'port',
+                            'name': 'csr_bus__r_en',
+                            'dir': 'in',
+                            'width': 1,
+                            'signed': False,
+                            'init': '0'
+                        },
+                        'r_data': {
+                            'type': 'port',
+                            'name': 'csr_bus__r_data',
+                            'dir': 'out',
+                            'width': 32,
+                            'signed': False,
+                            'init': '0'
+                        },
+                    },
+                    'annotations': {
+                        'https://amaranth-lang.org/schema/example/0/csr-layout.json': {
+                            'registers': {
+                                'control': 0,
+                                'status':  4,
+                                'data':    8
+                            }
+                        }
+                    }
+                }
+            },
+            'annotations': {}
+        }
+    }
+
+.. testcode::
+    :hide:
+
+    # The way doctest requires this object to be formatted is truly hideous, even with +NORMALIZE_WHITESPACE.
+    assert peripheral.metadata.as_json() == {'interface': {'members': {'csr_bus': {'type': 'interface', 'members': {'addr': {'type': 'port', 'name': 'csr_bus__addr', 'dir': 'in', 'width': 16, 'signed': False, 'init': '0'}, 'w_en': {'type': 'port', 'name': 'csr_bus__w_en', 'dir': 'in', 'width': 1, 'signed': False, 'init': '0'}, 'w_data': {'type': 'port', 'name': 'csr_bus__w_data', 'dir': 'in', 'width': 32, 'signed': False, 'init': '0'}, 'r_en': {'type': 'port', 'name': 'csr_bus__r_en', 'dir': 'in', 'width': 1, 'signed': False, 'init': '0'}, 'r_data': {'type': 'port', 'name': 'csr_bus__r_data', 'dir': 'out', 'width': 32, 'signed': False, 'init': '0'}}, 'annotations': {'https://amaranth-lang.org/schema/example/0/csr-layout.json': {'registers': {'control': 0, 'status': 4, 'data': 8}}}}}, 'annotations': {}}}
+
+
+Identifying schemas
+-------------------
+
+An :class:`Annotation` schema must have a ``"$id"`` property, whose value is a URL that serves as its globally unique identifier. The suggested format of this URL is:
+
+.. code::
+
+    <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 :class:`Annotation` subclass;
+    * ``<version>`` is the version of that package;
+    * ``<path>`` is a non-empty string specific to the annotation.
+
+.. note::
+
+    Annotations used in the Amaranth project packages are published under https://amaranth-lang.org/schema/ according to this URL format, and are covered by the usual compatibility commitment.
+
+    Other projects that define additional Amaranth annotations are encouraged, but not required, to make their schemas publicly accessible; the only requirement is for the URL to be globally unique.
+
+
+Reference
+---------
+
+.. autoexception:: InvalidSchema
+
+.. autoexception:: InvalidAnnotation
+
+.. autoclass:: Annotation
+    :no-members:
+    :members: validate, origin, as_json
+
+    .. automethod:: __init_subclass__()
+
+    .. autoattribute:: schema
+        :annotation: = { "$id": "...", ... }
diff --git a/docs/stdlib/wiring.rst b/docs/stdlib/wiring.rst
index 8978e56..5434563 100644
--- a/docs/stdlib/wiring.rst
+++ b/docs/stdlib/wiring.rst
@@ -599,4 +599,19 @@ Making connections
 Components
 ==========
 
+.. _JSON Schema: https://json-schema.org
+
 .. autoclass:: Component
+
+
+Component metadata
+==================
+
+.. autoexception:: InvalidMetadata
+
+.. autoclass:: ComponentMetadata
+   :no-members:
+   :members: validate, origin, as_json
+
+   .. autoattribute:: schema
+      :annotation: = { "$id": "https://amaranth-lang.org/schema/amaranth/0.5/component.json", ... }
diff --git a/pyproject.toml b/pyproject.toml
index 2f861e4..dcfdec6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,6 +16,7 @@ license = {file = "LICENSE.txt"}
 requires-python = "~=3.8"
 dependencies = [
   "importlib_resources; python_version<'3.9'", # for amaranth._toolchain.yosys
+  "jschon~=0.11.1", # for amaranth.lib.meta
   "pyvcd>=0.2.2,<0.5", # for amaranth.sim.pysim
   "Jinja2~=3.0", # for amaranth.build
 ]
@@ -31,6 +32,9 @@ remote-build  = ["paramiko~=2.7"]
 [project.scripts]
 amaranth-rpc = "amaranth.rpc:main"
 
+[project.entry-points."amaranth.lib.meta"]
+"0.5/component.json" = "amaranth.lib.wiring:ComponentMetadata"
+
 [project.urls]
 "Homepage" = "https://amaranth-lang.org/"
 "Documentation" = "https://amaranth-lang.org/docs/amaranth/" # modified in pdm_build.py
@@ -95,3 +99,5 @@ document-linkcheck.cmd = "sphinx-build docs/ docs/_linkcheck/ -b linkcheck"
 coverage-text.cmd = "python -m coverage report"
 coverage-html.cmd = "python -m coverage html"
 coverage-xml.cmd  = "python -m coverage xml"
+
+extract-schemas.call = "amaranth.lib.meta:_extract_schemas('amaranth', base_uri='https://amaranth-lang.org/schema/amaranth')"


Test patch:
diff --git a/tests/test_lib_meta.py b/tests/test_lib_meta.py
new file mode 100644
index 0000000..a4f20ee
--- /dev/null
+++ b/tests/test_lib_meta.py
@@ -0,0 +1,87 @@
+import unittest
+
+from amaranth import *
+from amaranth.lib.meta import *
+
+
+class AnnotationTestCase(unittest.TestCase):
+    def test_init_subclass(self):
+        class MyAnnotation(Annotation):
+            schema = {
+                "$schema": "https://json-schema.org/draft/2020-12/schema",
+                "$id": "https://example.com/schema/test/0.1/my-annotation.json",
+                "type": "string",
+            }
+
+            @property
+            def origin(self):
+                return "foo"
+
+            @property
+            def as_json(self):
+                return "foo"
+
+        self.assertRegex(repr(MyAnnotation()), r"<.+\.MyAnnotation for 'foo'>")
+
+    def test_init_subclass_wrong_schema(self):
+        with self.assertRaisesRegex(TypeError, r"Annotation schema must be a dict, not 'foo'"):
+            class MyAnnotation(Annotation):
+                schema = "foo"
+
+    def test_init_subclass_schema_missing_id(self):
+        with self.assertRaisesRegex(InvalidSchema, r"'\$id' keyword is missing from Annotation schema: {}"):
+            class MyAnnotation(Annotation):
+                schema = {}
+
+    def test_init_subclass_schema_missing_schema(self):
+        with self.assertRaises(InvalidSchema):
+            class MyAnnotation(Annotation):
+                schema = {
+                    "$id": "https://example.com/schema/test/0.1/my-annotation.json",
+                }
+
+    def test_init_subclass_schema_error(self):
+        with self.assertRaises(InvalidSchema):
+            class MyAnnotation(Annotation):
+                schema = {
+                    "$schema": "https://json-schema.org/draft/2020-12/schema",
+                    "$id": "https://example.com/schema/test/0.1/my-annotation.json",
+                    "type": "foo",
+                }
+
+    def test_validate(self):
+        class MyAnnotation(Annotation):
+            schema = {
+                "$schema": "https://json-schema.org/draft/2020-12/schema",
+                "$id": "https://example.com/schema/test/0.1/my-annotation.json",
+                "type": "object",
+                "properties": {
+                    "foo": {
+                        "enum": [ "bar" ],
+                    },
+                },
+                "additionalProperties": False,
+                "required": [
+                    "foo",
+                ],
+            }
+        MyAnnotation.validate({"foo": "bar"})
+
+    def test_validate_error(self):
+        class MyAnnotation(Annotation):
+            schema = {
+                "$schema": "https://json-schema.org/draft/2020-12/schema",
+                "$id": "https://example.com/schema/test/0.1/my-annotation.json",
+                "type": "object",
+                "properties": {
+                    "foo": {
+                        "enum": [ "bar" ],
+                    },
+                },
+                "additionalProperties": False,
+                "required": [
+                    "foo",
+                ],
+            }
+        with self.assertRaises(InvalidAnnotation):
+            MyAnnotation.validate({"foo": "baz"})
diff --git a/tests/test_lib_wiring.py b/tests/test_lib_wiring.py
index 4602f27..d4b7d8c 100644
--- a/tests/test_lib_wiring.py
+++ b/tests/test_lib_wiring.py
@@ -8,8 +8,9 @@ from amaranth.lib import data, enum
 from amaranth.lib.wiring import Flow, In, Out, Member
 from amaranth.lib.wiring import SignatureError, SignatureMembers, FlippedSignatureMembers
 from amaranth.lib.wiring import Signature, FlippedSignature, PureInterface, FlippedInterface
-from amaranth.lib.wiring import Component
+from amaranth.lib.wiring import Component, ComponentMetadata, InvalidMetadata
 from amaranth.lib.wiring import ConnectionError, connect, flipped
+from amaranth.lib.meta import Annotation
 
 
 class FlowTestCase(unittest.TestCase):
@@ -336,6 +337,11 @@ class SignatureTestCase(unittest.TestCase):
         sig = Signature({"a": In(1)})
         self.assertEqual(sig.members, SignatureMembers({"a": In(1)}))
 
+    def test_annotations_empty(self):
+        sig   = Signature({"a": In(1)})
+        iface = PureInterface(sig)
+        self.assertEqual(sig.annotations(iface), ())
+
     def test_eq(self):
         self.assertEqual(Signature({"a": In(1)}),
                          Signature({"a": In(1)}))
@@ -1156,3 +1162,174 @@ class ComponentTestCase(unittest.TestCase):
         with self.assertRaisesRegex(TypeError,
                 r"^Object 4 is not a signature nor a dict$"):
             C(2)
+
+    def test_metadata_origin(self):
+        class A(Component):
+            clk: In(1)
+
+        a = A()
+        self.assertIsInstance(a.metadata, ComponentMetadata)
+        self.assertIs(a.metadata.origin, a)
+
+
+class ComponentMetadataTestCase(unittest.TestCase):
+    def test_as_json(self):
+        class Annotation1(Annotation):
+            schema = {
+                "$schema": "https://json-schema.org/draft/2020-12/schema",
+                "$id": "https://example.com/schema/foo/0.1/bar.json",
+                "type": "object",
+                "properties": {
+                    "hello": { "type": "boolean" },
+                },
+            }
+
+            def origin(self):
+                return object()
+
+            def as_json(self):
+                instance = { "hello": True }
+                self.validate(instance)
+                return instance
+
+        class Signature1(Signature):
+            def __init__(self):
+                super().__init__({
+                    "i": In(unsigned(8), init=42).array(2).array(3),
+                    "o": Out(signed(4))
+                })
+
+            def annotations(self, obj):
+                return (*Signature.annotations(self, obj), Annotation1())
+
+        class Signature2(Signature):
+            def __init__(self):
+                super().__init__({
+                    "clk": In(1),
+                    "foo": Out(Signature1()).array(4),
+                    "oof": In(Signature1())
+                })
+
+            def annotations(self, obj):
+                return (*Signature.annotations(self, obj), Annotation1())
+
+        class A(Component):
+            def __init__(self):
+                super().__init__(Signature2())
+
+        metadata = ComponentMetadata(A())
+        self.assertEqual(metadata.as_json(), {
+            "interface": {
+                "members": {
+                    "clk": {
+                        "type": "port",
+                        "name": "clk",
+                        "dir": "in",
+                        "width": 1,
+                        "signed": False,
+                        "init": "0",
+                    },
+                    "foo": [{
+                        "type": "interface",
+                        "members": {
+                            "i": [[{
+                                    "type": "port",
+                                    "name": f"foo__{x}__i__{y}__{z}",
+                                    "dir": "in",
+                                    "width": 8,
+                                    "signed": False,
+                                    "init": "42",
+                                } for z in range(2)] for y in range(3)],
+                            "o": {
+                                "type": "port",
+                                "name": f"foo__{x}__o",
+                                "dir": "out",
+                                "width": 4,
+                                "signed": True,
+                                "init": "0",
+                            },
+                        },
+                        "annotations": {
+                            "https://example.com/schema/foo/0.1/bar.json": {
+                                "hello": True,
+                            },
+                        },
+                    } for x in range(4)],
+                    "oof": {
+                        "type": "interface",
+                        "members": {
+                            "i": [[{
+                                "type": "port",
+                                "name": f"oof__i__{y}__{z}",
+                                "dir": "out",
+                                "width": 8,
+                                "signed": False,
+                                "init": "42",
+                            } for z in range(2)] for y in range(3)],
+                            "o": {
+                                "type": "port",
+                                "name": "oof__o",
+                                "dir": "in",
+                                "width": 4,
+                                "signed": True,
+                                "init": "0",
+                            },
+                        },
+                        "annotations": {
+                            "https://example.com/schema/foo/0.1/bar.json": {
+                                "hello": True,
+                            },
+                        },
+                    },
+                },
+                "annotations": {
+                    "https://example.com/schema/foo/0.1/bar.json": {
+                        "hello": True,
+                    },
+                },
+            },
+        })
+
+    def test_validate(self):
+        ComponentMetadata.validate({
+            "interface": {
+                "members": {
+                    "i": {
+                        "type": "port",
+                        "name": "i",
+                        "dir": "in",
+                        "width": 1,
+                        "signed": False,
+                        "init": "0",
+                    },
+                    "o": {
+                        "type": "port",
+                        "name": "o",
+                        "dir": "out",
+                        "width": 1,
+                        "signed": False,
+                        "init": "0",
+                    },
+                },
+                "annotations": {
+                    "https://example.com/schema/foo/0/foo.json": {
+                        "foo": True,
+                    },
+                },
+            },
+        })
+
+    def test_validate_error(self):
+        with self.assertRaises(InvalidMetadata):
+            ComponentMetadata.validate({
+                "interface": {
+                    "members": {
+                        "foo": True,
+                    },
+                    "annotations": {},
+                },
+            })
+
+    def test_wrong_origin(self):
+        with self.assertRaisesRegex(TypeError, r"Origin must be a component, not 'foo'"):
+            ComponentMetadata("foo")