@@ -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 )
0 commit comments