Skip to content

Commit 257acd4

Browse files
BordaclaudeCopilotpre-commit-ci[bot]
committed
feat: wrap fset/fdel in deprecated property (#190)
- Added deprecation support for property setters and deleters (`fset`/`fdel`) in addition to getters - Introduced a property subclass that preserves deprecation wrapping across `.setter()` and `.deleter()` chains - Enabled chain-style `@deprecated @property` patterns to warn on read, write, and delete operations - Added decoration-time guards against double-decorating deprecated properties - Added decoration-time guards rejecting unsupported property targets (`ARGS_REMAP`, `True`, callable targets, `args_extra`) - Added guards against wrapping already-deprecated property accessors - Fixed audit scanning to discover setter-only, deleter-only, and setter-deprecated properties - Improved property warning stacklevel attribution and per-accessor warning-budget behavior - Added independent warning handling for getter, setter, and deleter accessors - Added support and audit coverage for setter-only and deleter-only properties - Added regression coverage for inner-order vs outer-order decorator behavior - Improved property error messages and property-name resolution in TypeErrors - Refined property wrapping internals, type signatures, and docstring propagation behavior - Added extensive property runtime, audit, and error-path test coverage - Extracted property tests into a dedicated `test_property.py` suite - Expanded documentation for property deprecation patterns, decorator ordering, dataclass aliases, and migration guidance - Added troubleshooting guidance for property-order pitfalls and double-wrap errors - Clarified `@property` vs `@cached_property` accessor coverage - Added documented patterns for deprecated property aliases and manual delegation - Updated audit, README, changelog, LLM guidance, and use-case examples to reflect full property support - Fixed documentation examples, doctest execution issues, typing/lint problems, and review follow-ups --------- Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 61047a5 commit 257acd4

15 files changed

Lines changed: 1388 additions & 230 deletions

.github/CONTRIBUTING.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ Tests live in `tests/` and follow a **three-layer separation**:
374374
> [!IMPORTANT]
375375
> **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:
376376
>
377-
> - Use `print()` for values you want to verify, paired with a `<details><summary>Output: ...</summary>` block immediately after the code block.
377+
> - 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.
378378
> - 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.
379379
> - Do **not** use bare `assert` statements — they crash the test with an unhelpful `AssertionError` if the value changes.
380380
> - Regenerate `test_readme.py` after any README change: `phmdoctest README.md --outfile tests/integration/test_readme.py`
@@ -486,6 +486,16 @@ def decorated_sum_warn_only(a: int, b: int = 5) -> int:
486486
- **One behavior per test** — each test method should verify one specific aspect.
487487
- **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.
488488
- **Assertions on warnings:** Use `pytest.warns(FutureWarning|DeprecationWarning)` to verify deprecation warnings are emitted correctly.
489+
- **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.
490+
491+
```python
492+
def test_warns_on_read(self) -> None:
493+
"""FutureWarning fires on property read access.
494+
495+
A user accesses a deprecated property on an existing object and
496+
expects a FutureWarning with the original value still returned.
497+
"""
498+
```
489499

490500
**For bug fixes:**
491501

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Write a clear explanation linking to both sources, then let maintainers decide o
7575
- Help onboard new contributors
7676
- Ensure deprecation examples are documented in both README and `docs/guide/use-cases.md`
7777
- Keep `docs/troubleshooting.md` and its FAQPage JSON-LD in `docs/overrides/main.html` in sync
78+
- 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))
7879

7980
**Guidelines**:
8081

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

122123
- **Three-layer separation**: targets in `collection_targets.py`, deprecated wrappers in `collection_deprecate.py`, test logic in `test_*.py`
123124
- **Do not** define targets or `@deprecated` wrappers directly in test files
125+
- **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))
124126
- See [Test Organization](.github/CONTRIBUTING.md#test-organization) for details
125127

126128
### Documentation Site

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Added
66

7+
- **`@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))
8+
79
### Changed
810

911
### Deprecated

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Another good aspect is not overwhelming users with too many warnings, so per fun
7171
- 🚫 The deprecated function body is never executed when using `target`
7272
- ⚡ Minimal runtime overhead with zero dependencies (Python standard library only)
7373
- 🛠️ 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)
74+
- 🏠 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`).
7475
- 📦 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)
7576
- 📝 Optionally, docstrings can be updated automatically in RST/Sphinx or MkDocs/Markdown format (auto-detected); ships bundled Griffe and Sphinx doc-engine extensions
7677
- 🔍 Preserves original function signature, annotations and metadata for introspection

docs/guide/use-cases.md

Lines changed: 172 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -976,7 +976,7 @@ Both decorator orders produce `classmethod(deprecated_wrapper)` or `staticmethod
976976

977977
## Properties and cached properties
978978

979-
`@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.
979+
`@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.
980980

981981
```python
982982
from functools import cached_property
@@ -985,48 +985,201 @@ from deprecate import deprecated
985985

986986

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

994-
# @deprecated outside @property — also works
995-
@deprecated(deprecated_in="1.0", remove_in="2.0")
996-
@property
997-
def retries(self) -> int:
998-
return 3
999-
1000-
# @deprecated inside @cached_property — conventional order
1001993
@cached_property
1002994
@deprecated(deprecated_in="1.0", remove_in="2.0")
1003995
def base_url(self) -> str:
1004996
return "https://example.com"
1005997

