"""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(),
)