diff --git a/CHANGELOG.md b/CHANGELOG.md index dba4b8c..3367ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.96 (2026-06-10) + +### Fixed + +- **`Any` / `Any | None` parameters are no longer autowired.** A constructor or + `@bean`-method parameter typed `Any` (e.g. `registry: Any = None`) or `Any | None` + (e.g. `metrics: Any | None = None`) was resolved against the container, matching + whatever bean was registered under `Any` (such as an `@bean ... -> Any` cache health + indicator). That injected the wrong object, e.g. `'CacheHealthIndicator' object has + no attribute 'counter'` landing in `RuleEngineService(metrics=...)`. `Any` is now + treated like `type` — not injectable — so the parameter falls back to its default. + Regression tests in `tests/container/test_any_not_injectable.py`. + +--- + ## v26.06.95 (2026-06-10) ### Fixed diff --git a/README.md b/README.md index 0c4c9de..ff106c3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.95 + Version: 26.06.96 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index 17b6ee5..d949537 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.6.95" +version = "26.6.96" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/container/container.py b/src/pyfly/container/container.py index 7b5e566..3b1692c 100644 --- a/src/pyfly/container/container.py +++ b/src/pyfly/container/container.py @@ -543,7 +543,12 @@ def _resolve_param(self, param_type: type) -> Any: # Fast path: a plain class dependency — no typing origin and not a PEP 604 union. if origin is None and not isinstance(param_type, types.UnionType): - if param_type is type: + # `type` and `Any` are not injectable dependencies — let the caller fall back + # to the parameter's default (NoSuchBeanError + has_default => use default). + # `Any` must NOT be resolved: it would match whatever bean happens to be + # registered under `Any` (e.g. an `@bean ... -> Any`), injecting the wrong + # object — e.g. a CacheHealthIndicator landing in `registry: Any = None`. + if param_type is type or param_type is Any: raise NoSuchBeanError(bean_type=None) return self.resolve(param_type) @@ -565,6 +570,11 @@ def _resolve_param(self, param_type: type) -> Any: args = get_args(param_type) non_none = [a for a in args if a is not type(None)] if len(non_none) == 1: + # Optional[Any] (`Any | None`) is not an injectable dependency — `Any` + # would match whatever bean happens to be registered under `Any`, + # injecting the wrong object. Leave it unset (None). + if non_none[0] is Any: + return None try: return self.resolve(non_none[0]) except (NoSuchBeanError, NoUniqueBeanError): diff --git a/tests/container/test_any_not_injectable.py b/tests/container/test_any_not_injectable.py new file mode 100644 index 0000000..0b87803 --- /dev/null +++ b/tests/container/test_any_not_injectable.py @@ -0,0 +1,24 @@ +"""Regression: ``Any`` / ``Any | None`` parameters are NOT injectable. + +A parameter typed ``Any`` (or ``Any | None``) must fall back to its default +instead of resolving to whatever bean happens to be registered under ``Any`` +(e.g. an ``@bean ... -> Any``). Injecting the wrong object caused +`'CacheHealthIndicator' object has no attribute 'counter'` at startup when such a +value landed in ``RuleEngineService(metrics: Any | None = None)``. +""" + +from typing import Any + +import pytest + +from pyfly.container.container import Container +from pyfly.container.exceptions import NoSuchBeanError + + +def test_plain_any_is_not_injectable() -> None: + with pytest.raises(NoSuchBeanError): + Container()._resolve_param(Any) + + +def test_optional_any_resolves_to_none() -> None: + assert Container()._resolve_param(Any | None) is None diff --git a/uv.lock b/uv.lock index 90fbc76..d57ed1b 100644 --- a/uv.lock +++ b/uv.lock @@ -2160,7 +2160,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.95" +version = "26.6.96" source = { editable = "." } dependencies = [ { name = "pydantic" },