From b48d2373cc704f147bf78f7a75084856437b9500 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:28:09 +0000 Subject: [PATCH 1/3] fix: stop __version__ drift and mypy [no-redef] in optional mcp import Two remaining infra fixes from the #85-#88 batch (#86/#88 already merged in d83609d). - #85: derive agent_kernel.__version__ from installed distribution metadata (importlib.metadata.version("weaver-kernel")) instead of a hardcoded literal that had drifted to "0.5.0" while the package shipped 0.7.0/0.8.0. Falls back to "0.0.0+local" without dist metadata. This matches RELEASE.md, which only ever documented bumping pyproject.toml. - #87: resolve the optional McpError import through a _load_mcp_error() helper (mirroring mcp_support.import_optional) so the _McpError global is declared exactly once, fixing the mypy [no-redef] that surfaced when the mcp extra was not installed. Adds regression tests (tests/test_version.py; two _load_mcp_error cases in tests/test_mcp_driver.py) and CHANGELOG entries. make ci passes (564 passed, 1 skipped; mypy clean with and without the mcp extra). https://claude.ai/code/session_0185WabrTuoL3jCNZiER7ES6 --- CHANGELOG.md | 10 ++++++++++ src/agent_kernel/__init__.py | 11 ++++++++++- src/agent_kernel/drivers/mcp.py | 33 ++++++++++++++++++++++++--------- tests/test_mcp_driver.py | 18 ++++++++++++++++++ tests/test_version.py | 24 ++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 tests/test_version.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5491916..c1bd917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 auto-formatting. `AGENTS.md`, `docs/agent-context/workflows.md`, `docs/agent-context/review-checklist.md`, `CONTRIBUTING.md`, and the `README.md` development section are updated to describe the new chain. (#88) +- `agent_kernel.__version__` is now derived from the installed distribution + metadata (`importlib.metadata.version("weaver-kernel")`) instead of a + hand-maintained literal, so it can no longer drift from `pyproject.toml` + (it previously reported `0.5.0` while the package shipped `0.7.0`/`0.8.0`). + Falls back to `0.0.0+local` in a source tree without dist metadata. (#85) +- The `mcp.shared.exceptions.McpError` optional import in `drivers/mcp.py` + no longer triggers a mypy `[no-redef]` error when the `mcp` extra is not + installed. The lazy import is resolved through a `_load_mcp_error()` helper + (mirroring `mcp_support.import_optional`), declaring the `_McpError` global + exactly once. (#87) ## [0.8.0] - 2026-05-22 diff --git a/src/agent_kernel/__init__.py b/src/agent_kernel/__init__.py index e9ff4d4..5b45dd8 100644 --- a/src/agent_kernel/__init__.py +++ b/src/agent_kernel/__init__.py @@ -51,6 +51,9 @@ ) """ +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version + from .adapters import AnthropicMiddleware, OpenAIMiddleware from .drivers.base import Driver, ExecutionContext from .drivers.http import HTTPDriver @@ -139,7 +142,13 @@ from .tokens import CapabilityToken, HMACTokenProvider from .trace import TraceStore -__version__ = "0.5.0" +# Single source of truth: read the version from the installed distribution +# metadata (the PyPI dist name is ``weaver-kernel``, distinct from the import +# name ``agent_kernel``) so it never drifts from ``pyproject.toml``. +try: + __version__ = _pkg_version("weaver-kernel") +except PackageNotFoundError: # pragma: no cover - source tree without dist metadata + __version__ = "0.0.0+local" __all__ = [ # version diff --git a/src/agent_kernel/drivers/mcp.py b/src/agent_kernel/drivers/mcp.py index fe4f2c7..39e26d4 100644 --- a/src/agent_kernel/drivers/mcp.py +++ b/src/agent_kernel/drivers/mcp.py @@ -2,6 +2,7 @@ from __future__ import annotations +import importlib from collections.abc import Awaitable, Callable from typing import Any @@ -19,15 +20,29 @@ normalize_call_result, ) -# Lazy import of McpError — only available when the mcp optional dep is installed. -# If mcp is absent, factory methods raise ImportError before any session is created, -# so _McpError will never be None on a live driver instance. The explicit annotation -# keeps mypy --strict happy across the try/except branches. -_McpError: type[BaseException] | None -try: - from mcp.shared.exceptions import McpError as _McpError -except ImportError: # pragma: no cover - _McpError = None + +def _load_mcp_error() -> type[BaseException] | None: + """Return the ``McpError`` type, or ``None`` when ``mcp`` is unavailable. + + Imported via ``importlib`` (matching ``mcp_support.import_optional``) so the + optional-dependency branch is testable. Resolving the type into a separate, + explicitly annotated module global avoids the mypy ``[no-redef]`` that the + old ``import ... as _McpError`` pattern produced when ``mcp`` was absent and + the import resolved to ``Any`` under ``ignore_missing_imports``. + """ + try: + exceptions = importlib.import_module("mcp.shared.exceptions") + except ImportError: + return None + error_type: type[BaseException] = exceptions.McpError + return error_type + + +# Resolved once at import time and used in _run_with_retries to classify +# protocol-level rejections as non-retryable. None when the optional ``mcp`` +# dependency is not installed (factory methods raise ImportError first, so this +# is never None on a live driver instance). +_McpError: type[BaseException] | None = _load_mcp_error() def _infer_safety_class(spec: ToolSpec) -> SafetyClass: diff --git a/tests/test_mcp_driver.py b/tests/test_mcp_driver.py index 81d1a02..5963127 100644 --- a/tests/test_mcp_driver.py +++ b/tests/test_mcp_driver.py @@ -84,6 +84,24 @@ def test_from_http_missing_dependency_raises_helpful_import_error() -> None: MCPDriver.from_http("http://localhost:8080/mcp") +def test_load_mcp_error_returns_type_when_mcp_available() -> None: + """_load_mcp_error resolves the real McpError type when mcp is installed.""" + from mcp.shared.exceptions import McpError + + from agent_kernel.drivers.mcp import _load_mcp_error + + assert _load_mcp_error() is McpError + + +def test_load_mcp_error_returns_none_when_mcp_unavailable() -> None: + """_load_mcp_error returns None when the optional mcp dep is missing (issue #87).""" + from agent_kernel.drivers.mcp import _load_mcp_error + + with patch("agent_kernel.drivers.mcp.importlib.import_module") as import_module: + import_module.side_effect = ImportError("No module named 'mcp'") + assert _load_mcp_error() is None + + @pytest.mark.asyncio async def test_discover_converts_tools_to_capabilities() -> None: session = _FakeSession( diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..e23c8a9 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,24 @@ +"""Version-consistency tests. + +Pins the contract that ``agent_kernel.__version__`` is derived from the +installed distribution metadata rather than a hand-maintained literal, so it +can never drift from ``pyproject.toml`` again (issue #85). The PyPI +distribution name is ``weaver-kernel``, distinct from the import name +``agent_kernel``. +""" + +from __future__ import annotations + +from importlib.metadata import version as pkg_version + +import agent_kernel + + +def test_version_matches_distribution_metadata() -> None: + """``__version__`` equals the installed ``weaver-kernel`` dist version.""" + assert agent_kernel.__version__ == pkg_version("weaver-kernel") + + +def test_version_is_exported() -> None: + """``__version__`` stays part of the public API surface.""" + assert "__version__" in agent_kernel.__all__ From ffcb2e2b9b45255a4cf94669d2f0c0a3079302db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 06:55:42 +0000 Subject: [PATCH 2/3] fix: guard McpError attribute access in _load_mcp_error Address PR review feedback: _load_mcp_error accessed exceptions.McpError directly, so a module that imports but lacks/renames McpError would raise AttributeError at import time and break agent_kernel.drivers.mcp entirely. Use getattr(..., None) plus a type check to degrade to None, restoring the old ImportError -> None fallback semantics for the optional-dependency path. Add a regression test covering the module-present-but-attribute-missing case. https://claude.ai/code/session_01TnKC4bmhsCCJTju77jKwdJ --- src/agent_kernel/drivers/mcp.py | 10 ++++++++-- tests/test_mcp_driver.py | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/agent_kernel/drivers/mcp.py b/src/agent_kernel/drivers/mcp.py index 39e26d4..5594c0e 100644 --- a/src/agent_kernel/drivers/mcp.py +++ b/src/agent_kernel/drivers/mcp.py @@ -34,8 +34,14 @@ def _load_mcp_error() -> type[BaseException] | None: exceptions = importlib.import_module("mcp.shared.exceptions") except ImportError: return None - error_type: type[BaseException] = exceptions.McpError - return error_type + # Guard attribute access too: if the module imports but ``McpError`` is + # missing or renamed, degrade to ``None`` (matching the old + # ``from ... import McpError`` ImportError fallback) rather than raising + # AttributeError at import time and breaking the whole driver module. + error_type = getattr(exceptions, "McpError", None) + if isinstance(error_type, type) and issubclass(error_type, BaseException): + return error_type + return None # Resolved once at import time and used in _run_with_retries to classify diff --git a/tests/test_mcp_driver.py b/tests/test_mcp_driver.py index 5963127..0f293a8 100644 --- a/tests/test_mcp_driver.py +++ b/tests/test_mcp_driver.py @@ -102,6 +102,22 @@ def test_load_mcp_error_returns_none_when_mcp_unavailable() -> None: assert _load_mcp_error() is None +def test_load_mcp_error_returns_none_when_attribute_missing() -> None: + """_load_mcp_error degrades to None if the module imports but McpError is gone. + + Guards against an AttributeError at import time (and a broken driver module) + when a future/renamed mcp release no longer exposes ``McpError``. + """ + from types import ModuleType + + from agent_kernel.drivers.mcp import _load_mcp_error + + empty_module = ModuleType("mcp.shared.exceptions") + with patch("agent_kernel.drivers.mcp.importlib.import_module") as import_module: + import_module.return_value = empty_module + assert _load_mcp_error() is None + + @pytest.mark.asyncio async def test_discover_converts_tools_to_capabilities() -> None: session = _FakeSession( From 70e9a24a955676091315f6e97078254b67c499c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 07:01:07 +0000 Subject: [PATCH 3/3] test: cross-check __version__ against pyproject [project].version Audit follow-up (#85 AC1): the existing test only re-derived __version__ from the same importlib.metadata source it is defined from, so it could not catch a pyproject bump that the installed/editable metadata had not picked up. Add test_version_matches_pyproject_declaration, which parses [project].version directly (regex, no tomllib so it runs on 3.10) and skips gracefully when the source tree is absent. https://claude.ai/code/session_01TnKC4bmhsCCJTju77jKwdJ --- tests/test_version.py | 44 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/test_version.py b/tests/test_version.py index e23c8a9..8257da8 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -9,16 +9,58 @@ from __future__ import annotations +import re from importlib.metadata import version as pkg_version +from pathlib import Path + +import pytest import agent_kernel +_PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" + + +def _declared_pyproject_version() -> str | None: + """Return ``[project].version`` from ``pyproject.toml``, or None if absent. + + Parsed with a small regex rather than ``tomllib`` so the test runs + unchanged on Python 3.10 (where ``tomllib`` is unavailable) without adding + a ``tomli`` dependency. Returns None when the source tree — and thus + ``pyproject.toml`` — is not present (e.g. a wheel-only install). + """ + if not _PYPROJECT.is_file(): + return None + text = _PYPROJECT.read_text(encoding="utf-8") + # Scope to the [project] table: from its header up to the next table header. + section = re.search(r"(?ms)^\[project\]\s*$(.*?)(?=^\[)", text) + body = section.group(1) if section else text + match = re.search(r'(?m)^\s*version\s*=\s*"([^"]+)"', body) + return match.group(1) if match else None + def test_version_matches_distribution_metadata() -> None: - """``__version__`` equals the installed ``weaver-kernel`` dist version.""" + """``__version__`` equals the installed ``weaver-kernel`` dist version. + + Pins that ``__version__`` is *derived from* dist metadata (not a literal); + cross-checking against ``pyproject.toml`` is done separately below. + """ assert agent_kernel.__version__ == pkg_version("weaver-kernel") +def test_version_matches_pyproject_declaration() -> None: + """``__version__`` matches the version declared in ``pyproject.toml``. + + Directly enforces issue #85's acceptance criterion ("matches + ``pyproject.toml``'s ``[project].version``") rather than only re-deriving + from the same metadata source, so a release that bumps ``pyproject.toml`` + without refreshing the installed/editable metadata is caught. + """ + declared = _declared_pyproject_version() + if declared is None: + pytest.skip("pyproject.toml not present (installed without source tree)") + assert agent_kernel.__version__ == declared + + def test_version_is_exported() -> None: """``__version__`` stays part of the public API surface.""" assert "__version__" in agent_kernel.__all__