diff --git a/CHANGELOG.md b/CHANGELOG.md index e42f871..efc71c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.05.04 (2026-05-08) + +### Fixed — `pyfly.security` no longer needs `pyjwt` to import + +`pyfly.security/__init__.py` used to eagerly re-export +`SecurityMiddleware`, which transitively imported the starlette +adapter and `pyjwt` at module load time. The chain meant that +`import pyfly` itself failed when those optional packages were +missing — even for non-HTTP services that just want the kernel + DDD +primitives. + +The import is now wrapped in `try / except ImportError`, matching the +pattern already used for `JWTService` and `BcryptPasswordEncoder`. +Optional symbols (`SecurityMiddleware`, `JWTService`, +`BcryptPasswordEncoder`) only land in the `__all__` export list when +their underlying packages (`starlette`, `pyjwt`, `bcrypt`) are present. + +Regression test pinned in +[`tests/security/test_optional_imports.py`](tests/security/test_optional_imports.py). + +### Verified — bare-wheel install works end-to-end + +* `pip install pyfly` (no extras) → `pyfly.domain` primitives + importable, no infra deps required. +* `pip install "pyfly[web,cqrs,transactional,eventsourcing]"` → + `PyFlyApplication`, `@enable_*_stack` decorators, and + `register_*_stack(app)` imperative API all work without `pyjwt` + installed. + +--- + ## v26.05.03 (2026-05-08) ### Changed — starter decorators are now functional diff --git a/README.md b/README.md index 6e983c1..86def17 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.05.03 + Version: 26.05.04 Type Checked: mypy strict Code Style: Ruff Async First @@ -804,13 +804,13 @@ See **[`samples/order_service/`](samples/order_service/README.md)** for an end-t ```bash # Install the latest release (uv) -uv add "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.5.3-py3-none-any.whl" +uv add "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.5.4-py3-none-any.whl" # Install with specific extras -uv add "pyfly[web,data-relational,cache] @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.5.3-py3-none-any.whl" +uv add "pyfly[web,data-relational,cache] @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.5.4-py3-none-any.whl" # Or with pip -pip install "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.5.3-py3-none-any.whl" +pip install "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.5.4-py3-none-any.whl" ``` ### One-Line Install (CLI + Framework) @@ -1136,7 +1136,12 @@ The git tag and human-readable display use the leading-zero form (`v26.05.01`); See **[CHANGELOG.md](CHANGELOG.md)** for detailed release notes. -**Current:** `v26.05.03` (2026-05-08) — Functional starters + Java/.NET parity: +**Current:** `v26.05.04` (2026-05-08) — `pyfly.security` import-chain fix: + +- **Bug fix** — `pyfly.security/__init__.py` no longer eagerly imports a starlette-specific `SecurityMiddleware` that transitively pulls in `pyjwt`. Importing `pyfly` (and instantiating `PyFlyApplication`) now works without `[security]` extras installed. Optional symbols (`SecurityMiddleware`, `JWTService`, `BcryptPasswordEncoder`) only export when their underlying packages (`starlette`, `pyjwt`, `bcrypt`) are present. Regression test pinned in `tests/security/test_optional_imports.py`. +- Verified: bare wheel install (`pip install pyfly`) now exposes `pyfly.domain` immediately; the `[web,cqrs,transactional,eventsourcing]` extras unblock the full application bootstrap path. + +**Previous:** `v26.05.03` (2026-05-08) — Functional starters + Java/.NET parity: - **Starters now actually do something** — `@enable_*_stack` decorators no longer just set a marker attribute. They now inject their property defaults between framework defaults and the user's `pyfly.yaml`, so the bundle activates the modules it promises (`pyfly.cqrs.enabled`, `pyfly.transactional.enabled`, etc.) while explicit user values still win. - **`@enable_web_stack` (new)** — dedicated web-tier starter for HTTP/REST APIs that don't need EDA, CQRS, or cache. Activates web framework adapter (Starlette/FastAPI), ASGI server, validation, actuator, observability, and resilience filters. diff --git a/install.sh b/install.sh index 48eb53a..c757fd2 100755 --- a/install.sh +++ b/install.sh @@ -26,7 +26,7 @@ set -euo pipefail # ── Constants ────────────────────────────────────────────────────────────────── -PYFLY_VERSION="26.05.03" +PYFLY_VERSION="26.05.04" PYFLY_REPO="https://github.com/fireflyframework/fireflyframework-pyfly.git" DEFAULT_INSTALL_DIR="$HOME/.pyfly" MIN_PYTHON_MAJOR=3 diff --git a/pyproject.toml b/pyproject.toml index 266aaaa..820356a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "hatchling.build" [project] name = "pyfly" -# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.3); +# 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.03) to match the Java/.NET/Go siblings. -version = "26.5.3" +# (v26.05.04) to match the Java/.NET/Go siblings. +version = "26.5.4" 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 eec3500..498687e 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.05.03" +__version__ = "26.05.04" diff --git a/src/pyfly/security/__init__.py b/src/pyfly/security/__init__.py index 80089b2..d7323ea 100644 --- a/src/pyfly/security/__init__.py +++ b/src/pyfly/security/__init__.py @@ -11,26 +11,43 @@ # 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. -"""PyFly Security — Authentication, authorization, and JWT integration.""" +"""PyFly Security — Authentication, authorization, and JWT integration. + +The starlette-specific :class:`SecurityMiddleware`, :class:`JWTService` +and :class:`BcryptPasswordEncoder` are *optional* — they are only +exported when their underlying third-party dependency +(``starlette`` / ``pyjwt`` / ``bcrypt``) is installed. This keeps the +security package importable from non-HTTP applications (workers, CLI +tools, data jobs) that don't pull in the ``[web]`` / ``[security]`` +extras. +""" from pyfly.security.context import SecurityContext from pyfly.security.decorators import secure from pyfly.security.http_security import AccessRule, AccessRuleType, HttpSecurity, SecurityRule from pyfly.security.method_security import post_authorize, pre_authorize -from pyfly.security.middleware import SecurityMiddleware __all__ = [ "AccessRule", "AccessRuleType", "HttpSecurity", "SecurityContext", - "SecurityMiddleware", "SecurityRule", "post_authorize", "pre_authorize", "secure", ] +try: + from pyfly.security.middleware import SecurityMiddleware + + __all__ += ["SecurityMiddleware"] +except ImportError: + # ``pyfly.security.middleware`` re-exports a starlette adapter that + # transitively requires ``pyjwt``; skip it when those extras aren't + # installed. + pass + try: from pyfly.security.jwt import JWTService diff --git a/tests/security/test_optional_imports.py b/tests/security/test_optional_imports.py new file mode 100644 index 0000000..189834c --- /dev/null +++ b/tests/security/test_optional_imports.py @@ -0,0 +1,85 @@ +# Copyright 2026 Firefly Software Foundation. +# Licensed under the Apache License, Version 2.0. +"""Regression test: ``pyfly.security`` must import without infra extras. + +Before v26.05.04 ``pyfly.security.__init__`` unconditionally re-exported +``SecurityMiddleware``, which transitively imported ``starlette`` and +``jwt`` at module load time. That made ``import pyfly`` itself fail +when those optional packages were missing — even for non-HTTP services +that just want the kernel + DDD primitives. + +The fix wraps the ``SecurityMiddleware`` import in ``try / except +ImportError`` so it's only exposed when its dependencies are +satisfied. This test pins the behaviour by checking that the +package's exports degrade gracefully. +""" + +from __future__ import annotations + +import importlib + +import pytest + + +def test_pyfly_security_imports_without_starlette_or_jwt() -> None: + """The package must import even if ``starlette`` / ``jwt`` are missing. + + We can't easily uninstall packages mid-test, so this test merely + verifies that the ``__init__`` doesn't *unconditionally* depend on + them — the fix is to check that ``SecurityMiddleware`` is wrapped + in a ``try / except`` import block, and that the package can be + fully reloaded without crashing. + """ + import pyfly.security as sec + + # Reload to exercise the fresh import path again. + importlib.reload(sec) + + # The unconditional symbols must always be available. + for name in ( + "AccessRule", + "AccessRuleType", + "HttpSecurity", + "SecurityContext", + "SecurityRule", + "post_authorize", + "pre_authorize", + "secure", + ): + assert hasattr(sec, name), f"missing required symbol: {name}" + + +def test_security_middleware_import_is_optional() -> None: + """Confirm the import is guarded — same source-shape used by + JWTService and BcryptPasswordEncoder, our reference patterns. + """ + spec = importlib.util.find_spec("pyfly.security") + assert spec is not None + src = spec.origin + assert src is not None + with open(src) as fh: + body = fh.read() + + # The eager import on the canonical symbol must be wrapped in a + # ``try`` block (matching the JWTService / password helpers). + assert "from pyfly.security.middleware import SecurityMiddleware" in body + middleware_idx = body.index("from pyfly.security.middleware import SecurityMiddleware") + preceding_try = body.rfind("try:", 0, middleware_idx) + assert preceding_try != -1, ( + "SecurityMiddleware import must be wrapped in a try / except ImportError " + "block so the package stays importable without starlette/pyjwt." + ) + # The matching except clause must appear after the import: + after = body[middleware_idx:] + assert "except ImportError" in after, "missing except ImportError after SecurityMiddleware import" + + +@pytest.mark.parametrize( + "extra_symbol", + ["JWTService", "BcryptPasswordEncoder", "PasswordEncoder", "SecurityMiddleware"], +) +def test_optional_security_symbols_are_in_all_when_available(extra_symbol: str) -> None: + import pyfly.security as sec + + if hasattr(sec, extra_symbol): + assert extra_symbol in sec.__all__