Skip to content

Commit 578355a

Browse files
Bordaclaude
andcommitted
feat(proxy): add attrs_mapping to deprecated_class for selective attribute deprecation
- Add `attrs_mapping: Optional[dict[str, Optional[str]]]` to `deprecated_class()` and `_DeprecatedProxy` - `__getattr__`/`__setattr__`/`__delattr__` warn only for listed attribute names; unlisted attrs pass through silently - Redirect reads/writes/deletes to canonical attribute name when value is non-None; warn-only when value is None - Reuse existing `_warn(arg_name=...)` per-key budget path for per-attribute warning counters - Raise `ValueError` at decoration time for circular mappings (key also appears as a redirect target) - Store `attrs_mapping` in both `DeprecationConfig` (audit visibility) and `_ProxyConfig` (runtime) - Add `TestDeprecatedAttrs` with 10 tests covering read/write/delete redirect, notify-only, per-attr budget, enum, message content, circular validation --- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent f02b225 commit 578355a

5 files changed

Lines changed: 397 additions & 1 deletion

File tree

src/deprecate/_types.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,10 @@ class DeprecationConfig:
266266
templates at warn time. ``None`` (default) keeps the built-in template selected for the active scenario.
267267
Audit tools may surface this for introspection. See :func:`~deprecate.deprecation.deprecated` for the
268268
available placeholders (e.g. ``%(source_name)s``, ``%(target_path)s``, ``%(deprecated_in)s``).
269+
attrs_mapping: Optional mapping of deprecated attribute names to their canonical replacement names (or
270+
``None`` for warn-only). Set by :func:`~deprecate.proxy.deprecated_class` when selective per-attribute
271+
deprecation is enabled. Audit tools surface this for documentation and migration tracking. ``None``
272+
(default) means the proxy uses its blanket-warning behaviour (every attribute access emits a warning).
269273
270274
"""
271275

@@ -278,6 +282,7 @@ class DeprecationConfig:
278282
misconfigured: bool = False
279283
docstring_style: Literal["rst", "mkdocs"] = "rst"
280284
template_mgs: Optional[str] = None
285+
attrs_mapping: Optional[dict[str, Optional[str]]] = None
281286

282287

283288
@runtime_checkable
@@ -334,6 +339,9 @@ class _ProxyConfig:
334339
template_mgs: Optional custom warning-message template (``%``-style placeholders) that overrides the built-in
335340
templates at warn time. ``None`` (default) keeps the built-in templates. See
336341
:func:`~deprecate.proxy.deprecated_class` for the placeholder catalogue.
342+
attrs_mapping: Optional mapping of deprecated attribute names to their canonical replacement names (or
343+
``None`` for warn-only). When set, the proxy emits warnings only on access to listed names and forwards
344+
all other reads/writes/deletes silently. See :func:`~deprecate.proxy.deprecated_class` for full semantics.
337345
warned: Mutable counter tracking how many global (callable-level) warnings have been emitted so far.
338346
warned_args: Per-argument warning counts for argument-level deprecations. Keys are deprecated argument names;
339347
values are emission counts.
@@ -346,6 +354,7 @@ class _ProxyConfig:
346354
read_only: bool
347355
args_extra: Optional[dict[str, Any]] = None
348356
template_mgs: Optional[str] = None
357+
attrs_mapping: Optional[dict[str, Optional[str]]] = None
349358
warned: int = 0
350359
warned_args: dict[str, int] = field(default_factory=dict)
351360

src/deprecate/proxy.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ def __init__(
9191
target: Any = None, # noqa: ANN401
9292
args_mapping: Optional[dict[str, Optional[str]]] = None,
9393
args_extra: Optional[dict[str, Any]] = None,
94+
attrs_mapping: Optional[dict[str, Optional[str]]] = None,
9495
deprecated_in: str = "",
9596
remove_in: str = "",
9697
num_warns: int = 1,
@@ -120,6 +121,18 @@ def __init__(
120121
# Probe ``template_mgs`` against every documented placeholder so typos and malformed
121122
# conversion specifiers fail at decoration time instead of on the first proxy access.
122123
_validate_template_mgs(template_mgs)
124+
# Reject circular ``attrs_mapping`` mappings at decoration time so the misconfiguration
125+
# surfaces before any caller hits the proxy. A key that is also a non-``None`` redirect
126+
# target would cause an infinite forwarding loop when the deprecated name is accessed.
127+
if attrs_mapping is not None:
128+
non_none_values = {v for v in attrs_mapping.values() if v is not None}
129+
overlap = set(attrs_mapping) & non_none_values
130+
if overlap:
131+
raise ValueError(
132+
f"`attrs_mapping` has circular redirects — keys that are also redirect targets:"
133+
f" {sorted(overlap)}."
134+
" Each deprecated name must not appear as a redirect target of another entry."
135+
)
123136
# Track whether the raw ``target=False`` sentinel was passed so audit can flag it. The override
124137
# path lets upstream callers fold their own pre-validated misconfig signals into the same flag.
125138
misconfigured = target is False or _misconfigured_override
@@ -144,6 +157,7 @@ def __init__(
144157
read_only=read_only,
145158
args_extra=args_extra,
146159
template_mgs=template_mgs,
160+
attrs_mapping=attrs_mapping,
147161
)
148162
object.__setattr__(self, "_DeprecatedProxy__config", cfg)
149163
# Static deprecation metadata stored as a dunder attribute — readable by audit tools via __deprecated__.
@@ -157,6 +171,7 @@ def __init__(
157171
misconfigured=misconfigured,
158172
docstring_style=normalize_docstring_style(docstring_style),
159173
template_mgs=template_mgs,
174+
attrs_mapping=attrs_mapping,
160175
)
161176
object.__setattr__(self, "__deprecated__", dep_meta)
162177
# Expose the wrapped object's docstring as an instance attribute so
@@ -235,6 +250,28 @@ def _warn(self, *, arg_name: Optional[str] = None) -> None:
235250
"remove_in": dep.remove_in,
236251
"argument_map": argument_map,
237252
}
253+
elif arg_name is not None and dep.attrs_mapping is not None and arg_name in dep.attrs_mapping:
254+
# Per-attribute warning for ``attrs_mapping``: format the message so callers see the
255+
# deprecated attribute name and (when a non-``None`` redirect is configured) the canonical
256+
# replacement attribute path on the wrapped class.
257+
new_attr = dep.attrs_mapping[arg_name]
258+
if new_attr is not None:
259+
target_path = f"{dep.name}.{new_attr}"
260+
template = custom_template or TEMPLATE_WARNING_CALLABLE
261+
msg = template % {
262+
"source_name": arg_name,
263+
"deprecated_in": dep.deprecated_in,
264+
"remove_in": dep.remove_in,
265+
"target_name": new_attr,
266+
"target_path": target_path,
267+
}
268+
else:
269+
template = custom_template or TEMPLATE_WARNING_NO_TARGET
270+
msg = template % {
271+
"source_name": arg_name,
272+
"deprecated_in": dep.deprecated_in,
273+
"remove_in": dep.remove_in,
274+
}
238275
elif callable(target):
239276
target_name = target.__name__
240277
target_path = f"{target.__module__}.{target_name}"
@@ -336,10 +373,23 @@ def _is_potential_mutator(name: str) -> bool:
336373
def __getattr__(self, name: str) -> Any: # noqa: ANN401
337374
"""Forward attribute lookup to the active object, emitting a deprecation warning.
338375
376+
When ``attrs_mapping`` is configured, only attributes listed in the mapping emit a warning; all other accesses
377+
are forwarded silently without a warning. The redirect target name (value in the mapping) is used for the
378+
actual attribute lookup when the value is a non-``None`` string; ``None`` means warn-only with no rename.
379+
339380
In read-only mode, common mutating methods on built-in collections (for example, ``append`` or ``update``) are
340381
wrapped so that calling them raises :class:`AttributeError` instead of mutating the underlying object.
341382
342383
"""
384+
attrs_mapping = self._cfg.attrs_mapping
385+
if attrs_mapping is not None:
386+
if name in attrs_mapping:
387+
self._warn(arg_name=name)
388+
redirect = attrs_mapping[name]
389+
active = self._get_active()
390+
return getattr(active, redirect if redirect is not None else name)
391+
# Not a deprecated attr — silent passthrough, no warning.
392+
return getattr(self._get_active(), name)
343393
self._warn()
344394
attr = getattr(self._get_active(), name)
345395
# In read-only mode, guard common mutating methods accessed via attribute lookup.
@@ -354,21 +404,39 @@ def _guarded_mutator(*args: Any, **kwargs: Any) -> None: # noqa: ANN401
354404
def __setattr__(self, name: str, value: Any) -> None: # noqa: ANN401
355405
"""Forward attribute mutation to the active object, raising in read-only mode.
356406
407+
When ``attrs_mapping`` is configured and the attribute name is a deprecated alias, emits a warning and
408+
redirects the write to the canonical attribute name.
409+
357410
Raises:
358411
AttributeError: If the proxy is in read-only mode.
359412
360413
"""
361414
self._check_read_only(f"Setting attribute '{name}'")
415+
attrs_mapping = self._cfg.attrs_mapping
416+
if attrs_mapping is not None and name in attrs_mapping:
417+
self._warn(arg_name=name)
418+
redirect = attrs_mapping[name]
419+
setattr(self._get_active(), redirect if redirect is not None else name, value)
420+
return
362421
setattr(self._get_active(), name, value)
363422

364423
def __delattr__(self, name: str) -> None:
365424
"""Forward attribute deletion to the active object, raising in read-only mode.
366425
426+
When ``attrs_mapping`` is configured and the attribute name is a deprecated alias, emits a warning and
427+
redirects the deletion to the canonical attribute name.
428+
367429
Raises:
368430
AttributeError: If the proxy is in read-only mode.
369431
370432
"""
371433
self._check_read_only(f"Deleting attribute '{name}'")
434+
attrs_mapping = self._cfg.attrs_mapping
435+
if attrs_mapping is not None and name in attrs_mapping:
436+
self._warn(arg_name=name)
437+
redirect = attrs_mapping[name]
438+
delattr(self._get_active(), redirect if redirect is not None else name)
439+
return
372440
delattr(self._get_active(), name)
373441

374442
# ------------------------------------------------------------------
@@ -549,6 +617,7 @@ def deprecated_class(
549617
template_mgs: Optional[str] = None,
550618
args_mapping: Optional[dict[str, Optional[str]]] = None,
551619
args_extra: Optional[dict[str, Any]] = None,
620+
attrs_mapping: Optional[dict[str, Optional[str]]] = None,
552621
update_docstring: bool = False,
553622
docstring_style: Literal["auto", "rst", "mkdocs", "markdown"] = "auto",
554623
_misconfigured_override: bool = False,
@@ -592,6 +661,17 @@ def deprecated_class(
592661
been applied. Caller-supplied values override entries with the same key. Ignored when ``target`` is
593662
:attr:`~deprecate._types.TargetMode.NOTIFY` (passing both emits a :class:`UserWarning` at decoration
594663
time; will be :class:`TypeError` in v1.0).
664+
attrs_mapping: Optional dict mapping deprecated attribute names to canonical names (or ``None`` for
665+
warn-only). When set, only the listed attribute names emit a deprecation warning on access; all other
666+
attributes are forwarded silently. The redirect applies to reads (``__getattr__``), writes
667+
(``__setattr__``), and deletes (``__delattr__``). Keys and non-``None`` values must be disjoint to prevent
668+
circular redirects — a circular mapping raises :class:`ValueError` at decoration time.
669+
670+
Example: ``attrs_mapping={"color": "colour", "txt": "text"}`` warns on ``proxy.color`` access and
671+
returns ``proxy.colour``; ``proxy.colour`` is forwarded silently.
672+
673+
When ``attrs_mapping`` is ``None`` (default), the existing behaviour is preserved: a warning is emitted
674+
on every attribute access through the proxy.
595675
update_docstring: If ``True``, inject a deprecation notice into the class docstring at decoration time (same
596676
behaviour as ``@deprecated(update_docstring=True)``).
597677
docstring_style: Output style for the injected notice when ``update_docstring=True``. ``"auto"`` detects the
@@ -628,6 +708,23 @@ def deprecated_class(
628708
>>> LegacyConfig(time_limit=30).timeout # old name — remapped to timeout
629709
30
630710
711+
Selective per-attribute deprecation via ``attrs_mapping``: only the listed attribute
712+
aliases emit a warning; other attribute accesses pass through silently.
713+
714+
>>> @deprecated_class(
715+
... attrs_mapping={"color": "colour"},
716+
... deprecated_in="1.0",
717+
... remove_in="2.0",
718+
... stream=None,
719+
... )
720+
... class Palette:
721+
... colour = "red"
722+
... color = colour # deprecated alias for ``colour``
723+
>>> Palette.colour # canonical name — silent passthrough
724+
'red'
725+
>>> Palette.color # deprecated alias — warns (suppressed by ``stream=None``)
726+
'red'
727+
631728
"""
632729

