diff --git a/docs/source/utils.rst b/docs/source/utils.rst index 855954fd29..321e6acdfc 100644 --- a/docs/source/utils.rst +++ b/docs/source/utils.rst @@ -32,3 +32,8 @@ Profiling --------- .. automodule:: monai.utils.profiling :members: + +Deprecated +---------- +.. automodule:: monai.utils.deprecated + :members: diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 324f6aa7d4..7026a2ac4d 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -12,6 +12,7 @@ # have to explicitly bring these in here to resolve circular import issues from .aliases import alias, resolve_name from .decorators import MethodReplacer, RestartGenerator +from .deprecated import DeprecatedError, deprecated, deprecated_arg from .enums import ( Activation, Average, diff --git a/monai/utils/deprecated.py b/monai/utils/deprecated.py new file mode 100644 index 0000000000..d7fc3bba2b --- /dev/null +++ b/monai/utils/deprecated.py @@ -0,0 +1,181 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import inspect +import warnings +from functools import wraps +from threading import Lock +from types import FunctionType +from typing import Optional + +from .. import __version__ + +__all__ = ["deprecated", "deprecated_arg", "DeprecatedError"] + +warned_set = set() +warned_lock = Lock() + + +class DeprecatedError(Exception): + pass + + +def warn_deprecated(obj, msg): + """ + Issue the warning message `msg` only once per process for the given object `obj`. When this function is called + and `obj` is not in `warned_set`, it is added and the warning issued, if it's already present nothing happens. + """ + if obj not in warned_set: # ensure warning is issued only once per process + warned_set.add(obj) + warnings.warn(msg, category=DeprecationWarning, stacklevel=2) + + +def version_leq(lhs, rhs): + """Returns True if version `lhs` is earlier or equal to `rhs`.""" + + def _try_cast(val): + val = val.strip() + try: + return int(val) + except ValueError: + return val + + # remove git version suffixes if present + lhs = lhs.split("+", 1)[0] + rhs = rhs.split("+", 1)[0] + + # parse the version strings in this basic way to avoid needing the `packaging` package + lhs = map(_try_cast, lhs.split(".")) + rhs = map(_try_cast, rhs.split(".")) + + for l, r in zip(lhs, rhs): + if l != r: + return l < r + + return True + + +def deprecated( + since: Optional[str] = None, removed: Optional[str] = None, msg_suffix: str = "", version_val=__version__ +): + """ + Marks a function or class as deprecated. If `since` is given this should be a version at or earlier than the + current version and states at what version of the definition was marked as deprecated. If `removed` is given + this can be any version and marks when the definition was removed. When the decorated definition is called, + that is when the function is called or the class instantiated, a warning is issued if `since` is given and + the current version is at or later than that given. An exception is instead raised if `removed` is given and + the current version is at or later than that, or if neither `since` nor `removed` is provided. + + Args: + since: version at which the definition was marked deprecated but not removed + removed: version at which the definition was removed and no longer usable + msg_suffix: message appended to warning/exception detailing reasons for deprecation and what to use instead + version_val: (used for testing) version to compare since and removed against, default is MONAI version + + Returns: + Decorated definition which warns or raises exception when used + """ + + is_deprecated = since is not None and version_leq(since, version_val) + is_removed = removed is not None and version_leq(removed, version_val) + + def _decorator(obj): + is_func = isinstance(obj, FunctionType) + call_obj = obj if is_func else obj.__init__ + + msg_prefix = f"{'Function' if is_func else 'Class'} `{obj.__name__}`" + + if is_removed: + msg_infix = f"was removed in version {removed}." + elif is_deprecated: + msg_infix = f"has been deprecated since version {since}." + if removed is not None: + msg_infix += f" It will be removed in version {removed}." + else: + msg_infix = "has been deprecated." + + msg = f"{msg_prefix} {msg_infix} {msg_suffix}".strip() + + @wraps(call_obj) + def _wrapper(*args, **kwargs): + if is_deprecated: + warn_deprecated(obj, msg) + else: + raise DeprecatedError(msg) + + return call_obj(*args, **kwargs) + + if is_func: + return _wrapper + else: + obj.__init__ = _wrapper + return obj + + return _decorator + + +def deprecated_arg( + name, since: Optional[str] = None, removed: Optional[str] = None, msg_suffix: str = "", version_val=__version__ +): + """ + Marks a particular named argument of a callable as deprecated. The same conditions for `since` and `removed` as + described in the `deprecated` decorator. + + Args: + name: name of position or keyword argument to mark as deprecated + since: version at which the argument was marked deprecated but not removed + removed: version at which the argument was removed and no longer usable + msg_suffix: message appended to warning/exception detailing reasons for deprecation and what to use instead + version_val: (used for testing) version to compare since and removed against, default is MONAI version + + Returns: + Decorated callable which warns or raises exception when deprecated argument used + """ + + is_deprecated = since is not None and version_leq(since, version_val) + is_removed = removed is not None and version_leq(removed, version_val) + + def _decorator(func): + argname = f"{func.__name__}_{name}" + + msg_prefix = f"Argument `{name}`" + + if is_removed: + msg_infix = f"was removed in version {removed}." + elif is_deprecated: + msg_infix = f"has been deprecated since version {since}." + if removed is not None: + msg_infix += f" It will be removed in version {removed}." + else: + msg_infix = "has been deprecated." + + msg = f"{msg_prefix} {msg_infix} {msg_suffix}".strip() + + sig = inspect.signature(func) + + @wraps(func) + def _wrapper(*args, **kwargs): + binding = sig.bind(*args, **kwargs).arguments + + positional_found = name in binding + kw_found = "kwargs" in binding and name in binding["kwargs"] + + if positional_found or kw_found: + if is_deprecated: + warn_deprecated(argname, msg) + else: + raise DeprecatedError(msg) + + return func(*args, **kwargs) + + return _wrapper + + return _decorator diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py new file mode 100644 index 0000000000..e3ff8719eb --- /dev/null +++ b/tests/test_deprecated.py @@ -0,0 +1,167 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import unittest + +from monai.utils import DeprecatedError, deprecated, deprecated_arg + + +class TestDeprecated(unittest.TestCase): + def setUp(self): + self.test_version = "0.5.3+96.g1fa03c2.dirty" + self.prev_version = "0.4.3+96.g1fa03c2.dirty" + self.next_version = "0.6.3+96.g1fa03c2.dirty" + + def test_warning1(self): + """Test deprecated decorator with just `since` set.""" + + @deprecated(since=self.prev_version, version_val=self.test_version) + def foo1(): + pass + + self.assertWarns(DeprecationWarning, foo1) + + def test_warning2(self): + """Test deprecated decorator with `since` and `removed` set.""" + + @deprecated(since=self.prev_version, removed=self.next_version, version_val=self.test_version) + def foo2(): + pass + + self.assertWarns(DeprecationWarning, foo2) + + def test_except1(self): + """Test deprecated decorator raises exception with no versions set.""" + + @deprecated(version_val=self.test_version) + def foo3(): + pass + + self.assertRaises(DeprecatedError, foo3) + + def test_except2(self): + """Test deprecated decorator raises exception with `removed` set in the past.""" + + @deprecated(removed=self.prev_version, version_val=self.test_version) + def foo4(): + pass + + self.assertRaises(DeprecatedError, foo4) + + def test_class_warning1(self): + """Test deprecated decorator with just `since` set.""" + + @deprecated(since=self.prev_version, version_val=self.test_version) + class Foo1: + pass + + self.assertWarns(DeprecationWarning, Foo1) + + def test_class_warning2(self): + """Test deprecated decorator with `since` and `removed` set.""" + + @deprecated(since=self.prev_version, removed=self.next_version, version_val=self.test_version) + class Foo2: + pass + + self.assertWarns(DeprecationWarning, Foo2) + + def test_class_except1(self): + """Test deprecated decorator raises exception with no versions set.""" + + @deprecated(version_val=self.test_version) + class Foo3: + pass + + self.assertRaises(DeprecatedError, Foo3) + + def test_class_except2(self): + """Test deprecated decorator raises exception with `removed` set in the past.""" + + @deprecated(removed=self.prev_version, version_val=self.test_version) + class Foo4: + pass + + self.assertRaises(DeprecatedError, Foo4) + + def test_meth_warning1(self): + """Test deprecated decorator with just `since` set.""" + + class Foo5: + @deprecated(since=self.prev_version, version_val=self.test_version) + def meth1(self): + pass + + self.assertWarns(DeprecationWarning, lambda: Foo5().meth1()) + + def test_meth_except1(self): + """Test deprecated decorator with just `since` set.""" + + class Foo6: + @deprecated(version_val=self.test_version) + def meth1(self): + pass + + self.assertRaises(DeprecatedError, lambda: Foo6().meth1()) + + def test_arg_warn1(self): + """Test deprecated_arg decorator with just `since` set.""" + + @deprecated_arg("b", since=self.prev_version, version_val=self.test_version) + def afoo1(a, b=None): + pass + + afoo1(1) # ok when no b provided + + self.assertWarns(DeprecationWarning, lambda: afoo1(1, 2)) + + def test_arg_warn2(self): + """Test deprecated_arg decorator with just `since` set.""" + + @deprecated_arg("b", since=self.prev_version, version_val=self.test_version) + def afoo2(a, **kwargs): + pass + + afoo2(1) # ok when no b provided + + self.assertWarns(DeprecationWarning, lambda: afoo2(1, b=2)) + + def test_arg_except1(self): + """Test deprecated_arg decorator raises exception with no versions set.""" + + @deprecated_arg("b", version_val=self.test_version) + def afoo3(a, b=None): + pass + + self.assertRaises(DeprecatedError, lambda: afoo3(1, b=2)) + + def test_arg_except2(self): + """Test deprecated_arg decorator raises exception with `removed` set in the past.""" + + @deprecated_arg("b", removed=self.prev_version, version_val=self.test_version) + def afoo4(a, b=None): + pass + + self.assertRaises(DeprecatedError, lambda: afoo4(1, b=2)) + + def test_2arg_warn1(self): + """Test deprecated_arg decorator applied twice with just `since` set.""" + + @deprecated_arg("b", since=self.prev_version, version_val=self.test_version) + @deprecated_arg("c", since=self.prev_version, version_val=self.test_version) + def afoo5(a, b=None, c=None): + pass + + afoo5(1) # ok when no b or c provided + + self.assertWarns(DeprecationWarning, lambda: afoo5(1, 2)) + self.assertWarns(DeprecationWarning, lambda: afoo5(1, 2, 3))