Skip to content

Commit

Permalink
Experiment with new deprection api
Browse files Browse the repository at this point in the history
  • Loading branch information
Erotemic committed Aug 3, 2024
1 parent 95c80fd commit e513961
Showing 1 changed file with 314 additions and 0 deletions.
314 changes: 314 additions & 0 deletions dev/experimental/better_deprecation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
"""
The ubelt.schedule_deprecation function is pretty useful, but it could be
generalized and would probably work better as a class.
As a design / UX goal we need to ensure:
1. There is a concise way of getting minimal behavior where raise a
deprecation warning when we go into a deprecated codepath.
2. There is a way of controlling details in a readable way that is natural,
expressive, but not burdensome.
Such a basic API might look like:
.. code:: python
# Hacking
import ubelt as ub
import sys, os
experiment_dpath = ub.Path('~/code/ubelt/dev/experimental').expand()
sys.path.append(os.fspath(experiment_dpath))
from better_deprecation import * # NOQA
Deprecation.schedule(
'''
This is marking a feature that is deprecated and the first positional
argument gives the user nearly complete control over the message.
By default the warning emits now.
Perhaps some extra context is added by trying to introspect which
module you are currently in.
''')
And perhaps the expressive API looks like
.. code:: python
# Hacking
import ubelt as ub
import sys, os
experiment_dpath = ub.Path('~/code/ubelt/dev/experimental').expand()
sys.path.append(os.fspath(experiment_dpath))
from better_deprecation import * # NOQA
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info('ensure this prints for the test')
import rich
self = Deprecation(
warn='1.1.0',
error='1.2.0',
remove='1.3.0',
message='The foobar is deprecated.',
migration=ub.paragraph(
'''
This text should explain the way to migrate to non-deprecated behavior.
'''),
warncls=DeprecationWarning,
logger=logger,
print=rich.print,
)
print(f'self.__dict__ = {ub.urepr(self.__dict__, nl=1)}')
def foo():
self.emit()
foo()
We should also have a decorator API:
.. code:: python
# Hacking
import ubelt as ub
import sys, os
experiment_dpath = ub.Path('~/code/ubelt/dev/experimental').expand()
sys.path.append(os.fspath(experiment_dpath))
@Deprecation('', module_name='ubelt', print=True)
def old_function():
...
old_function()
What should the class be called?
* Deprecation?
* Deprecator?
* Deprecate?
"""


class Deprecation:
"""
"""

def __init__(
self,
message=None,
name=None,
type=None,
migration=None,
deprecate='now',
warn='soon',
error='soon',
remove='soon',
module_name=None,
module_version=None,
logger=None,
print=None,
warncls=DeprecationWarning,
):
self.message = message
self.name = name
self.type = type
self.migration = migration
self.deprecate = deprecate
self.warn = warn
self.error = error
self.remove = remove
self.module_name = module_name
self.module_version = module_version
self.warncls = warncls

self.logger = logger

if print is True:
import builtins
print = builtins.print

self.print = print
self.loud = False

self._modname_str = None
self._current_module_version = None
self._deprecate_now = None
self._remove_now = None
self._error_now = None
self._deprecate_str = None
self._remove_str = None
self._error_str = None
self._full_message = None

@classmethod
def schedule(
cls,
message=None,
name=None,
type=None,
migration=None,
deprecate='now',
error='soon',
remove='soon',
module_name=None,
module_version=None,
warncls=DeprecationWarning,
stacklevel=1,
):
"""
Concise classmethod to construct and emit the deprecation warning.
"""
self = cls(
message=message,
name=name,
type=type,
migration=migration,
deprecate=deprecate,
error=error,
remove=remove,
module_name=module_name,
module_version=module_version,
warncls=warncls,
)
self.emit(stacklevel=1 + stacklevel)
return self

def _resolve_module_version(self):
import sys
from packaging.version import parse as Version

if self.module_name is not None:
module = sys.modules[self.module_name]
self._current_module_version = Version(module.__version__)
else:
# TODO: use the inspect module to get the function / module this was
# called from and fill in unspecified values.
self._current_module_version = 'unknown'

if self.module_name is None:
self._modname_str = ''
else:
self._modname_str = f'{self.module_name} '

def _handle_when(self, when, default):
from packaging.version import parse as Version

if when is None:
is_now = default
when_str = ''
elif isinstance(when, str):
if when in {'soon', 'now'}:
when_str = ' {}{}'.format(self._modname_str, when)
is_now = when == 'now'
else:
when = Version(when)
when_str = ' in {}{}'.format(self._modname_str, when)
if self._current_module_version == 'unknown':
is_now = default
else:
is_now = self._current_module_version >= when
else:
is_now = bool(when)
when_str = ''
return is_now, when_str

def _resolve_timeline(self):
self._deprecate_now, self._deprecate_str = self._handle_when(
self.deprecate, default=True
)
self._remove_now, self._remove_str = self._handle_when(
self.remove, default=False
)
self._error_now, self._error_str = self._handle_when(self.error, default=False)

def _build_full_message(self):
self._resolve_module_version()
self._resolve_timeline()

parts = []
if self.message:
parts.append(self.message)

if self.name is not None:
_name = self.name or ""
_type = self.type or ""
what_str = f'The "{_name}" {_type}'
else:
what_str = 'This'

parts.append(
f'{what_str} was deprecated{self._deprecate_str}, will cause '
f'an error{self._error_str} and will be removed{self._remove_str}. '
)
parts.append(
f'The current {self._modname_str}version is {self._current_module_version}. '
)
if self.migration:
parts.append(self.migration)

# TODO: make the message more customizable.
self._full_message = ' '.join(parts).strip()

def emit(self, stacklevel=1):
"""
Emit the deprecation message via the requested channels.
"""
import warnings

self._build_full_message()

if self._remove_now:
error_message = (
'Forgot to remove deprecated: '
+ self._full_message
+ ' '
+ 'Remove the function, or extend the scheduled remove version.'
)
if self.logger is not None:
self.logger.error(error_message, stacklevel=1 + stacklevel)
if self.print:
self.print(error_message)
raise AssertionError(error_message)
if self._error_now:
if self.logger is not None:
self.logger.error(self._full_message, stacklevel=1 + stacklevel)
if self.print:
self.print(self._full_message)
raise RuntimeError(self._full_message)

if self._deprecate_now:
if self.logger is not None:
self.logger.warn(self._full_message, stacklevel=1 + stacklevel)
if self.print:
self.print(self._full_message)
warnings.warn(self._full_message, self.warncls, stacklevel=1 + stacklevel)
return self

def decorator(self, func):
import functools

if self.name is None:
self.name = func.__name__

if self.type is None:
self.type = type(func).__name__

@functools.wraps(func)
def _deprecated_func(*args, **kwargs):
self.emit()
result = func(*args, **kwargs)
return result

_deprecated_func._deprecation = self
return _deprecated_func

def __call__(self, func):
return self.decorator(func)

0 comments on commit e513961

Please sign in to comment.