633730
def decorator(cls: type) -> "_DeprecatedProxy":
@@ -651,6 +748,7 @@ def decorator(cls: type) -> "_DeprecatedProxy":
651748
target=target,
652749
args_mapping=args_mapping,
653750
args_extra=args_extra,
751+
attrs_mapping=attrs_mapping,
654752
docstring_style=docstring_style,
655753
_misconfigured_override=_misconfigured_override,
656754
)

tests/collection_deprecate.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@
5858
NewIntEnum,
5959
SomeTargetClass,
6060
TargetColorEnum,
61+
TargetPalette,
62+
TargetPaletteEnum,
6163
TargetWithInjected,
6264
TimerDecorator,
6365
_DelOnlyPropTarget, # private alias — see H3 fdel-only fixtures below
@@ -1793,3 +1795,48 @@ class DelOnlyDeprecatedPropCls(_DelOnlyPropTarget):
17931795
delete_only: property = deprecated(deprecated_in="1.0", remove_in="2.0")( # type: ignore[assignment]
17941796
property(None, None, del_only_prop_fdel) # type: ignore[arg-type]
17951797
)
1798+
1799+
1800+
# ========== attrs_mapping fixtures (selective per-attribute deprecation) ==========
1801+
# Wrap ``TargetPalette`` and ``TargetPaletteEnum`` to expose deprecated attribute aliases
1802+
# (``color``→``colour``, ``txt``→``text``, etc.). ``stream=None`` is used on most fixtures
1803+
# so reads/writes/deletes do not emit warnings during fixture import and can be controlled
1804+
# per-test via the WithStream variant below.
1805+
1806+
1807+
# Read-redirect: ``color``→``colour`` and ``txt``→``text``. Used by tests that exercise
1808+
# per-attribute warning budgets and silent passthrough of non-listed attribute names.
1809+
DeprecatedAttrsPalette = deprecated_class(
1810+
attrs_mapping={"color": "colour", "txt": "text"},
1811+
deprecated_in="1.0",
1812+
remove_in="2.0",
1813+
stream=None,
1814+
)(TargetPalette)
1815+
1816+
1817+
# Warn-only: ``size`` is registered with redirect target ``None``, so reads warn but the
1818+
# canonical attribute name is unchanged (no rename).
1819+
DeprecatedAttrsNotifyOnly = deprecated_class(
1820+
attrs_mapping={"size": None},
1821+
deprecated_in="1.0",
1822+
remove_in="2.0",
1823+
stream=None,
1824+
)(TargetPalette)
1825+
1826+
1827+
# Enum variant: deprecated alias ``COLOR`` redirects to canonical enum member ``COLOUR``.
1828+
DeprecatedAttrsPaletteEnum = deprecated_class(
1829+
attrs_mapping={"COLOR": "COLOUR"},
1830+
deprecated_in="1.0",
1831+
remove_in="2.0",
1832+
stream=None,
1833+
)(TargetPaletteEnum)
1834+
1835+
1836+
# Stream-enabled variant used by warning-message content tests: this fixture must NOT use
1837+
# ``stream=None`` because the tests assert the FutureWarning text emitted on access.
1838+
DeprecatedAttrsPaletteWithStream = deprecated_class(
1839+
attrs_mapping={"color": "colour"},
1840+
deprecated_in="1.0",
1841+
remove_in="2.0",
1842+
)(TargetPalette)

