Skip to content
This repository has been archived by the owner on Jul 3, 2023. It is now read-only.

Commit

Permalink
Adds @deprecated decorator
Browse files Browse the repository at this point in the history
Decided to build my own as I wanted to fully manage the lifecycle of
items. Currently meant for dev use but I guess this could also
be used for hamilton functions/nodes? Interesting...

Some things to think about:

1. This allows us to keep something that's deprecated in the future.
   Unlikely to be useful but gives us some freedom.
2. All versions are standard, no funky versions or rc versions (the rc
   gets stripped)
3. This forces us into contracts. Though we can break them, I like the
   idea of making deprecations a planned event.
  • Loading branch information
elijahbenizzy committed Jul 30, 2022
1 parent 6c46579 commit 76adc9e
Show file tree
Hide file tree
Showing 4 changed files with 243 additions and 1 deletion.
Empty file added hamilton/dev_utils/__init__.py
Empty file.
121 changes: 121 additions & 0 deletions hamilton/dev_utils/deprecation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import dataclasses
import functools
import logging
from typing import Callable, Optional, Union, Tuple

from hamilton import version

logger = logging.getLogger(__name__)


@dataclasses.dataclass
class Version:
major: int
minor: int
patch: int

def __gt__(self, other: 'Version'):
return (self.major, self.minor, self.patch) > (other.major, other.minor, other.patch)

@staticmethod
def current() -> 'Version':
current_version = version.VERSION
if len(current_version) > 3: # This means we have an RC
current_version = current_version[0:3] # Then let's ignore it
return Version(*current_version) # TODO, add some validation

def __repr__(self):
return '.'.join(map(str, [self.major, self.minor, self.patch]))


CURRENT_VERSION = Version.current()


class DeprecationError(Exception):
def raise_(self):
raise self


@dataclasses.dataclass
class deprecated:
"""Deprecation decorator -- use judiciously! For example:
@deprecate(
warn_starting=(1,10,0)
fail_starting=(2,0,0),
use_instead=parameterize_values,
reason='We have redefined the parameterization decorators to consist of `parametrize`, `parametrize_inputs`, and `parametrize_values`
migration_guide="https://github.com/stitchfix/hamilton/..."
)
class parameterized(...):
...
Note this locks into a future contract (although it *can* be changed), so if you promise to deprecate something by X.0, then do it!
"""
warn_starting: Union[Tuple[int, int, int], Version]
fail_starting: Union[Tuple[int, int, int], Version]
use_this: Optional[Callable] # If this is None, it means this functionality is no longer supported.
explanation: str
migration_guide: Optional[str] # If this is None, this means that the use_instead is a drop in replacement
current_version: Union[Tuple[int, int, int], Version] = dataclasses.field(default=CURRENT_VERSION)
warn_action: Callable[[str], None] = dataclasses.field(default=logger.warning)
fail_action: Callable[[str], None] = dataclasses.field(default=lambda message: DeprecationError(message).raise_())

@staticmethod
def _raise_failure(message: str):
raise DeprecationError(message)

@staticmethod
def ensure_version_type(version_spec: Union[Tuple[int, int, int], Version]) -> Version:
if isinstance(version_spec, tuple):
return Version(*version_spec)
return version_spec

def __post_init__(self):
if self.use_this is None:
if self.migration_guide is None:
raise ValueError('@deprecate must include a migration guide if there is no replacement.')
self.warn_starting = deprecated.ensure_version_type(self.warn_starting)
self.fail_starting = deprecated.ensure_version_type(self.fail_starting)
self.current_version = deprecated.ensure_version_type(self.current_version)
self._validate_fail_starting()

def _validate_fail_starting(self):
if self.fail_starting.major > 0: # This means we're past alpha. We are, but nice to have...
if self.fail_starting.minor != 0 or self.fail_starting.patch != 0:
raise ValueError(f'Can only deprecate starting on major version releases. {self.fail_starting} is not valid.')
if self.warn_starting > self.fail_starting:
raise ValueError(f'warn_starting must come before fail_starting. {self.fail_starting} < {self.warn_starting}')

def _do_action(self, fn: Callable):
if self._should_fail():
failure_message = ' '.join(
[
f'{fn.__qualname__} has been deprecated, as of hamilton version: {self.fail_starting}.',
f'{self.explanation}'
] + \
([f'Instead, you should be using: {self.use_this.__qualname__}.'] if self.use_this is not None else []) + \
([f'For migration, see: {self.migration_guide}.'] if self.migration_guide is not None else [f'This is a drop-in replacement.']))
self.fail_action(failure_message)
elif self._should_warn():
warn_message = ' '.join(
[
f'{fn.__qualname__} will be deprecated by of hamilton version: {self.fail_starting}.',
f'{self.explanation}'
] + \
([f'Instead, you should be using: {self.use_this.__qualname__}.'] if self.use_this is not None else []) + \
([f'For migration, see: {self.migration_guide}.'] if self.migration_guide is not None else [f'This is a drop-in replacement.']))
self.warn_action(warn_message)

