From 8c33df3d51719addeb366571b541297a37b8ac01 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 30 Sep 2026 00:52:34 -0700 Subject: [PATCH] fix: skip scheduler descriptors before binding jobs --- CHANGELOG.md | 18 ++++ README.md | 9 +- docs/cli.md | 2 +- docs/getting-started.md | 2 +- docs/installation.md | 2 +- docs/modules/core.md | 4 +- docs/modules/scheduling.md | 7 +- docs/versioning.md | 7 +- install.sh | 2 +- pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- src/pyfly/scheduling/task_scheduler.py | 14 ++- .../scheduling/test_discovery_descriptors.py | 100 ++++++++++++++++++ uv.lock | 2 +- web/index.html | 2 +- 15 files changed, 155 insertions(+), 20 deletions(-) create mode 100644 tests/scheduling/test_discovery_descriptors.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd6e5f8..f84da141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.09.11 (2026-09-30) + +### Fixed + +- `TaskScheduler.discover()` skips custom descriptors before binding + scheduled methods. Scanning a Pydantic settings instance no longer evaluates + `model_fields` or `model_computed_fields`, which emitted + `PydanticDeprecatedSince211` warnings in 26.09.10. Discovery still finds and + executes ordinary, inherited, static and class methods marked `@scheduled`. +- Regression coverage verifies both the absence of Pydantic warnings and that + discovery never executes custom descriptor, property or cached-property getters. + +### Upgrading + +- No configuration or public API changes. No warning filters are required. + Custom descriptors are not evaluated to obtain scheduled callables; expose + scheduled jobs as methods. + ## v26.09.10 (2026-09-29) Reusable infrastructure hardening discovered while building Firefly Weave. diff --git a/README.md b/README.md index 997ab623..5c9e2b60 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.09.10 + Version: 26.09.11 Type Checked: mypy strict Code Style: Ruff Async First @@ -850,13 +850,13 @@ See **[`samples/lumen/`](samples/lumen/README.md)** for an end-to-end DDD micros ```bash # Install the latest release (uv) -uv add "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.10-py3-none-any.whl" +uv add "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.11-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.9.10-py3-none-any.whl" +uv add "pyfly[web,data-relational,cache] @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.11-py3-none-any.whl" # Or with pip -pip install "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.10-py3-none-any.whl" +pip install "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.11-py3-none-any.whl" ``` ### One-Line Install (CLI + Framework) @@ -1184,6 +1184,7 @@ The git tag and human-readable display use the leading-zero form (`v26.05.01`); The full release history lives in **[CHANGELOG.md](CHANGELOG.md)** ([Keep a Changelog](https://keepachangelog.com/) format). Recent highlights: +- **`v26.09.11`** (2026-09-30) — scheduled-method discovery skips custom descriptors, avoiding Pydantic instance-field deprecation warnings. - **`v26.09.10`** (2026-09-29) — bounded JWKS rotation and HTTP responses, standalone OAuth acquisition, complete failed-start cleanup, substitutable observability and exact-byte webhook validation. - **`v26.09.09`** (2026-09-28) — a restarted context no longer resolves an interface through the previous run's binding (a second `start()` of the same context failed with a `KeyError`). - **`v26.09.08`** (2026-09-28) — **the data layer on a unit of work**: Spring `@transactional` semantics on every backend (all seven propagations, rollback-only, isolation, read-only replica routing, timeouts), one `DataSourceRegistry` for every engine, repositories that commit per call outside a transaction, the framework's stores in the business transaction, MongoDB transactions on a replica set, and a tested matrix of SQLite, PostgreSQL, MySQL, MariaDB and MongoDB. Several changes are breaking: read the upgrade guide in the [CHANGELOG](CHANGELOG.md). diff --git a/docs/cli.md b/docs/cli.md index 43267233..5a97ed03 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -775,7 +775,7 @@ Missing optional tools are shown with a `-` dash indicator (dimmed), while missi Verifies that PyFly itself is importable and displays the installed version: ``` -✓ pyfly v26.09.10 +✓ pyfly v26.09.11 ``` ### Summary diff --git a/docs/getting-started.md b/docs/getting-started.md index 2b7dffd0..c0ecb2ef 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -480,7 +480,7 @@ ______ ___.__._/ ____\ | ___.__. | __// ____| |__| |____/ ____| |__| \/ \/ - PyFly v26.09.10 | Python 3.12.0 + PyFly v26.09.11 | Python 3.12.0 2026-01-15T10:30:00Z [info] starting_application app=my-service version=0.1.0 2026-01-15T10:30:00Z [info] no_active_profiles message=No active profiles set, falling back to default diff --git a/docs/installation.md b/docs/installation.md index dfd8aceb..f869d79a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -513,7 +513,7 @@ PyFly Doctor ✓ mypy — Type checker PyFly packages: - ✓ pyfly v26.09.10 + ✓ pyfly v26.09.11 All checks passed! ``` diff --git a/docs/modules/core.md b/docs/modules/core.md index 8ff0e963..79bc4404 100644 --- a/docs/modules/core.md +++ b/docs/modules/core.md @@ -599,7 +599,7 @@ class BannerMode(enum.Enum): | Mode | Behavior | |---|---| | `TEXT` | Full ASCII art banner (default) with a framework version line. | -| `MINIMAL` | Single line: `:: PyFly :: (v26.09.10)` | +| `MINIMAL` | Single line: `:: PyFly :: (v26.09.11)` | | `OFF` | No banner output at all. | ### BannerPrinter Class @@ -640,7 +640,7 @@ ______ ___.__._/ ____\ | ___.__. | __// ____| |__| |____/ ____| |__| \/ \/ -:: PyFly Framework :: (v26.09.10) +:: PyFly Framework :: (v26.09.11) ``` ### Custom Banner Files diff --git a/docs/modules/scheduling.md b/docs/modules/scheduling.md index 4bcdbbae..a65b7b1f 100644 --- a/docs/modules/scheduling.md +++ b/docs/modules/scheduling.md @@ -457,7 +457,12 @@ scheduler = TaskScheduler(lock=RedisLock(redis_client)) Call `discover()` with a list of bean instances. It scans every public attribute (names not starting with `_`) and records those marked with -`__pyfly_scheduled__ = True`. Returns the number of scheduled methods found: +`__pyfly_scheduled__ = True`. Since v26.09.11, discovery skips custom +descriptors as well as properties and cached properties before binding methods. +Pydantic settings can be included without instance-field deprecation warnings. +Ordinary, inherited, static and class methods remain discoverable; expose jobs +as methods rather than descriptor-produced callables. Returns the number of +scheduled methods found: ```python beans = [metrics_collector, data_syncer, report_generator] diff --git a/docs/versioning.md b/docs/versioning.md index 82d3049f..8fd6deb0 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -64,6 +64,7 @@ rare case where a substantial change needs an additional review window. | Version | Date | Notes | |---------|------|-------| +| `26.09.11` | 2026-09-30 | Scheduled-method discovery avoids evaluating Pydantic and custom descriptors. | | `26.09.10` | 2026-09-29 | Bounded identity and HTTP primitives, lifecycle ownership, substitutable observability and exact-byte webhooks. | | `26.09.09` | 2026-09-28 | A restarted context drops the previous run's interface bindings with its registrations, so a second `start()` of the same context no longer fails with a `KeyError` (a test suite whose modules share an application). | | `26.09.08` | 2026-09-28 | The data layer on a unit of work: Spring `@transactional` semantics on every backend, one `DataSourceRegistry` for every engine, repositories that commit per call outside a transaction, the framework stores in the business transaction, MongoDB transactions, and a backend matrix (SQLite, PostgreSQL, MySQL, MariaDB, MongoDB replica set). Several breaking changes: see the changelog. | @@ -93,17 +94,17 @@ shipped, with the version metadata updated. ```python import pyfly -print(pyfly.__version__) # → "26.09.10" +print(pyfly.__version__) # → "26.09.11" ``` ```bash -pyfly --version # → 26.09.10 +pyfly --version # → 26.09.11 ``` The startup banner displays the leading-zero form: ``` -:: PyFly Framework :: (v26.09.10) (Python 3.13.9) +:: PyFly Framework :: (v26.09.11) (Python 3.13.9) ``` --- diff --git a/install.sh b/install.sh index 0e7dab14..56e55773 100755 --- a/install.sh +++ b/install.sh @@ -26,7 +26,7 @@ set -euo pipefail # ── Constants ────────────────────────────────────────────────────────────────── -PYFLY_VERSION="26.09.10" +PYFLY_VERSION="26.09.11" 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 20c47434..e695f282 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.9.10" +version = "26.9.11" 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 e5b42002..bd6267d9 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.09.10" +__version__ = "26.09.11" diff --git a/src/pyfly/scheduling/task_scheduler.py b/src/pyfly/scheduling/task_scheduler.py index a603d0c6..f790f1e7 100644 --- a/src/pyfly/scheduling/task_scheduler.py +++ b/src/pyfly/scheduling/task_scheduler.py @@ -123,8 +123,9 @@ def executor(self) -> TaskExecutorPort: def discover(self, beans: list[Any]) -> int: """Scan beans for @scheduled methods. Return number of scheduled methods found. - For each bean, inspects all attributes. If an attribute is callable and - has ``__pyfly_scheduled__ == True``, it is recorded for later scheduling. + For each bean, inspects public attributes without evaluating custom + descriptors. Callable attributes marked ``__pyfly_scheduled__ == True`` + are recorded for later scheduling. """ count = 0 for bean in beans: @@ -137,6 +138,15 @@ def discover(self, beans: list[Any]) -> int: static_attr = inspect.getattr_static(bean, name, None) if isinstance(static_attr, (property, functools.cached_property)): continue + # Custom descriptors (including Pydantic's deprecated instance + # properties) can look like method descriptors on Python 3.12. + if hasattr(type(static_attr), "__get__") and not ( + inspect.isfunction(static_attr) + or isinstance(static_attr, (staticmethod, classmethod)) + or inspect.ismethoddescriptor(static_attr) + and type(static_attr).__module__ == "builtins" + ): + continue try: attr = getattr(bean, name) except Exception: # noqa: BLE001 diff --git a/tests/scheduling/test_discovery_descriptors.py b/tests/scheduling/test_discovery_descriptors.py new file mode 100644 index 00000000..1f1a591a --- /dev/null +++ b/tests/scheduling/test_discovery_descriptors.py @@ -0,0 +1,100 @@ +# 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. +"""Discovery must not evaluate bean descriptors while finding scheduled methods.""" + +from __future__ import annotations + +import asyncio +import warnings +from datetime import timedelta +from functools import cached_property +from typing import Any + +import pytest +from pydantic import BaseModel +from pydantic.warnings import PydanticDeprecatedSince211 + +from pyfly.scheduling.decorators import scheduled +from pyfly.scheduling.task_scheduler import TaskScheduler + + +@pytest.mark.asyncio +async def test_pydantic_settings_do_not_warn_and_scheduled_bindings_still_execute() -> None: + observed = {name: asyncio.Event() for name in ("model", "inherited", "static", "class")} + + class Settings(BaseModel): + name: str = "settings" + + @scheduled(fixed_delay=timedelta(milliseconds=10)) + async def model_job(self) -> None: + assert self.name == "settings" + observed["model"].set() + + class ParentJobs: + @scheduled(fixed_delay=timedelta(milliseconds=10)) + async def inherited_job(self) -> None: + observed["inherited"].set() + + class Jobs(ParentJobs): + @staticmethod + @scheduled(fixed_delay=timedelta(milliseconds=10)) + async def static_job() -> None: + observed["static"].set() + + @classmethod + @scheduled(fixed_delay=timedelta(milliseconds=10)) + async def class_job(cls) -> None: + assert cls is Jobs + observed["class"].set() + + scheduler = TaskScheduler() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", PydanticDeprecatedSince211) + count = scheduler.discover([Settings(), Jobs()]) + assert not caught, [str(warning.message) for warning in caught] + assert count == 4 + try: + await scheduler.start() + await asyncio.wait_for(asyncio.gather(*(event.wait() for event in observed.values())), timeout=1) + finally: + await scheduler.stop() + + +def test_discovery_does_not_evaluate_custom_descriptors_or_properties() -> None: + accessed: list[str] = [] + + class Descriptor: + def __get__(self, instance: Any, owner: type | None = None) -> Any: + accessed.append("descriptor") + raise RuntimeError("must not execute during discovery") + + class Bean: + metadata = Descriptor() + + @property + def value(self) -> str: + accessed.append("property") + raise RuntimeError("must not execute during discovery") + + @cached_property + def cached(self) -> str: + accessed.append("cached_property") + raise RuntimeError("must not execute during discovery") + + @scheduled(fixed_delay=timedelta(seconds=1)) + async def job(self) -> None: + pass + + assert TaskScheduler().discover([Bean()]) == 1 + assert accessed == [] diff --git a/uv.lock b/uv.lock index fdb52983..5ce573e8 100644 --- a/uv.lock +++ b/uv.lock @@ -2337,7 +2337,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.9.10" +version = "26.9.11" source = { editable = "." } dependencies = [ { name = "pydantic" }, diff --git a/web/index.html b/web/index.html index e86a244d..e0b3459b 100644 --- a/web/index.html +++ b/web/index.html @@ -91,7 +91,7 @@

Apache 2.0 mypy strict async-first - v26.09.10 + v26.09.11