tests/collection_targets.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,3 +394,30 @@ def del_only_prop_fdel(self: _DelOnlyPropTarget) -> None:
394394
395395
"""
396396
self._value = None
397+
398+
399+
class TargetPalette:
400+
"""Target class with canonical attribute names for ``attrs_mapping`` tests.
401+
402+
Carries both the canonical names (``colour``, ``text``, ``size``) and is wrapped by ``deprecated_class`` fixtures in
403+
:mod:`tests.collection_deprecate` to register deprecated aliases (``color`` → ``colour``, ``txt`` → ``text``). The
404+
canonical attributes are mutable instance-style class attributes so that read/write/delete tests can exercise the
405+
forwarding behaviour without instantiating the class.
406+
407+
"""
408+
409+
colour: str = "red"
410+
text: str = "hello"
411+
size: int = 42
412+
413+
414+
class TargetPaletteEnum(Enum):
415+
"""Enum with canonical member names for the ``attrs_mapping`` enum redirect test.
416+
417+
Wrapped by ``DeprecatedAttrsPaletteEnum`` in :mod:`tests.collection_deprecate` to register a deprecated alias
418+
``COLOR`` → ``COLOUR``. Used to verify that proxy ``__getattr__`` redirect logic survives the enum metaclass.
419+
420+
"""
421+
422+
COLOUR = "red"
423+
TEXT = "hello"

0 commit comments

Comments
 (0)