From 46b7a37dc8fcf9f48023b36c6ef47b7a54a47ccc Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Fri, 26 Jun 2026 14:15:41 +0100 Subject: [PATCH 1/4] Fix release builds --- .github/workflows/release.yml | 2 +- dimos/spec/utils.py | 108 ++++++++++++++++++++++++++++++---- docs/development/releasing.md | 2 +- pyproject.toml | 2 - uv.lock | 13 +--- 5 files changed, 101 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42f6e54e6b..6aae54a906 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,7 +102,6 @@ jobs: include: - { os: ubuntu-latest, arch: x86_64 } - { os: ubuntu-24.04-arm, arch: aarch64 } - - { os: macos-13, arch: x86_64 } - { os: macos-14, arch: arm64 } runs-on: ${{ matrix.os }} permissions: @@ -112,6 +111,7 @@ jobs: env: CIBW_ENABLE: "cpython-freethreading" CIBW_ARCHS: ${{ matrix.arch }} + CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_31 # open3d>=0.19 requires glibc 2.31+ CIBW_TEST_COMMAND: "python -c 'from dimos.navigation.replanning_a_star.min_cost_astar_ext import min_cost_astar_cpp'" MACOSX_DEPLOYMENT_TARGET: "11.0" steps: diff --git a/dimos/spec/utils.py b/dimos/spec/utils.py index 90a9cd69f2..d7b6d0ec9a 100644 --- a/dimos/spec/utils.py +++ b/dimos/spec/utils.py @@ -13,10 +13,14 @@ # limitations under the License. import inspect -from typing import Any, Protocol, runtime_checkable +import sys +from types import UnionType +from typing import Any, Protocol, Union, get_args, get_origin, runtime_checkable -from annotation_protocol import AnnotationProtocol -from typing_extensions import is_protocol +if sys.version_info >= (3, 13): + from typing import get_protocol_members, is_protocol +else: + from typing_extensions import get_protocol_members, is_protocol # Allows us to differentiate plain Protocols from Module-Spec Protocols @@ -89,14 +93,98 @@ def foo(self) -> int: ... if not is_spec(proto): raise TypeError("Not a Spec") - # Build a *strict* runtime protocol dynamically - strict_proto = type( - f"Strict{proto.__name__}", - (AnnotationProtocol,), - dict(proto.__dict__), + # Structural compliance (every member present) is a prerequisite. + if not isinstance(obj, runtime_checkable(proto)): + return False + + # On top of structure, every method the spec declares must match by signature + # (return + argument annotations). Data attributes are not signature-able, so + # only their presence -- already verified above -- is required. + obj_cls = obj if isinstance(obj, type) else type(obj) + for name in get_protocol_members(proto): + try: + spec_sig = inspect.signature(getattr(proto, name), eval_str=True) + except (AttributeError, TypeError, ValueError): + continue # data attribute, not a method -- only its presence matters + try: + impl_sig = inspect.signature(getattr(obj_cls, name), eval_str=True) + except (AttributeError, TypeError, ValueError): + return False # spec declares a method here but the impl has no callable + if not _signatures_compatible(spec_sig, impl_sig): + return False + return True + + +def _annotation_compatible(spec_ann: Any, impl_ann: Any) -> bool: + """Return True if an implementation's ``impl_ann`` satisfies the spec's ``spec_ann``. + + Missing (``empty``), ``Any`` and ``None`` spec annotations accept any implementation + type, and an ``Any`` implementation annotation satisfies any spec. A union spec + annotation is satisfied by any subset of its members. + """ + if spec_ann is inspect.Parameter.empty or spec_ann is Any or spec_ann is None: + return True + if impl_ann is Any: + return True + + spec_origin = get_origin(spec_ann) + if spec_origin is Union or spec_origin is UnionType: + spec_types = set(get_args(spec_ann)) + impl_origin = get_origin(impl_ann) + if impl_origin is Union or impl_origin is UnionType: + impl_types = set(get_args(impl_ann)) + else: + impl_types = {impl_ann} + return spec_types >= impl_types + + return bool(spec_ann == impl_ann) + + +def _signatures_compatible(spec_sig: inspect.Signature, impl_sig: inspect.Signature) -> bool: + """Return True if ``impl_sig`` satisfies ``spec_sig`` by return and argument annotations.""" + if not _annotation_compatible(spec_sig.return_annotation, impl_sig.return_annotation): + return False + + # Re-shape the implementation parameters into positional/keyword arguments and bind + # them against the spec signature, which validates arity and parameter names. + has_var_args = any( + p.kind is inspect.Parameter.VAR_POSITIONAL for p in spec_sig.parameters.values() ) - - return isinstance(obj, strict_proto) + args: list[inspect.Parameter] = [] + kwargs: dict[str, inspect.Parameter] = {} + for p in impl_sig.parameters.values(): + if p.kind is inspect.Parameter.POSITIONAL_ONLY: + args.append(p) + elif p.kind is inspect.Parameter.KEYWORD_ONLY: + kwargs[p.name] = p + elif p.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD: + if has_var_args: + args.append(p) + else: + kwargs[p.name] = p + + try: + bound = spec_sig.bind(*args, **kwargs) + except TypeError: + return False + + for spec_param in spec_sig.parameters.values(): + if spec_param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + impl_param = bound.arguments[spec_param.name] + if ( + spec_param.kind is not inspect.Parameter.POSITIONAL_ONLY + and spec_param.name != impl_param.name + ): + return False + if ( + spec_param.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + and impl_param.kind is inspect.Parameter.POSITIONAL_ONLY + ): + return False + if not _annotation_compatible(spec_param.annotation, impl_param.annotation): + return False + return True def get_protocol_method_signatures(proto: type[object]) -> dict[str, inspect.Signature]: diff --git a/docs/development/releasing.md b/docs/development/releasing.md index 521b6fbc13..7994de93d1 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -8,7 +8,7 @@ Throughout this document, replace `X.Y.Z` with the version you are releasing (e. 1. Check for an existing `release/*` branch on the remote (`git ls-remote --heads origin 'release/*'`, or the Branches page). If one is still around from a previous release, complete section 3 for that branch before continuing. 2. Bump the version on `main`. `uv version --bump patch` (or `minor` / `major`). Open a PR, squash-merge. -3. Create the temporary release branch from the version-bump commit: +3. Create the temporary release branch from the version-bump commit (need CI to complete on main before push will succeed): ```bash git fetch origin diff --git a/pyproject.toml b/pyproject.toml index a60761a753..6389cc5077 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,6 @@ dependencies = [ "pydantic", "python-dotenv", "typing_extensions>=4.0; python_version < '3.11'", - "annotation-protocol>=1.4.0", "plum-dispatch==2.5.7", # Logging "structlog>=25.5.0,<26", @@ -506,7 +505,6 @@ exclude = "^dimos/models/Detic(/|$)|.*/test_.|.*/conftest.py*" [[tool.mypy.overrides]] module = [ - "annotation_protocol", "a750_control", "a750_control.*", "cyclonedds", diff --git a/uv.lock b/uv.lock index 33a33fa51b..7606629a6a 100644 --- a/uv.lock +++ b/uv.lock @@ -40,7 +40,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-06-19T12:47:59.312614Z" exclude-newer-span = "P7D" [options.exclude-newer-package] @@ -332,15 +332,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] -[[package]] -name = "annotation-protocol" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/fd/612c96531b1c1d1c06e5d79547faea3f805785d67481b350f3f6a9cf6dc5/annotation_protocol-1.4.0.tar.gz", hash = "sha256:15d846a4984339bab6cbf80a44623219b8cb06b4f4fee0f22c31a255d16900f8", size = 8470, upload-time = "2026-01-19T08:48:27.051Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/8b/71a5e1392dd3aca7ffeef0c3b10ea9b0e62959b5f39889702a06e11eda96/annotation_protocol-1.4.0-py3-none-any.whl", hash = "sha256:6fc66f1506f015db16fdd50fad18520cbb126a7902b27257c9fa521eb5efec60", size = 7834, upload-time = "2026-01-19T08:48:25.848Z" }, -] - [[package]] name = "antlr4-python3-runtime" version = "4.9.3" @@ -1962,7 +1953,6 @@ name = "dimos" version = "0.0.13" source = { editable = "." } dependencies = [ - { name = "annotation-protocol" }, { name = "bleak" }, { name = "cryptography" }, { name = "dimos-lcm" }, @@ -2409,7 +2399,6 @@ tests-self-hosted = [ [package.metadata] requires-dist = [ { name = "a750-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'manipulation'" }, - { name = "annotation-protocol", specifier = ">=1.4.0" }, { name = "bleak", specifier = ">=3.0.2" }, { name = "chromadb", marker = "extra == 'perception'", specifier = ">=1.0.0" }, { name = "cryptography", specifier = ">=46.0.5" }, From a8700b0850127580a2c8d618873562d764c7656b Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Fri, 26 Jun 2026 14:33:26 +0100 Subject: [PATCH 2/4] Fix --- .github/workflows/release.yml | 3 ++- pyproject.toml | 2 +- uv.lock | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6aae54a906..b7fe112d1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,7 +111,8 @@ jobs: env: CIBW_ENABLE: "cpython-freethreading" CIBW_ARCHS: ${{ matrix.arch }} - CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_31 # open3d>=0.19 requires glibc 2.31+ + CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_34 # open3d>=0.19 requires glibc 2.31+ + CIBW_MANYLINUX_AARCH64_IMAGE: manylinux_2_34 # open3d>=0.19 requires glibc 2.31+ CIBW_TEST_COMMAND: "python -c 'from dimos.navigation.replanning_a_star.min_cost_astar_ext import min_cost_astar_cpp'" MACOSX_DEPLOYMENT_TARGET: "11.0" steps: diff --git a/pyproject.toml b/pyproject.toml index 6389cc5077..b9ecdf3c11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,7 +103,7 @@ dependencies = [ "sortedcontainers==2.4.0", "pydantic", "python-dotenv", - "typing_extensions>=4.0; python_version < '3.11'", + "typing_extensions>=4.0; python_version < '3.13'", "plum-dispatch==2.5.7", # Logging "structlog>=25.5.0,<26", diff --git a/uv.lock b/uv.lock index 7606629a6a..1d496e24e9 100644 --- a/uv.lock +++ b/uv.lock @@ -40,7 +40,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-19T12:47:59.312614Z" +exclude-newer = "2026-06-19T13:31:15.809764Z" exclude-newer-span = "P7D" [options.exclude-newer-package] @@ -1986,7 +1986,7 @@ dependencies = [ { name = "textual-serve" }, { name = "toolz" }, { name = "typer" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "websocket-client" }, ] @@ -2494,7 +2494,7 @@ requires-dist = [ { name = "transformers", extras = ["torch"], marker = "extra == 'perception'", specifier = ">=4.53.0,<4.54" }, { name = "trimesh", marker = "extra == 'manipulation'" }, { name = "typer", specifier = ">=0.19.2,<1" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.0" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'", specifier = ">=4.0" }, { name = "ultralytics", marker = "extra == 'perception'", specifier = ">=8.3.70" }, { name = "unitree-sdk2py-dimos", marker = "extra == 'unitree-dds'", specifier = ">=1.0.2" }, { name = "unitree-webrtc-connect", marker = "extra == 'unitree'", specifier = ">=2.1.2" }, From 0f162273489a159563f60c120bb79b1ecaec5346 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Fri, 26 Jun 2026 14:38:42 +0100 Subject: [PATCH 3/4] Fix --- dimos/spec/test_utils.py | 20 ++++++++++++++++++++ dimos/spec/utils.py | 2 ++ 2 files changed, 22 insertions(+) diff --git a/dimos/spec/test_utils.py b/dimos/spec/test_utils.py index 531aa8f54d..87c9c43f16 100644 --- a/dimos/spec/test_utils.py +++ b/dimos/spec/test_utils.py @@ -78,3 +78,23 @@ def test_spec_annotation_compliance_requires_matching_annotations() -> None: def test_spec_annotation_compliance_rejects_non_spec() -> None: with pytest.raises(TypeError): spec_annotation_compliance(StructurallyCompliant(), NormalProtocol) + + +class DefaultArgSpec(Spec, Protocol): + def speak(self, text: str, blocking: bool = True) -> str: ... + + +class MissingDefaultArg: + def speak(self, text: str) -> str: + return text + + +class ProvidesDefaultArg: + def speak(self, text: str, blocking: bool = True) -> str: + return text + + +def test_spec_annotation_compliance_handles_defaulted_parameters() -> None: + # A spec parameter with a default that the impl omits is rejected, not a crash. + assert spec_annotation_compliance(MissingDefaultArg(), DefaultArgSpec) is False + assert spec_annotation_compliance(ProvidesDefaultArg(), DefaultArgSpec) is True diff --git a/dimos/spec/utils.py b/dimos/spec/utils.py index d7b6d0ec9a..49339f739b 100644 --- a/dimos/spec/utils.py +++ b/dimos/spec/utils.py @@ -171,6 +171,8 @@ def _signatures_compatible(spec_sig: inspect.Signature, impl_sig: inspect.Signat for spec_param in spec_sig.parameters.values(): if spec_param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): continue + if spec_param.name not in bound.arguments: + return False # spec declares this parameter but the impl does not provide it impl_param = bound.arguments[spec_param.name] if ( spec_param.kind is not inspect.Parameter.POSITIONAL_ONLY From ae3edb7a933758da13b205fe77b2c29760b9e08f Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Fri, 26 Jun 2026 14:46:04 +0100 Subject: [PATCH 4/4] Fix --- dimos/spec/test_utils.py | 20 ++++++++++++++++++++ dimos/spec/utils.py | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/dimos/spec/test_utils.py b/dimos/spec/test_utils.py index 87c9c43f16..3d42092517 100644 --- a/dimos/spec/test_utils.py +++ b/dimos/spec/test_utils.py @@ -98,3 +98,23 @@ def test_spec_annotation_compliance_handles_defaulted_parameters() -> None: # A spec parameter with a default that the impl omits is rejected, not a crash. assert spec_annotation_compliance(MissingDefaultArg(), DefaultArgSpec) is False assert spec_annotation_compliance(ProvidesDefaultArg(), DefaultArgSpec) is True + + +class ExtraParamSpec(Spec, Protocol): + def f(self, a: int) -> int: ... + + +class ExtraOptionalParam: + def f(self, a: int, b: int = 0) -> int: + return a + + +class ExtraRequiredParam: + def f(self, a: int, b: int) -> int: + return a + + +def test_spec_annotation_compliance_allows_extra_optional_parameters() -> None: + # Extra impl parameters with defaults stay substitutable; required ones do not. + assert spec_annotation_compliance(ExtraOptionalParam(), ExtraParamSpec) is True + assert spec_annotation_compliance(ExtraRequiredParam(), ExtraParamSpec) is False diff --git a/dimos/spec/utils.py b/dimos/spec/utils.py index 49339f739b..203efa4047 100644 --- a/dimos/spec/utils.py +++ b/dimos/spec/utils.py @@ -153,6 +153,12 @@ def _signatures_compatible(spec_sig: inspect.Signature, impl_sig: inspect.Signat args: list[inspect.Parameter] = [] kwargs: dict[str, inspect.Parameter] = {} for p in impl_sig.parameters.values(): + # An extra *optional* impl parameter (not declared by the spec, has a default) + # does not break substitutability -- a spec-driven caller never passes it -- so + # ignore it. Extra *required* params are kept, so binding fails and the impl is + # rejected. + if p.default is not inspect.Parameter.empty and p.name not in spec_sig.parameters: + continue if p.kind is inspect.Parameter.POSITIONAL_ONLY: args.append(p) elif p.kind is inspect.Parameter.KEYWORD_ONLY: