Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
94334bd
feat(deprecation): wrap fset/fdel in @deprecated property
Borda Jun 4, 2026
c0ab578
feat(deprecation): _DeprecatedProperty subclass for chain-style sette…
Borda Jun 4, 2026
808d107
docs: update property setter/deleter docs — both patterns now work
Borda Jun 4, 2026
d1a932f
Potential fix for pull request finding
Borda Jun 4, 2026
89ca0b6
docs: replace domain-specific KeyPoints example with generic Config.t…
Borda Jun 4, 2026
b6f0a04
fix: resolve two CI blockers on PR #190 — docs IndentationError and t…
Borda Jun 4, 2026
ec214be
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 4, 2026
1e95e41
fix(deprecation): guard against double-decoration on _DeprecatedProperty
Borda Jun 4, 2026
2abd13d
fix(deprecation): guard args_mapping/args_extra/callable target on pr…
Borda Jun 4, 2026
8b53bed
refine(deprecation): fix _wrap_accessor type sig to match Callable[[C…
Borda Jun 4, 2026
9968ae5
refine(deprecation): prefer fnew.__doc__ over self.__doc__ in getter/…
Borda Jun 4, 2026
8e8e339
docs(deprecation): rewrite _DeprecatedProperty docstring to Google style
Borda Jun 4, 2026
7876c58
test(deprecation): assert setter warning filename points at caller site
Borda Jun 4, 2026
7e0ede2
test(deprecation): verify chain-style setter counter silences second …
Borda Jun 4, 2026
fbc78b8
docs(llms.txt): describe property subclass by behaviour, not internal…
Borda Jun 4, 2026
b402609
docs(use-cases): make Config.timeout example self-contained
Borda Jun 4, 2026
e607b4b
Merge branch 'fix/property' of https://github.com/Borda/pyDeprecate i…
Borda Jun 4, 2026
a8ca123
Potential fix for pull request finding
Borda Jun 4, 2026
d2584b3
docs(use-cases): replace HTML phmdoctest comment with inline skip marker
Borda Jun 4, 2026
64ed6c9
fix(audit): discover setter-only and fset-deprecated properties
Borda Jun 4, 2026
7aaf0bc
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 4, 2026
90d6936
docs(use-cases): clarify @deprecated @property outer order wraps fset…
Borda Jun 4, 2026
50582ec
docs(llms): fix decorator order claims for @property fset/fdel wrapping
Borda Jun 4, 2026
00a4a1e
fix(deprecation): derive property name from first non-None accessor i…
Borda Jun 4, 2026
6c0f03b
test(deprecation): add TestPropertyErrorPaths for packing() TypeError…
Borda Jun 4, 2026
dabe8e6
lint: auto-fix violations after resolve cycle
Borda Jun 4, 2026
f5885a9
docs(changelog): add behavior-change entry for fset/fdel wrapping (#190)
Borda Jun 4, 2026
8f9d0f0
docs: fix misleading comments in deprecation.py and use-cases.md
Borda Jun 4, 2026
18b62a2
refine(deprecation): make _DeprecatedProperty._wrap required keyword-…
Borda Jun 4, 2026
7b4cb6f
fix(deprecation): guard against pre-deprecated accessors in property …
Borda Jun 4, 2026
ae1331b
docs: add typing note and getter-only migration tip for property depr…
Borda Jun 4, 2026
92bb9e2
docs: clarify @property vs @cached_property accessor coverage in READ…
Borda Jun 4, 2026
303177f
test(deprecation): add property regression tests for H2, H3, and M6
Borda Jun 4, 2026
0f75a91
docs(troubleshooting): add M8 property-order and double-wrap FAQ entries
Borda Jun 4, 2026
77de2a7
chore: fix mypy type-ignore placement and mdformat whitespace
Borda Jun 4, 2026
2a113ab
fix(deprecation): reject TargetMode.ARGS_REMAP/True when decorating a…
Borda Jun 4, 2026
dbc05c3
refine(deprecation): use _has_deprecation_meta in accessor double-dec…
Borda Jun 4, 2026
17e0a64
docs(use-cases): add dataclass property alias and redirect patterns
Borda Jun 5, 2026
8bd739d
docs(use-cases): add inline tests and output blocks to property examples
Borda Jun 5, 2026
a23eebf
docs: fix output block summary labels and clarify convention in CONTR…
Borda Jun 5, 2026
1bbc77b
docs(use-cases): fix misleading claims and remove duplicate property …
Borda Jun 5, 2026
0a4a873
test(property): extract property tests to dedicated file + add scenar…
Borda Jun 5, 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
12 changes: 11 additions & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ Tests live in `tests/` and follow a **three-layer separation**:
> [!IMPORTANT]
> **README examples must be runnable.** Every Python code block in `README.md` is extracted by `phmdoctest` and executed as a test. Follow these rules when writing README examples:
>
> - Use `print()` for values you want to verify, paired with a `<details><summary>Output: ...</summary>` block immediately after the code block.
> - Use `print()` for values you want to verify, paired with a `<details><summary>Output: <code>expression</code></summary>` block immediately after the code block. The `<summary>` label shows the **expression** being evaluated (e.g. `cfg.timeout`), not the `print()` wrapper.
> - Only import and use `pytest.raises` when an example intentionally raises an exception — this prevents the extracted test from crashing. Do **not** use `pytest.warns`; deprecation warnings are emitted to stderr and do not cause test failures.
> - Do **not** use bare `assert` statements — they crash the test with an unhelpful `AssertionError` if the value changes.
> - Regenerate `test_readme.py` after any README change: `phmdoctest README.md --outfile tests/integration/test_readme.py`
Expand Down Expand Up @@ -486,6 +486,16 @@ def decorated_sum_warn_only(a: int, b: int = 5) -> int:
- **One behavior per test** — each test method should verify one specific aspect.
- **Prefer parametrization for repetitive shapes** — when the setup/assertion flow is the same and only inputs/expected outputs differ, use `pytest.mark.parametrize(...)` to reduce duplication while keeping one behavioral intent per case.
- **Assertions on warnings:** Use `pytest.warns(FutureWarning|DeprecationWarning)` to verify deprecation warnings are emitted correctly.
- **Scenario description in docstrings** — every non-trivial test method must include a prose paragraph after the one-line summary that describes the real-world situation being tested. A one-line summary alone is not sufficient for complex tests.

```python
def test_warns_on_read(self) -> None:
"""FutureWarning fires on property read access.

A user accesses a deprecated property on an existing object and
expects a FutureWarning with the original value still returned.
"""
```

**For bug fixes:**

Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Write a clear explanation linking to both sources, then let maintainers decide o
- Help onboard new contributors
- Ensure deprecation examples are documented in both README and `docs/guide/use-cases.md`
- Keep `docs/troubleshooting.md` and its FAQPage JSON-LD in `docs/overrides/main.html` in sync
- Every `docs/**/*.md` code block containing `print()` must be followed by a `<details><summary>Output: <code>…</code></summary>` block with verified output (see [Docs examples](.github/CONTRIBUTING.md#test-organization))

**Guidelines**:

Expand Down Expand Up @@ -121,6 +122,7 @@ Write a clear explanation linking to both sources, then let maintainers decide o

- **Three-layer separation**: targets in `collection_targets.py`, deprecated wrappers in `collection_deprecate.py`, test logic in `test_*.py`
- **Do not** define targets or `@deprecated` wrappers directly in test files
- **Scenario description required** — every non-trivial test method docstring must include a prose paragraph describing the real-world situation being tested; a one-line summary alone is not sufficient (see [Test Requirements](.github/CONTRIBUTING.md#-tests-and-quality-assurance))
- See [Test Organization](.github/CONTRIBUTING.md#test-organization) for details

### Documentation Site
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@
### 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.

- **Async generator function support for `@deprecated`.** Decorating an `async def` + `yield` function no longer emits a `UserWarning` at decoration time. The wrapper is a sync callable that fires the deprecation warning eagerly at call time and returns the async generator object; callers iterate with `async for`. All three `TargetMode` variants work. Because the wrapper is sync, `inspect.isasyncgenfunction(wrapper)` returns `False` — frameworks that branch on that flag may need a thin async generator passthrough.

- **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))

- **`@deprecated @property` now wraps `fset` and `fdel` with `FutureWarning`.** Applying `@deprecated` on the outside of `@property` (outer order, or explicit `deprecated(...)(property(fget, fset, fdel))`) now wraps all three accessors. Previously, only `fget` emitted a warning; `fset` and `fdel` were silently passed through. Consumers running `filterwarnings=error::FutureWarning` that wrote to or deleted a deprecated property will now see `FutureWarning` errors — use inner-order (`@property @deprecated`) or decorate only `fget` directly if you want a silent setter/deleter. Chain-style rebinding via `@value.setter` / `@value.deleter` is fully supported through the new `_DeprecatedProperty` subclass. ([#190](https://github.com/Borda/pyDeprecate/pull/190))
Comment thread
Borda marked this conversation as resolved.

### Changed

- **CLI: renamed `--skip_errors` to `--exit-zero`** across all four subcommands (`check`, `expiry`, `chains`, `all`). ([#187](https://github.com/Borda/pyDeprecate/pull/187)) **Breaking change for existing scripts** — `--skip_errors` no longer accepted; update calls to `--exit-zero`. The new name matches the established linter convention (ruff, pylint, shellcheck) and accurately describes the behaviour: exit-code override only, no exception suppression.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Another good aspect is not overwhelming users with too many warnings, so per fun
- 🚫 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, **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 `@property` — all three accessors (`fget`, `fset`, `fdel`) fire `FutureWarning`; chain-style `.setter()` / `.deleter()` rebinding preserved via `_DeprecatedProperty` subclass. Also supports `@cached_property` (getter-only — no `fset`/`fdel`).
- 📦 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
191 changes: 172 additions & 19 deletions docs/guide/use-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -976,7 +976,7 @@ Both decorator orders produce `classmethod(deprecated_wrapper)` or `staticmethod

## Properties and cached properties

`@deprecated` works with `@property` and `@cached_property` in either decorator order — the deprecation warning fires correctly at access time regardless of which order the decorators were applied.
`@deprecated` works with `@property` and `@cached_property`. The decorator only adds a `FutureWarning` at access time — it does **not** forward reads or writes to another property. For a getter-only property, either decorator order is valid. To add a warning to all three accessors (`fget`, `fset`, `fdel`) so that read, write, **and** delete each fire `FutureWarning`, place `@deprecated` on the **outside** (`@deprecated @property` order, or explicit `deprecated(...)(property(fget, fset, fdel))`). The inner-first order (`@property @deprecated`) only adds a warning to `fget` — apply `@deprecated` to setter and deleter separately if you also need them to warn.

```python
from functools import cached_property
Expand All @@ -985,48 +985,201 @@ from deprecate import deprecated


class Config:
# @deprecated inside @property — conventional order
@property
@deprecated(deprecated_in="1.0", remove_in="2.0")
def timeout(self) -> int:
return 30

# @deprecated outside @property — also works
@deprecated(deprecated_in="1.0", remove_in="2.0")
@property
def retries(self) -> int:
return 3

# @deprecated inside @cached_property — conventional order
@cached_property
@deprecated(deprecated_in="1.0", remove_in="2.0")
def base_url(self) -> str:
return "https://example.com"

# @deprecated outside @cached_property — also works

print(Config().timeout)
```

<details>
<summary>Output: <code>Config().timeout</code></summary>

```
30
```

</details>

The `FutureWarning` fires on **attribute access** (`obj.timeout`), not on a call. For `@cached_property`, the warning fires on **first access only** — subsequent accesses return the cached value without emitting another warning.

!!! tip "Decorator order for getter-only properties"

Either `@property @deprecated` (inner) or `@deprecated @property` (outer) order works for getter-only properties. Inner order is conventional — the deprecated decorator is closer to the `def`. For properties with a setter or deleter, use outer order; see the next section.

### Deprecating a property with a setter or deleter

When the property being deprecated has a setter or deleter, all three accessors (`fget`, `fset`, `fdel`) are wrapped automatically — each fires a `FutureWarning`. Both the chain-style decorator pattern and the explicit construction pattern work:

```python
from deprecate import deprecated


class Config:
def __init__(self) -> None:
self._timeout: int = 30

# Outer order required: @deprecated @property wraps fget, fset, and fdel
@deprecated(deprecated_in="1.0", remove_in="2.0")
@cached_property
def legacy_url(self) -> str:
return "https://old.example.com"
@property
def timeout(self) -> int:
return self._timeout

@timeout.setter
def timeout(self, value: int) -> None:
self._timeout = value

@timeout.deleter
def timeout(self) -> None:
del self._timeout

print(Config().legacy_url)

cfg = Config()
cfg.timeout = 10 # FutureWarning: write
print(cfg.timeout) # FutureWarning: read; prints 10
del cfg.timeout # FutureWarning: delete
```

<details>
<summary>Output: <code>Config().legacy_url</code></summary>
<summary>Output: <code>cfg.timeout</code></summary>

```
https://old.example.com
10
```

</details>

The `FutureWarning` fires on **attribute access** (`obj.timeout`), not on a call. For `@cached_property`, the warning fires on **first access only** — subsequent accesses return the cached value without emitting another warning.
`obj.timeout` fires `FutureWarning` on **read**, `obj.timeout = value` fires on **write**, and `del obj.timeout` fires on **delete**.

!!! tip "Want only the getter to warn?"

If you want the setter or deleter to remain silent, apply `@deprecated` directly to `fget` using inner order (`@property @deprecated`) instead of wrapping the full `property` object.

!!! tip "Testing each accessor independently"

Each accessor (`fget`, `fset`, `fdel`) has its own warning counter — assert read, write, and delete warnings in separate `pytest.warns` blocks, or use `num_warns=-1` to disable per-accessor deduplication.

The explicit `property(fget, fset[, fdel])` construction also works:

```python
from deprecate import deprecated


def _timeout_fget(self) -> int:
return self._timeout


def _timeout_fset(self, value: int) -> None:
self._timeout = value


def _timeout_fdel(self) -> None:
del self._timeout


class Config:
def __init__(self) -> None:
self._timeout: int = 30

timeout = deprecated(deprecated_in="1.0", remove_in="2.0")(property(_timeout_fget, _timeout_fset, _timeout_fdel))
```

!!! note "Audit discoverability with explicit construction"

`find_deprecation_wrappers` discovers explicit-construction properties via the accessor that carries `__deprecated__` metadata. For setter-only properties (`property(None, fset)`), it discovers via `fset`; if `fget` is plain (not deprecated), it falls through to `fset` or `fdel`.

### Deprecated property alias on a dataclass

When a dataclass field is renamed, define a property with the old name that delegates to the new field in its accessor body. `@deprecated` adds a `FutureWarning` to each accessor — the delegation itself is plain Python in the method body, not something the library provides.

**Read-only alias (warns on read only):**

```python
from dataclasses import dataclass

from deprecate import deprecated
Comment thread
Borda marked this conversation as resolved.


@dataclass
class Config:
timeout_ms: int = 30_000 # renamed from `timeout`

@property
@deprecated(deprecated_in="1.0", remove_in="2.0")
def timeout(self) -> int:
"""Deprecated — use ``timeout_ms`` instead."""
return self.timeout_ms // 1000


cfg = Config(timeout_ms=5_000)
print(cfg.timeout) # FutureWarning fired; prints 5
```

<details>
<summary>Output: <code>cfg.timeout</code></summary>

```
5
```

</details>

**Read-write alias (warns on read and write):** use the outer order and chain `.setter`:

```python
from dataclasses import dataclass

from deprecate import deprecated


@dataclass
class Config:
timeout_ms: int = 30_000 # renamed from `timeout`

@deprecated(deprecated_in="1.0", remove_in="2.0")
@property
def timeout(self) -> int:
return self.timeout_ms // 1000

@timeout.setter
def timeout(self, value: int) -> None:
self.timeout_ms = value * 1000


cfg = Config(timeout_ms=5_000)
print(cfg.timeout) # FutureWarning fired; prints 5
cfg.timeout = 10 # FutureWarning fired; sets timeout_ms = 10_000
print(cfg.timeout_ms) # prints 10_000
```

<details>
<summary>Output: <code>cfg.timeout; cfg.timeout_ms</code></summary>

```
5
10000
```

</details>

`cfg.timeout` fires `FutureWarning` (from `@deprecated`) and the getter body returns `cfg.timeout_ms // 1000`. `cfg.timeout = 5` fires `FutureWarning` and the setter body assigns `cfg.timeout_ms = 5000`.

!!! warning "Do not shadow a dataclass field"

Do **not** use the same name as an existing dataclass field for the deprecated property. The `@dataclass`-generated `__init__` performs `self.field = value`, which conflicts with a property descriptor of the same name. Use a different name for the deprecated alias and keep the dataclass field under its new name.

The same pattern works on regular (non-dataclass) classes — replace field access with `self._attr` lookups in the accessor body. `@deprecated` only adds the warning in either case.

!!! tip "Prefer `@property @deprecated` (deprecated closer to `def`)"
!!! note "`target=<callable>` not supported on properties"

The inner-first order is the conventional Python style. Follow this pattern for consistency if your team has no existing convention.
`@deprecated` rejects `target=<callable>` on a `property` with `TypeError`. Properties have three independent accessors (`fget`, `fset`, `fdel`); there is no single callable to forward to. Delegate in each accessor body as shown above.

## Deprecating generator functions

Expand Down
Loading
Loading