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..5594c0e 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,35 @@ 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 + # 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 +# 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..0f293a8 100644 --- a/tests/test_mcp_driver.py +++ b/tests/test_mcp_driver.py @@ -84,6 +84,40 @@ 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 + + +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( diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..8257da8 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,66 @@ +"""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 + +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. + + 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__