Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -112,6 +111,8 @@ jobs:
env:
CIBW_ENABLE: "cpython-freethreading"
CIBW_ARCHS: ${{ matrix.arch }}
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:
Expand Down
40 changes: 40 additions & 0 deletions dimos/spec/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,43 @@ 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


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
116 changes: 106 additions & 10 deletions dimos/spec/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,14 +93,106 @@ 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():
# 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:
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
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
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
Comment thread
Dreamsorcerer marked this conversation as resolved.
return True


def get_protocol_method_signatures(proto: type[object]) -> dict[str, inspect.Signature]:
Expand Down
2 changes: 1 addition & 1 deletion docs/development/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,7 @@ dependencies = [
"sortedcontainers==2.4.0",
"pydantic",
Comment thread
Dreamsorcerer marked this conversation as resolved.
"python-dotenv",
"typing_extensions>=4.0; python_version < '3.11'",
"annotation-protocol>=1.4.0",
"typing_extensions>=4.0; python_version < '3.13'",
"plum-dispatch==2.5.7",
# Logging
"structlog>=25.5.0,<26",
Expand Down Expand Up @@ -506,7 +505,6 @@ exclude = "^dimos/models/Detic(/|$)|.*/test_.|.*/conftest.py*"

[[tool.mypy.overrides]]
module = [
"annotation_protocol",
"a750_control",
"a750_control.*",
"cyclonedds",
Expand Down
17 changes: 3 additions & 14 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading