Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e574f5b
fix(ci): deploy root AI files on all pushes; add dispatch w/ overwrit…
Borda May 25, 2026
2944cbf
feat: async def coroutine wrapper (N1-step2)
Borda May 25, 2026
b68bc5c
fix(async): resolve N1-step2 gaps from challenger review
Borda May 25, 2026
767bdff
Potential fix for pull request finding
Borda May 25, 2026
812c1a8
Potential fix for pull request finding
Borda May 25, 2026
f27abdd
Potential fix for pull request finding
Borda May 25, 2026
8d051f1
Potential fix for pull request finding
Borda May 25, 2026
281689d
Merge branch 'main' into feat/async
Borda May 25, 2026
cf926cc
fix(async): emit UserWarning when @deprecated applied to async generator
Borda May 26, 2026
50e2353
docs(_types): clarify short_circuit dispatch for var-positional sources
Borda May 26, 2026
6fef046
fix(async): raise TypeError when sync source forwards to async target
Borda May 26, 2026
fe3560a
docs: enumerate iscoroutinefunction false-negative classes
Borda May 26, 2026
682e63a
fix: preserve *args on short-circuit path for var-positional sources
Borda May 26, 2026
48fc58c
ci: fix lychee exclude regex for GitHub template URL
Borda May 26, 2026
cb56f1f
test: import target callables from collection_targets (three-layer rule)
Borda May 26, 2026
7bc9f4e
docs: remove duplicate section separator in troubleshooting.md
Borda May 26, 2026
78c5a1c
refactor(deprecation): clean up _build_call_plan internals
Borda May 26, 2026
2b83d54
refactor(deprecation): use keyword-only arguments for warning functions
Borda May 26, 2026
0c2ab36
Potential fix for pull request finding
Borda May 26, 2026
22cef2a
Potential fix for pull request finding
Borda May 26, 2026
d772466
Apply suggestions from code review
Borda May 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci_check-links.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,5 @@ jobs:
--exclude file://.*demo-sphinx.*
--exclude file://.*demo-mkdocs.*
--exclude '^https[:]//borda[/]$'
--exclude '[hH]ttps://github\.com/\$'
--exclude '^[hH]ttps://github\.com/\$(\.git)?$'
fail: true
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- **Generator function support for `@deprecated`.** Decorating a generator function now emits the deprecation warning eagerly at call time β€” before the first `next()` β€” consistent with regular function behavior. The generator body executes lazily as normal when iterated. All three `TargetMode` variants (`NOTIFY`, `ARGS_REMAP`, callable target) work transparently; no `isgeneratorfunction` check is required. ([#176](https://github.com/Borda/pyDeprecate/pull/176))
- **`async def` coroutine wrapper support for `@deprecated`.** Decorating an `async def` function now produces an `async def` wrapper β€” `inspect.iscoroutinefunction(wrapper)` returns `True`. All three `TargetMode` variants (`NOTIFY`, `ARGS_REMAP`, callable target) work with async sources and async targets. The deprecation warning fires when the coroutine is awaited, not when the wrapper is called. `pytest-asyncio` is required in the test suite to run the async integration tests.
- **Order-agnostic `classmethod`/`staticmethod` guard.** Applying `@deprecated` outside `@classmethod` (wrong decorator order) is now silently rescued at decoration time: the descriptor is unwrapped, the inner function is deprecated, and the result is re-wrapped as `classmethod(deprecated_wrapper)`. No `UserWarning` is emitted; a `FutureWarning` fires normally at call time. The preferred order (`@classmethod` outermost, `@deprecated` closer to `def`) is unchanged. ([#176](https://github.com/Borda/pyDeprecate/pull/176))

### Changed
Expand Down
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ ______________________________________________________________________
- [Deprecating Enums and dataclasses](#deprecating-enums-and-dataclasses)
- [Automatic docstring updates](#automatic-docstring-updates)
- [Injecting new required arguments](#injecting-new-required-arguments)
- [Async functions](#-async-functions)
- [πŸ”‡ Understanding the void() Helper](#understanding-the-void-helper)
- [πŸ” Audit](#audit)
- [Validating Wrapper Configuration](#validating-wrapper-configuration)
Expand All @@ -68,7 +69,7 @@ Another good aspect is not overwhelming users with too many warnings, so per fun
- πŸ”„ Arguments are automatically mapped to the target function
- 🚫 The deprecated function body is never executed when using `target`
- ⚑ Minimal runtime overhead with zero dependencies (Python standard library only)
- πŸ› οΈ Supports deprecating callables: functions, methods, class constructors, and **generator functions** (`def gen(): yield`) β€” warning fires eagerly at call time, before iteration begins
- πŸ› οΈ Supports deprecating callables: functions, methods, class constructors, **generator functions** (`def gen(): yield` β€” warning fires eagerly at call time, before iteration begins), and **`async def` coroutine functions** (wrapper is `async def`, `inspect.iscoroutinefunction(wrapper)` returns `True`; warning fires when the coroutine is awaited)
- πŸ“¦ Supports deprecating classes, Enums, dataclasses, and module-level constants/objects via transparent proxies (`deprecated_class` for types; `deprecated_instance` for objects, with optional read-only enforcement β€” blocks standard collection mutators: `append`, `pop`, `update`, `clear`, etc.; custom mutator names bypass this guard)
- πŸ“ Optionally, docstrings can be updated automatically in RST/Sphinx or MkDocs/Markdown format (auto-detected); ships bundled Griffe and Sphinx doc-engine extensions
- πŸ” Preserves original function signature, annotations and metadata for introspection
Expand Down Expand Up @@ -1021,6 +1022,57 @@ Sent to 'alice@example.com': 'Hello' [normal]
> [!NOTE]
> `args_extra` is merged into kwargs _after_ `args_mapping` is applied, so extra values can override mapped ones. It is used when `target` is a Callable or `TargetMode.ARGS_REMAP` (with `args_mapping`). For `TargetMode.NOTIFY`, it is ignored and a construction-time `UserWarning` is emitted by validation.

### πŸŒ€ Async functions

`@deprecated` works natively on `async def` functions. The resulting wrapper is itself `async def`, so `inspect.iscoroutinefunction(wrapper)` returns `True` and asyncio frameworks (FastAPI, `asyncio.run`, `asyncio.gather`) recognise it. All three `TargetMode` variants work; the deprecation warning fires when the coroutine is awaited, not when the wrapper is called.

```python
import asyncio
from deprecate import TargetMode, deprecated, void


# NEW/FUTURE API
async def download(url: str) -> bytes:
return url.encode()


# 1) target=<callable> β€” forward to a replacement async function
@deprecated(target=download, deprecated_in="0.9", remove_in="1.0")
async def fetch(url: str) -> bytes:
"""Deprecated β€” use download() instead."""
return void(url)


# 2) TargetMode.NOTIFY β€” warn callers; the async body still runs
@deprecated(deprecated_in="0.9", remove_in="1.0")
async def legacy_ping() -> str:
"""Deprecated β€” going away; remove call sites."""
return "pong"


# 3) TargetMode.ARGS_REMAP β€” rename an argument within the same async function
@deprecated(
target=TargetMode.ARGS_REMAP,
args_mapping={"endpoint": "url"},
deprecated_in="0.9",
remove_in="1.0",
)
async def fetch_data(endpoint: str = "", url: str = "") -> bytes:
"""Deprecated argument `endpoint` renamed to `url`."""
return url.encode()


asyncio.run(fetch("https://example.com"))
asyncio.run(legacy_ping())
asyncio.run(fetch_data(endpoint="https://example.com"))
```

> [!WARNING]
> Do not apply `@deprecated` to **async generator functions** (`async def` + `yield`). These are detected at decoration time and currently trigger a `UserWarning`, but they are still unsupported because the produced wrapper is synchronous and is not an async generator function. Full async generator support is planned for a future release.

> [!NOTE]
> `_WrapperState` fields are plain dataclass fields with no asyncio lock β€” concurrent coroutines sharing one deprecated wrapper can race on warning counts. Set `num_warns=-1` to bypass the count gate in tests that assert exact emission counts.

## πŸ”‡ Understanding the `void()` Helper

When using `@deprecated` with a `target` function, the deprecated function's body is never executedβ€”all calls are automatically forwarded. However, your IDE might complain about "unused parameters". The `void()` helper function silences these warnings:
Expand Down
74 changes: 74 additions & 0 deletions docs/guide/use-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,80 @@ def old_range(start: int, stop: int):

Internally, the deprecated wrapper for a generator is a regular (non-generator) function that fires the warning eagerly and then returns the actual generator object. In the current implementation, `_WrapperState.called` is incremented once per external call via the wrapper's normal dispatch path. Warning deduplication still works correctly: warnings fire at most `num_warns` times as configured.

## Async

`@deprecated` works on `async def` functions natively. The wrapper produced is itself `async def`, so `inspect.iscoroutinefunction(wrapper)` returns `True` and callers can `await` it as expected.

All three TargetModes work with async functions. The deprecation warning fires when the coroutine is awaited β€” not when it is created by calling the wrapper β€” because the warning logic runs inside the `async def` body. This differs from sync and generator wrappers where the warning fires eagerly at call time.

**`TargetMode.NOTIFY` β€” warn and keep the async body:**

```python
import asyncio
from deprecate import deprecated


@deprecated(deprecated_in="0.9", remove_in="1.0")
async def fetch_data(url: str) -> bytes:
"""Deprecated β€” no replacement yet; remove call sites."""
return b""


asyncio.run(fetch_data("https://example.com"))
```

**`TargetMode.ARGS_REMAP` β€” rename an argument within the same async function:**

```python
import asyncio
from deprecate import TargetMode, deprecated


@deprecated(
target=TargetMode.ARGS_REMAP,
args_mapping={"endpoint": "url"},
deprecated_in="0.9",
remove_in="1.0",
)
async def fetch_data(endpoint: str = "", url: str = "") -> bytes:
"""Deprecated argument `endpoint` renamed to `url`."""
return url.encode()


asyncio.run(fetch_data(endpoint="https://example.com"))
```

**`target=<callable>` β€” forward to a replacement async function:**

```python
import asyncio
from deprecate import deprecated, void


async def download(url: str) -> bytes:
"""New async API."""
return url.encode()


@deprecated(target=download, deprecated_in="0.9", remove_in="1.0")
async def fetch(url: str) -> bytes:
"""Deprecated β€” use download() instead."""
return void(url)


asyncio.run(fetch("https://example.com"))
```

!!! warning "Concurrent coroutines and warning counts"

`_WrapperState` fields (`called`, `warned_calls`, `warned_args`) are plain dataclass fields β€” there is no asyncio lock protecting them. If multiple coroutines share one deprecated wrapper and run concurrently, they can race on the warning counter: the same wrapper may emit more or fewer warnings than `num_warns` specifies, depending on scheduling.

This is an accepted limitation for v0.9. If exact warning counts matter (for example in tests), either run deprecated coroutines sequentially or set `num_warns=-1` to bypass the gate entirely.

!!! warning "Async generator functions are not supported"

Do not apply `@deprecated` to `async def` functions that contain `yield` (async generator functions). pyDeprecate **does** detect async generators at decoration time and raises a `UserWarning`, but full async-generator wrapper support is still not provided. The generated wrapper remains a regular sync function rather than an async generator wrapper, so `inspect.isasyncgenfunction(...)` on the decorated function will be `False` and calling the result will not behave as expected. Full async generator support is planned for a future release.

## See also

- [Customization](customization.md) β€” redirect deprecation output to a logger or use a custom message template
Expand Down
13 changes: 12 additions & 1 deletion docs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ What is being deprecated?
β”‚ β†’ use @deprecated_class(target=NewCls, ...) β€” NOT @deprecated
β”‚ (classes are callables, but @deprecated is for functions/methods only)
β”‚
└─ A function or method (including generator functions β€” `def gen(): yield`)?
└─ A function or method (including generator functions β€” `def gen(): yield` β€” and async functions β€” `async def fn()`)?
β†’ use @deprecated; then choose target= based on the replacement:
β”œβ”€ Has a replacement function/method?
β”‚ β†’ target=<callable>; leave body empty (pass / void())
Expand All @@ -164,6 +164,9 @@ What is being deprecated?
(ARGS_REMAP outer + NOTIFY inner β€” both layers fire independently)
Note: for generator functions the warning fires eagerly at call time (before first next()),
not on first iteration β€” consistent with regular function behavior.
Note: for async def functions, the wrapper is async def; inspect.iscoroutinefunction(wrapper)
returns True. Warning fires on await, not on call. async generator functions
(async def + yield) are not supported β€” do not apply @deprecated to them.
```

### Enforce removal deadlines in CI
Expand Down Expand Up @@ -207,6 +210,8 @@ pydeprecate all src/
- `@deprecated` works with `@classmethod`, `@staticmethod`, `@property`, and `@cached_property` in either decorator order β€” `@classmethod @deprecated` and `@deprecated @classmethod` both produce `classmethod(deprecated_wrapper)`; `@staticmethod @deprecated` and `@deprecated @staticmethod` both produce `staticmethod(deprecated_wrapper)`. Both fire `FutureWarning` at call time.
- `@property` with `@deprecated` in either order: warning fires on attribute access (`obj.prop` triggers it, not `obj.prop()`).
- `@cached_property` with `@deprecated` in either order: warning fires on **first access only** β€” subsequent accesses hit the cached value and do not emit a warning.
- `@deprecated` on `async def` functions: the wrapper is `async def`; `inspect.iscoroutinefunction(wrapper)` returns `True`. All three TargetModes work. Warning fires when the coroutine is awaited, not when the wrapper is called β€” `coro = wrapper(x=1)` produces no warning; `await coro` fires it.
- `@deprecated` on async generator functions (`async def` + `yield`): detected at decoration time via `inspect.isasyncgenfunction(source)`, and pyDeprecate warns at decoration time rather than pretending they are regular coroutine functions. Async generators remain unsupported β€” see Known Limitations.

## Anti-Patterns

Expand All @@ -216,11 +221,17 @@ pydeprecate all src/
- Do not call the target inside a deprecated function body when `target=<callable>`.
- Do not use raw `warnings.warn` for public API migrations that need forwarding, argument mapping, class proxying, warning frequency control, or CI audit.
- `@deprecated` works with `@classmethod` and `@staticmethod` in either order. pyDeprecate unwraps the underlying function (`.__func__`), applies the deprecation wrapper, and re-wraps the descriptor, so the method remains deprecated regardless of decorator order.
- Do not apply `@deprecated` to async generator functions (`async def` + `yield`) β€” pyDeprecate detects them at decoration time and emits a `UserWarning`, but full async generator support is not implemented and the wrapper will not behave correctly. Full async generator support is planned for a future release.

## Known Limitations

- `_WrapperState.called` is incremented once per deprecated wrapper invocation in the current implementation. Warning deduplication still works correctly β€” warnings fire at most `num_warns` times β€” and the stored call count reflects wrapper invocations rather than a generator-specific double increment.
- `_WrapperState` has no thread or async locks. Concurrent calls to the same deprecated wrapper can race on the `called` counter (pre-existing limitation, identical to regular function wrappers).
- **Async generator functions not yet supported**: `@deprecated` on `async def` functions containing `yield` (async generator functions) is detected at decoration time and triggers a `UserWarning`, but full support is not implemented. Do not use. Full support is planned for a future release.
- **`_WrapperState` concurrency under async**: for `async def` wrappers, `_WrapperState` fields (`called`, `warned_calls`, `warned_args`) are plain dataclass fields with no asyncio lock. Concurrent coroutines sharing one deprecated wrapper can race on warning counts β€” the wrapper may emit fewer warnings than `num_warns` specifies. Set `num_warns=-1` to bypass the count gate if exact emission counts matter in tests.
- **`functools.partial` of `async def` loses coroutine flag on Python 3.9–3.11**: `inspect.iscoroutinefunction(functools.partial(async_fn))` returns `False` on Python ≀3.11 β€” `partial` does not propagate the coroutine flag. Apply `@deprecated` to the `async def` directly; use `partial` on the already-deprecated wrapper if needed. Resolved in Python 3.12+.
- **Sync wrapper over `async def` loses coroutine flag**: a sync decorator applied over a `@deprecated async def` wrapper produces a sync callable β€” `inspect.iscoroutinefunction` returns `False`. Outer decorators must use an `async def` wrapper when wrapping async functions; otherwise frameworks (FastAPI route handlers, asyncio task creation) will reject the result.
- **Callable objects with `async def __call__` not detected**: `inspect.iscoroutinefunction(callable_obj)` returns `False` for callable objects whose `__call__` is `async def` β€” `@deprecated` installs the sync wrapper and `await wrapper(...)` will fail. Workaround: wrap in a plain `async def my_wrapper(*a, **kw): return await callable_obj(*a, **kw)` before applying `@deprecated`.

## Full Context

Expand Down
8 changes: 8 additions & 0 deletions docs/overrides/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,14 @@
"@type": "Answer",
"text": "Yes. pyDeprecate silently rescues the misordered stack: it detects the classmethod descriptor, unwraps it, applies the deprecation wrapper to the underlying function, and re-wraps the result in classmethod. No UserWarning is emitted at decoration time, and a FutureWarning fires normally when the method is called. The preferred order is still @classmethod outermost with @deprecated closer to def, as it is explicit and avoids the silent rescue."
}
},
{
"@type": "Question",
"name": "Why do concurrent async calls to a deprecated wrapper emit fewer warnings than expected?",
"acceptedAnswer": {
"@type": "Answer",
"text": "_WrapperState fields (called, warned_calls, warned_args) are plain Python dataclass fields with no asyncio lock. Concurrent coroutines sharing one deprecated wrapper race on the warning counter, causing fewer warnings than num_warns specifies to be emitted. Workaround: set num_warns=-1 to bypass the count gate entirely β€” the warning then fires unconditionally on every call regardless of scheduling."
}
}
]
}
Expand Down
Loading
Loading