1006-
# @deprecated outside @cached_property — also works
998+
999+
print(Config().timeout)
1000+
```
1001+
1002+
<details>
1003+
<summary>Output: <code>Config().timeout</code></summary>
1004+
1005+
```
1006+
30
1007+
```
1008+
1009+
</details>
1010+
1011+
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.
1012+
1013+
!!! tip "Decorator order for getter-only properties"
1014+
1015+
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.
1016+
1017+
### Deprecating a property with a setter or deleter
1018+
1019+
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:
1020+
1021+
```python
1022+
from deprecate import deprecated
1023+
1024+
1025+
class Config:
1026+
def __init__(self) -> None:
1027+
self._timeout: int = 30
1028+
1029+
# Outer order required: @deprecated @property wraps fget, fset, and fdel
10071030
@deprecated(deprecated_in="1.0", remove_in="2.0")
1008-
@cached_property
1009-
def legacy_url(self) -> str:
1010-
return "https://old.example.com"
1031+
@property
1032+
def timeout(self) -> int:
1033+
return self._timeout
1034+
1035+
@timeout.setter
1036+
def timeout(self, value: int) -> None:
1037+
self._timeout = value
10111038

1039+
@timeout.deleter
1040+
def timeout(self) -> None:
1041+
del self._timeout
10121042

1013-
print(Config().legacy_url)
1043+
1044+
cfg = Config()
1045+
cfg.timeout = 10 # FutureWarning: write
1046+
print(cfg.timeout) # FutureWarning: read; prints 10
1047+
del cfg.timeout # FutureWarning: delete
10141048
```
10151049

10161050
<details>
1017-
<summary>Output: <code>Config().legacy_url</code></summary>
1051+
<summary>Output: <code>cfg.timeout</code></summary>
10181052

10191053
```
1020-
https://old.example.com
1054+
10
10211055
```
10221056

10231057
</details>
10241058

1025-
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.
1059+
`obj.timeout` fires `FutureWarning` on **read**, `obj.timeout = value` fires on **write**, and `del obj.timeout` fires on **delete**.
1060+
1061+
!!! tip "Want only the getter to warn?"
1062+
1063+
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.
1064+
1065+
!!! tip "Testing each accessor independently"
1066+
1067+
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.
1068+
1069+
The explicit `property(fget, fset[, fdel])` construction also works:
1070+
1071+
```python
1072+
from deprecate import deprecated
1073+
1074+
1075+
def _timeout_fget(self) -> int:
1076+
return self._timeout
1077+
1078+
1079+
def _timeout_fset(self, value: int) -> None:
1080+
self._timeout = value
1081+
1082+
1083+
def _timeout_fdel(self) -> None:
1084+
del self._timeout
1085+
1086+
1087+
class Config:
1088+
def __init__(self) -> None:
1089+
self._timeout: int = 30
1090+
1091+
timeout = deprecated(deprecated_in="1.0", remove_in="2.0")(property(_timeout_fget, _timeout_fset, _timeout_fdel))
1092+
```
1093+
1094+
!!! note "Audit discoverability with explicit construction"
1095+
1096+
`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`.
1097+
1098+
### Deprecated property alias on a dataclass
1099+
1100+
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.
1101+
1102+
**Read-only alias (warns on read only):**
1103+
1104+
```python
1105+
from dataclasses import dataclass
1106+
1107+
from deprecate import deprecated
1108+
1109+
1110+
@dataclass
1111+
class Config:
1112+
timeout_ms: int = 30_000 # renamed from `timeout`
1113+
1114+
@property
1115+
@deprecated(deprecated_in="1.0", remove_in="2.0")
1116+
def timeout(self) -> int:
1117+
"""Deprecated — use ``timeout_ms`` instead."""
1118+
return self.timeout_ms // 1000
1119+
1120+
1121+
cfg = Config(timeout_ms=5_000)
1122+
print(cfg.timeout) # FutureWarning fired; prints 5
1123+
```
1124+
1125+
<details>
1126+
<summary>Output: <code>cfg.timeout</code></summary>
1127+
1128+
```
1129+
5
1130+
```
1131+
1132+
</details>
1133+
1134+
**Read-write alias (warns on read and write):** use the outer order and chain `.setter`:
1135+
1136+
```python
1137+
from dataclasses import dataclass
1138+
1139+
from deprecate import deprecated
1140+
1141+
1142+
@dataclass
1143+
class Config:
1144+
timeout_ms: int = 30_000 # renamed from `timeout`
1145+
1146+
@deprecated(deprecated_in="1.0", remove_in="2.0")
1147+
@property
1148+
def timeout(self) -> int:
1149+
return self.timeout_ms // 1000
1150+
1151+
@timeout.setter
1152+
def timeout(self, value: int) -> None:
1153+
self.timeout_ms = value * 1000
1154+
1155+
1156+
cfg = Config(timeout_ms=5_000)
1157+
print(cfg.timeout) # FutureWarning fired; prints 5
1158+
cfg.timeout = 10 # FutureWarning fired; sets timeout_ms = 10_000
1159+
print(cfg.timeout_ms) # prints 10_000
1160+
```
1161+
1162+
<details>
1163+
<summary>Output: <code>cfg.timeout; cfg.timeout_ms</code></summary>
1164+
1165+
```
1166+
5
1167+
10000
1168+
```
1169+
1170+
</details>
1171+
1172+
`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`.
1173+
1174+
!!! warning "Do not shadow a dataclass field"
1175+
1176+
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.
1177+
1178+
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.
10261179

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

1029-
The inner-first order is the conventional Python style. Follow this pattern for consistency if your team has no existing convention.
1182+
`@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.
10301183

10311184
## Deprecating generator functions
10321185

0 commit comments

Comments
 (0)