From db8a691f0777c5722ccd92004137a6d2f2fc58e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Sun, 7 Jun 2026 13:30:43 +0200 Subject: [PATCH] feat(context): @conditional_on_web_application + @conditional_on_resource + bump v26.06.40 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more Spring @ConditionalOn* conditions: - conditional_on_web_application(): matches when starlette/fastapi importable (find_spec). - conditional_on_resource(path): matches when the filesystem resource exists. Both pass-1 (config/class), wired into the condition evaluator + exported from pyfly.context. (@ConditionalOnSingleCandidate deferred — needs careful bean-counting vs interface aliases.) Tests: tests/context/test_conditional_on_family.py (2). Gates: mypy --strict (619), ruff + format, full suite 3820 passed. --- .gitignore | 3 ++ CHANGELOG.md | 12 +++++ README.md | 2 +- pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- src/pyfly/context/__init__.py | 4 ++ src/pyfly/context/condition_evaluator.py | 14 ++++++ src/pyfly/context/conditions.py | 30 ++++++++++++ tests/context/test_conditional_on_family.py | 53 +++++++++++++++++++++ uv.lock | 2 +- 10 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 tests/context/test_conditional_on_family.py diff --git a/.gitignore b/.gitignore index c7f9b90b..5546dd6e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ docs/plans/ # Playwright MCP verification artifacts .playwright-mcp/ .audit/ + +# Superpowers brainstorming visual companion scratch +.superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ae08a733..41f0bd6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.40 (2026-06-07) + +### Added (context — more @ConditionalOn* conditions) + +- **`@conditional_on_web_application()`** — registers the bean only when a web stack + (Starlette or FastAPI) is importable (Spring `@ConditionalOnWebApplication`). +- **`@conditional_on_resource(path)`** — registers only when the filesystem resource at + *path* exists (Spring `@ConditionalOnResource`). + +Both exported from `pyfly.context`. (`@ConditionalOnSingleCandidate` remains on the +roadmap — it needs careful bean-counting around interface-alias registrations.) + ## v26.06.39 (2026-06-07) ### Added (config — Spring Boot 2.4+ profile expressions) diff --git a/README.md b/README.md index fb2b36c2..805b081c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.39 + Version: 26.06.40 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index 1debcd0a..d16b0bc4 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.39" +version = "26.6.40" 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/__init__.py b/src/pyfly/__init__.py index 3267959c..5e259781 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.06.39" +__version__ = "26.06.40" diff --git a/src/pyfly/context/__init__.py b/src/pyfly/context/__init__.py index 3469cc48..03473931 100644 --- a/src/pyfly/context/__init__.py +++ b/src/pyfly/context/__init__.py @@ -21,6 +21,8 @@ conditional_on_expression, conditional_on_missing_bean, conditional_on_property, + conditional_on_resource, + conditional_on_web_application, ) from pyfly.context.environment import Environment from pyfly.context.events import ( @@ -52,6 +54,8 @@ "conditional_on_missing_bean", "conditional_on_expression", "conditional_on_property", + "conditional_on_resource", + "conditional_on_web_application", "post_construct", "pre_destroy", ] diff --git a/src/pyfly/context/condition_evaluator.py b/src/pyfly/context/condition_evaluator.py index d9904941..51d51054 100644 --- a/src/pyfly/context/condition_evaluator.py +++ b/src/pyfly/context/condition_evaluator.py @@ -15,7 +15,9 @@ from __future__ import annotations +import importlib.util import logging +import os from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: @@ -90,6 +92,10 @@ def _evaluate(self, cond: dict[str, Any], *, declaring_cls: type | None = None) result = cond["check"]() elif cond_type == "on_expression": result = self._eval_on_expression(cond) + elif cond_type == "on_web_application": + result = self._eval_on_web_application() + elif cond_type == "on_resource": + result = self._eval_on_resource(cond) elif cond_type == "on_missing_bean": result = self._eval_on_missing_bean(cond, declaring_cls) elif cond_type == "on_bean": @@ -127,6 +133,14 @@ def _eval_on_expression(self, cond: dict[str, Any]) -> bool: return bool(evaluate(cond["expression"], self._config)) + def _eval_on_web_application(self) -> bool: + """Match when a web stack (Starlette or FastAPI) is importable.""" + return any(importlib.util.find_spec(name) is not None for name in ("starlette", "fastapi")) + + def _eval_on_resource(self, cond: dict[str, Any]) -> bool: + """Match when the filesystem resource at the configured path exists.""" + return os.path.exists(cond["path"]) + def _eval_on_missing_bean(self, cond: dict[str, Any], declaring_cls: type | None = None) -> bool: return not self._has_bean_of_type(cond["bean_type"], exclude=declaring_cls) diff --git a/src/pyfly/context/conditions.py b/src/pyfly/context/conditions.py index 35658073..5c92f7b7 100644 --- a/src/pyfly/context/conditions.py +++ b/src/pyfly/context/conditions.py @@ -134,6 +134,36 @@ def decorator(cls: F) -> F: return decorator +def conditional_on_web_application() -> Callable[[F], F]: + """Only register this bean when a web stack is present (Starlette or FastAPI). + + Mirrors Spring Boot's ``@ConditionalOnWebApplication``. + """ + + def decorator(cls: F) -> F: + conditions = list(cls.__dict__.get("__pyfly_conditions__", [])) + conditions.append({"type": "on_web_application"}) + cls.__pyfly_conditions__ = conditions # type: ignore[attr-defined] + return cls + + return decorator + + +def conditional_on_resource(path: str) -> Callable[[F], F]: + """Only register this bean when the filesystem resource at *path* exists. + + Mirrors Spring Boot's ``@ConditionalOnResource``. + """ + + def decorator(cls: F) -> F: + conditions = list(cls.__dict__.get("__pyfly_conditions__", [])) + conditions.append({"type": "on_resource", "path": path}) + cls.__pyfly_conditions__ = conditions # type: ignore[attr-defined] + return cls + + return decorator + + def auto_configuration(cls: T) -> T: """Mark a @configuration class as auto-configuration. diff --git a/tests/context/test_conditional_on_family.py b/tests/context/test_conditional_on_family.py new file mode 100644 index 00000000..821b7083 --- /dev/null +++ b/tests/context/test_conditional_on_family.py @@ -0,0 +1,53 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""@ConditionalOnWebApplication / @ConditionalOnResource (v26.06.40).""" + +from __future__ import annotations + +from pathlib import Path + +from pyfly.container.container import Container +from pyfly.context.condition_evaluator import ConditionEvaluator +from pyfly.context.conditions import conditional_on_resource, conditional_on_web_application +from pyfly.core.config import Config + + +def _evaluator() -> ConditionEvaluator: + return ConditionEvaluator(Config({}), Container()) + + +def test_conditional_on_web_application() -> None: + @conditional_on_web_application() + class WebBean: + pass + + # starlette is installed in the test environment -> a web application + assert _evaluator().should_include(WebBean) is True + + +def test_conditional_on_resource(tmp_path: Path) -> None: + present_file = tmp_path / "present.txt" + present_file.write_text("hi") + + @conditional_on_resource(str(present_file)) + class WithResource: + pass + + @conditional_on_resource(str(tmp_path / "missing.txt")) + class WithoutResource: + pass + + evaluator = _evaluator() + assert evaluator.should_include(WithResource) is True + assert evaluator.should_include(WithoutResource) is False diff --git a/uv.lock b/uv.lock index bbd75e2d..35e354da 100644 --- a/uv.lock +++ b/uv.lock @@ -1981,7 +1981,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.39" +version = "26.6.40" source = { editable = "." } dependencies = [ { name = "pydantic" },