def _should_warn(self) -> bool:
return self.current_version > self.warn_starting

def _should_fail(self) -> bool:
return self.current_version > self.fail_starting

def __call__(self, fn: Callable):
@functools.wraps(fn)
def replacement(*args, **kwargs):
self._do_action(fn)
return fn(*args, **kwargs)

return replacement
121 changes: 121 additions & 0 deletions tests/test_dev_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import operator

import pytest

from hamilton.dev_utils.deprecation import Version, deprecated, DeprecationError


@pytest.mark.parametrize(
'version_1, version_2, op',
[
(Version(0, 1, 2), Version(0, 1, 2), operator.eq),
(Version(0, 1, 2), Version(0, 1, 3), operator.lt),
(Version(0, 1, 2), Version(0, 1, 1), operator.gt),
(Version(1, 9, 0), Version(2, 0, 0), operator.lt),
(Version(2, 0, 0), Version(1, 9, 0), operator.gt),
]
)
def test_version_compare(version_1, version_2, op):
assert op(version_1, version_2)


@pytest.mark.parametrize(
'kwargs',
[
dict(warn_starting=(0, 0, 0), fail_starting=(0, 0, 1), use_this=None,
explanation='something', migration_guide='https://github.com/stitchfix/hamilton'),
dict(warn_starting=(0, 0, 0), fail_starting=(0, 0, 1), use_this=test_version_compare,
explanation='something', migration_guide=None),
dict(warn_starting=(0, 0, 0), fail_starting=(1, 0, 0), use_this=test_version_compare,
explanation='something', migration_guide=None)
]
)
def test_validate_deprecated_decorator_params_happy(kwargs):
deprecated(**kwargs)


@pytest.mark.parametrize(
'kwargs',
[
dict(warn_starting=(1, 0, 0), fail_starting=(0, 0, 1), use_this=None,
explanation='something', migration_guide='https://github.com/stitchfix/hamilton'),
dict(warn_starting=(0, 0, 0), fail_starting=(0, 0, 1), use_this=None,
explanation='something', migration_guide=None),
dict(warn_starting=(0, 0, 0), fail_starting=(1,0,0), use_this=None,
explanation='something', migration_guide=None)
]
)
def test_validate_deprecated_decorator_params_sad(kwargs):
with pytest.raises(ValueError):
deprecated(**kwargs)


def test_call_function_not_deprecated_yet():
warned = False

def warn(s):
nonlocal warned
warned = True

def replacement_function() -> bool:
return True

@deprecated(
warn_starting=(0, 5, 0),
fail_starting=(1, 0, 0),
use_this=replacement_function,
explanation='True is the new False',
migration_guide='https://github.com/stitchfix/hamilton',
current_version=(0, 0, 0),
warn_action=warn
)
def deprecated_function() -> bool:
return False

deprecated_function()
assert not warned


def test_call_function_soon_to_be_deprecated():
warned = False

def warn(s):
nonlocal warned
warned = True

def replacement_function() -> bool:
return True

@deprecated(
warn_starting=(0, 5, 0),
fail_starting=(1, 0, 0),
use_this=replacement_function,
explanation='True is the new False',
migration_guide='https://github.com/stitchfix/hamilton',
current_version=(0, 6, 0),
warn_action=warn
)
def deprecated_function() -> bool:
return False

deprecated_function()
assert warned


def test_call_function_already_deprecated():
def replacement_function() -> bool:
return True

@deprecated(
warn_starting=(0, 5, 0),
fail_starting=(1, 0, 0),
use_this=replacement_function,
explanation='True is the new False',
migration_guide='https://github.com/stitchfix/hamilton',
current_version=(1, 1, 0),
)
def deprecated_function() -> bool:
return False

with pytest.raises(DeprecationError):
deprecated_function()
2 changes: 1 addition & 1 deletion tests/test_function_modifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -798,5 +798,5 @@ def test_parametrized_full_multiple_replacements():
f'3. Replace the other variants with this, ensure all use-cases are covered \n ✅'
f'4. Refactor to fix weird polymorphism or simplify in another way\n'
f'5. Refactor all decorators to be in a `function_modifiers` module if possible. Then have the __init__.py just copy them\n'
f'6. Add a @deprecate meta-decorator for deprecating decorators\n'
f'6. Add a @deprecate meta-decorator for deprecating decorators\n'
)

0 comments on commit 76adc9e

Please sign in to comment.