Source code for ezmsg.baseproc.util.deprecation

"""Deprecation of the per-processor ``axis`` setting.

A processor that carries state *between* messages -- filter initial conditions,
a running mean, a sample buffer, a previous-sample cache -- can only do so along
the dimension messages accumulate along. Carrying it along a static axis is not
a smaller error but a different operation: that axis has the same length every
message, so the carried state applies message N's tail to message N+1's head at
the same coordinate, forever.

Which dimension that is belongs to the producer, and
:attr:`~ezmsg.util.messages.axisarray.AxisArray.stream_dim` is where it says so.
A setting that lets a consumer disagree can only be used to be wrong, so it is
going away; see :func:`~ezmsg.baseproc.util.streamdim.resolve_stream_dim`.

During the deprecation window the setting is still honoured, so nothing changes
behaviour until it is removed. Two warnings partition the call sites:

* This module's construction-time :class:`FutureWarning` fires for *every* use,
  including a harmless ``axis="time"`` on a raw stream. It means "delete this".
* :func:`~ezmsg.baseproc.util.streamdim.resolve_configured_stream_dim`'s runtime
  warning fires only when the configured axis disagrees with a *declared*
  ``stream_dim``. It means "deleting this will change what this stage computes".

To find every remaining call site in a pipeline, run its tests with
``-W error::FutureWarning``.
"""

import sys
import typing
import warnings
from contextlib import contextmanager
from contextvars import ContextVar

__all__ = [
    "DEFAULT_REMOVAL",
    "suppress_axis_deprecation",
    "warn_axis_deprecated",
]

DEFAULT_REMOVAL = "a future release"
"""Used when a caller names no removal version. Naming one is much better: it is
the difference between "this will break someday" and something a user can plan
against."""

_suppressed: ContextVar[bool] = ContextVar("_axis_deprecation_suppressed", default=False)


[docs] @contextmanager def suppress_axis_deprecation() -> typing.Iterator[None]: """Silence the construction-time warning while forwarding a setting internally. A stage that builds a child processor from its own already-warned settings (a scaler and its two child EWMAs, a decimator and its anti-alias filter) would otherwise warn a second time for one user-visible setting -- and, since some of that forwarding happens in ``_reset_state``, would warn mid-stream pointing at whatever is driving the pipeline rather than at any call site. Deleted along with the settings themselves. """ token = _suppressed.set(True) try: yield finally: _suppressed.reset(token)
def _user_stacklevel() -> int: """``stacklevel`` that makes a ``warn()`` in this module's caller point at the first frame outside ezmsg. A fixed level cannot work: the depth differs between ``SomeSettings(axis=...)`` (the dataclass ``__init__``) and ``SomeTransformer(axis=...)`` (through ``_unify_settings``), and functional factories build the settings object inside the library, so a fixed level would blame the library for a call the user made. """ # Frames are identified by module rather than by filename. The dataclass # __init__ every one of these settings objects is constructed through is # generated by exec, so its co_filename is "<string>" -- indistinguishable # from a user running `python -c`. Its globals, though, usually name the # defining module, so the walk continues correctly in both cases. # # "Usually", because `dataclasses` builds that __init__ with globals taken # from `sys.modules[cls.__module__]`, and a class whose module is not in # sys.modules (defined by exec, or during a partially initialised import) # leaves it with no __name__ at all. A frame with no module identity is # synthetic, never the user's code, so it keeps the walk going -- treating # unknown as "user" would blame a frame with no source line to show. # # 0 is this function, 1 is warn_axis_deprecated -- the frame a stacklevel of # 1 would name. Walk out from there until we leave the package. frame: typing.Any = sys._getframe(1) level = 1 while frame is not None: module = frame.f_globals.get("__name__") if module is not None and module != "ezmsg" and not module.startswith("ezmsg."): return level if frame.f_back is None: # Never left the package (a settings object built at import time, or # from a bare exec). Blaming the outermost frame beats pointing the # user at `sys:1`. return level frame = frame.f_back level += 1 return level
[docs] def warn_axis_deprecated( settings: typing.Any, field: str = "axis", *, package: str = "this package", removal: str = DEFAULT_REMOVAL, ) -> None: """Warn that *settings*' ``field`` is deprecated, if it was actually set. Call from a ``__post_init__``: ``ez.Settings`` classes are frozen dataclasses and every construction path -- the settings class, the transformer, the unit, and the functional factory -- funnels through their ``__init__``, so one hook covers all four. :param package: Distribution name to quote, since each releases separately. :param removal: Version that drops the setting. """ if getattr(settings, field, None) is None or _suppressed.get(): return warnings.warn( f"{type(settings).__name__}.{field} is deprecated and will be removed in " f"{package} {removal}. This processor carries state between messages, which " f"is only meaningful along the dimension they accumulate along; that " f"dimension now comes from AxisArray.stream_dim. Drop the setting. " f"If stream_dim is wrong, fix it at the producer.", FutureWarning, stacklevel=_user_stacklevel(), )