diff --git a/conda_build/deprecations.py b/conda_build/deprecations.py index 494f0f85f1..f691b5192d 100644 --- a/conda_build/deprecations.py +++ b/conda_build/deprecations.py @@ -6,16 +6,22 @@ import sys import warnings +from argparse import Action from functools import wraps from types import ModuleType from typing import TYPE_CHECKING if TYPE_CHECKING: - from argparse import Action - from typing import Any, Callable + from argparse import ArgumentParser, Namespace + from typing import Any, Callable, ParamSpec, Self, TypeVar from packaging.version import Version + T = TypeVar("T") + P = ParamSpec("P") + + ActionType = TypeVar("ActionType", bound=type[Action]) + from . import __version__ @@ -30,7 +36,7 @@ class DeprecationHandler: _version_tuple: tuple[int, ...] | None _version_object: Version | None - def __init__(self, version: str): + def __init__(self: Self, version: str) -> None: """Factory to create a deprecation handle for the specified version. :param version: The version to compare against when checking deprecation statuses. @@ -52,14 +58,13 @@ def _get_version_tuple(version: str) -> tuple[int, ...] | None: except (AttributeError, ValueError): return None - def _version_less_than(self, version: str) -> bool: + def _version_less_than(self: Self, version: str) -> bool: """Test whether own version is less than the given version. :param version: Version string to compare against. """ - if self._version_tuple: - if version_tuple := self._get_version_tuple(version): - return self._version_tuple < version_tuple + if self._version_tuple and (version_tuple := self._get_version_tuple(version)): + return self._version_tuple < version_tuple # If self._version or version could not be represented by a simple # tuple[int, ...], do a more elaborate version parsing and comparison. @@ -68,19 +73,20 @@ def _version_less_than(self, version: str) -> bool: if self._version_object is None: try: - self._version_object = parse(self._version) + self._version_object = parse(self._version) # type: ignore[arg-type] except TypeError: + # TypeError: self._version could not be parsed self._version_object = parse("0.0.0.dev0+placeholder") return self._version_object < parse(version) def __call__( - self, + self: Self, deprecate_in: str, remove_in: str, *, addendum: str | None = None, stack: int = 0, - ) -> Callable[[Callable], Callable]: + ) -> Callable[[Callable[P, T]], Callable[P, T]]: """Deprecation decorator for functions, methods, & classes. :param deprecate_in: Version in which code will be marked as deprecated. @@ -89,12 +95,12 @@ def __call__( :param stack: Optional stacklevel increment. """ - def deprecated_decorator(func: Callable) -> Callable: + def deprecated_decorator(func: Callable[P, T]) -> Callable[P, T]: # detect function name and generate message category, message = self._generate_message( - deprecate_in, - remove_in, - f"{func.__module__}.{func.__qualname__}", + deprecate_in=deprecate_in, + remove_in=remove_in, + prefix=f"{func.__module__}.{func.__qualname__}", addendum=addendum, ) @@ -104,7 +110,7 @@ def deprecated_decorator(func: Callable) -> Callable: # alert user that it's time to remove something @wraps(func) - def inner(*args, **kwargs): + def inner(*args: P.args, **kwargs: P.kwargs) -> T: warnings.warn(message, category, stacklevel=2 + stack) return func(*args, **kwargs) @@ -114,7 +120,7 @@ def inner(*args, **kwargs): return deprecated_decorator def argument( - self, + self: Self, deprecate_in: str, remove_in: str, argument: str, @@ -122,7 +128,7 @@ def argument( rename: str | None = None, addendum: str | None = None, stack: int = 0, - ) -> Callable[[Callable], Callable]: + ) -> Callable[[Callable[P, T]], Callable[P, T]]: """Deprecation decorator for keyword arguments. :param deprecate_in: Version in which code will be marked as deprecated. @@ -133,16 +139,16 @@ def argument( :param stack: Optional stacklevel increment. """ - def deprecated_decorator(func: Callable) -> Callable: + def deprecated_decorator(func: Callable[P, T]) -> Callable[P, T]: # detect function name and generate message category, message = self._generate_message( - deprecate_in, - remove_in, - f"{func.__module__}.{func.__qualname__}({argument})", + deprecate_in=deprecate_in, + remove_in=remove_in, + prefix=f"{func.__module__}.{func.__qualname__}({argument})", # provide a default addendum if renaming and no addendum is provided - addendum=f"Use '{rename}' instead." - if rename and not addendum - else addendum, + addendum=( + f"Use '{rename}' instead." if rename and not addendum else addendum + ), ) # alert developer that it's time to remove something @@ -151,7 +157,7 @@ def deprecated_decorator(func: Callable) -> Callable: # alert user that it's time to remove something @wraps(func) - def inner(*args, **kwargs): + def inner(*args: P.args, **kwargs: P.kwargs) -> T: # only warn about argument deprecations if the argument is used if argument in kwargs: warnings.warn(message, category, stacklevel=2 + stack) @@ -168,22 +174,27 @@ def inner(*args, **kwargs): return deprecated_decorator def action( - self, + self: Self, deprecate_in: str, remove_in: str, - action: type[Action], + action: ActionType, *, addendum: str | None = None, stack: int = 0, - ): - class DeprecationMixin: - def __init__(inner_self, *args, **kwargs): + ) -> ActionType: + """Wraps any argparse.Action to issue a deprecation warning.""" + + class DeprecationMixin(Action): + category: type[Warning] + help: str # override argparse.Action's help type annotation + + def __init__(inner_self: Self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) category, message = self._generate_message( - deprecate_in, - remove_in, - ( + deprecate_in=deprecate_in, + remove_in=remove_in, + prefix=( # option_string are ordered shortest to longest, # use the longest as it's the most descriptive f"`{inner_self.option_strings[-1]}`" @@ -192,6 +203,7 @@ def __init__(inner_self, *args, **kwargs): else f"`{inner_self.dest}`" ), addendum=addendum, + deprecation_type=FutureWarning, ) # alert developer that it's time to remove something @@ -201,18 +213,26 @@ def __init__(inner_self, *args, **kwargs): inner_self.category = category inner_self.help = message - def __call__(inner_self, parser, namespace, values, option_string=None): + def __call__( + inner_self: Self, + parser: ArgumentParser, + namespace: Namespace, + values: Any, + option_string: str | None = None, + ) -> None: # alert user that it's time to remove something warnings.warn( - inner_self.help, inner_self.category, stacklevel=7 + stack + inner_self.help, + inner_self.category, + stacklevel=7 + stack, ) super().__call__(parser, namespace, values, option_string) - return type(action.__name__, (DeprecationMixin, action), {}) + return type(action.__name__, (DeprecationMixin, action), {}) # type: ignore[return-value] def module( - self, + self: Self, deprecate_in: str, remove_in: str, *, @@ -235,7 +255,7 @@ def module( ) def constant( - self, + self: Self, deprecate_in: str, remove_in: str, constant: str, @@ -257,10 +277,10 @@ def constant( module, fullname = self._get_module(stack) # detect function name and generate message category, message = self._generate_message( - deprecate_in, - remove_in, - f"{fullname}.{constant}", - addendum, + deprecate_in=deprecate_in, + remove_in=remove_in, + prefix=f"{fullname}.{constant}", + addendum=addendum, ) # alert developer that it's time to remove something @@ -280,10 +300,10 @@ def __getattr__(name: str) -> Any: raise AttributeError(f"module '{fullname}' has no attribute '{name}'") - module.__getattr__ = __getattr__ + module.__getattr__ = __getattr__ # type: ignore[method-assign] def topic( - self, + self: Self, deprecate_in: str, remove_in: str, *, @@ -301,10 +321,10 @@ def topic( """ # detect function name and generate message category, message = self._generate_message( - deprecate_in, - remove_in, - topic, - addendum, + deprecate_in=deprecate_in, + remove_in=remove_in, + prefix=topic, + addendum=addendum, ) # alert developer that it's time to remove something @@ -314,7 +334,7 @@ def topic( # alert user that it's time to remove something warnings.warn(message, category, stacklevel=2 + stack) - def _get_module(self, stack: int) -> tuple[ModuleType, str]: + def _get_module(self: Self, stack: int) -> tuple[ModuleType, str]: """Detect the module from which we are being called. :param stack: The stacklevel increment. @@ -333,13 +353,15 @@ def _get_module(self, stack: int) -> tuple[ModuleType, str]: # AttributeError: frame.f_code.co_filename is undefined pass else: - for module in sys.modules.values(): - if not isinstance(module, ModuleType): + # use a copy of sys.modules to avoid RuntimeError during iteration + # see https://github.com/conda/conda/issues/13754 + for loaded in tuple(sys.modules.values()): + if not isinstance(loaded, ModuleType): continue - if not hasattr(module, "__file__"): + if not hasattr(loaded, "__file__"): continue - if module.__file__ == filename: - return (module, module.__name__) + if loaded.__file__ == filename: + return (loaded, loaded.__name__) # If above failed, do an expensive import and costly getmodule call. import inspect @@ -351,18 +373,22 @@ def _get_module(self, stack: int) -> tuple[ModuleType, str]: raise DeprecatedError("unable to determine the calling module") def _generate_message( - self, + self: Self, deprecate_in: str, remove_in: str, prefix: str, addendum: str | None, + *, + deprecation_type: type[Warning] = DeprecationWarning, ) -> tuple[type[Warning] | None, str]: - """Deprecation decorator for functions, methods, & classes. + """Generate the standardized deprecation message and determine whether the + deprecation is pending, active, or past. :param deprecate_in: Version in which code will be marked as deprecated. :param remove_in: Version in which code is expected to be removed. :param prefix: The message prefix, usually the function name. :param addendum: Additional messaging. Useful to indicate what to do instead. + :param deprecation_type: The warning type to use for active deprecations. :return: The warning category (if applicable) and the message. """ category: type[Warning] | None @@ -370,7 +396,7 @@ def _generate_message( category = PendingDeprecationWarning warning = f"is pending deprecation and will be removed in {remove_in}." elif self._version_less_than(remove_in): - category = DeprecationWarning + category = deprecation_type warning = f"is deprecated and will be removed in {remove_in}." else: category = None diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index a4ff2d1ea7..35383913fb 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -1,268 +1,200 @@ # Copyright (C) 2014 Anaconda, Inc # SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + import sys from argparse import ArgumentParser, _StoreTrueAction +from contextlib import nullcontext +from typing import TYPE_CHECKING import pytest from conda_build.deprecations import DeprecatedError, DeprecationHandler - -@pytest.fixture(scope="module") -def deprecated_v1() -> DeprecationHandler: - """Fixture mocking the conda_build.deprecations.deprecated object with `version=1.0`.""" - return DeprecationHandler("1.0") - - -@pytest.fixture(scope="module") -def deprecated_v2() -> DeprecationHandler: - """Fixture mocking the conda_build.deprecations.deprecated object with `version=2.0`.""" - return DeprecationHandler("2.0") - - -@pytest.fixture(scope="module") -def deprecated_v3() -> DeprecationHandler: - """Fixture mocking the conda_build.deprecations.deprecated object with `version=3.0`.""" - return DeprecationHandler("3.0") - - -def test_function_pending(deprecated_v1: DeprecationHandler): - """Calling a pending deprecation function displays associated warning.""" - - @deprecated_v1("2.0", "3.0") - def foo(): - return True - - with pytest.deprecated_call(match="pending deprecation"): - assert foo() - - -def test_function_deprecated(deprecated_v2: DeprecationHandler): - """Calling a deprecated function displays associated warning.""" - - @deprecated_v2("2.0", "3.0") - def foo(): - return True - - with pytest.deprecated_call(match="deprecated"): - assert foo() - - -def test_function_remove(deprecated_v3: DeprecationHandler): - """A function existing past its removal version raises an error.""" - with pytest.raises(DeprecatedError): - - @deprecated_v3("2.0", "3.0") +if TYPE_CHECKING: + from packaging.version import Version + + from conda_build.deprecations import DevDeprecationType, UserDeprecationType + +PENDING = pytest.param( + DeprecationHandler("1.0"), # deprecated + PendingDeprecationWarning, # warning + "pending deprecation", # message + id="pending", +) +FUTURE = pytest.param( + DeprecationHandler("2.0"), # deprecated + FutureWarning, # warning + "deprecated", # message + id="future", +) +DEPRECATED = pytest.param( + DeprecationHandler("2.0"), # deprecated + DeprecationWarning, # warning + "deprecated", # message + id="deprecated", +) +REMOVE = pytest.param( + DeprecationHandler("3.0"), # deprecated + None, # warning + None, # message + id="remove", +) + +parametrize_user = pytest.mark.parametrize( + "deprecated,warning,message", + [PENDING, FUTURE, REMOVE], +) +parametrize_dev = pytest.mark.parametrize( + "deprecated,warning,message", + [PENDING, DEPRECATED, REMOVE], +) + + +@parametrize_dev +def test_function( + deprecated: DeprecationHandler, + warning: DevDeprecationType | None, + message: str | None, +) -> None: + """Calling a deprecated function displays associated warning (or error).""" + with nullcontext() if warning else pytest.raises(DeprecatedError): + + @deprecated("2.0", "3.0") def foo(): return True - -def test_method_pending(deprecated_v1: DeprecationHandler): - """Calling a pending deprecation method displays associated warning.""" - - class Bar: - @deprecated_v1("2.0", "3.0") - def foo(self): - return True - - with pytest.deprecated_call(match="pending deprecation"): - assert Bar().foo() + with pytest.warns(warning, match=message): + assert foo() -def test_method_deprecated(deprecated_v2: DeprecationHandler): - """Calling a deprecated method displays associated warning.""" - - class Bar: - @deprecated_v2("2.0", "3.0") - def foo(self): - return True - - with pytest.deprecated_call(match="deprecated"): - assert Bar().foo() - - -def test_method_remove(deprecated_v3: DeprecationHandler): - """A method existing past its removal version raises an error.""" - with pytest.raises(DeprecatedError): +@parametrize_dev +def test_method( + deprecated: DeprecationHandler, + warning: DevDeprecationType | None, + message: str | None, +) -> None: + """Calling a deprecated method displays associated warning (or error).""" + with nullcontext() if warning else pytest.raises(DeprecatedError): class Bar: - @deprecated_v3("2.0", "3.0") + @deprecated("2.0", "3.0") def foo(self): return True + with pytest.warns(warning, match=message): + assert Bar().foo() -def test_class_pending(deprecated_v1: DeprecationHandler): - """Calling a pending deprecation class displays associated warning.""" - - @deprecated_v1("2.0", "3.0") - class Foo: - pass - with pytest.deprecated_call(match="pending deprecation"): - assert Foo() +@parametrize_dev +def test_class( + deprecated: DeprecationHandler, + warning: DevDeprecationType | None, + message: str | None, +) -> None: + """Calling a deprecated class displays associated warning (or error).""" + with nullcontext() if warning else pytest.raises(DeprecatedError): - -def test_class_deprecated(deprecated_v2: DeprecationHandler): - """Calling a deprecated class displays associated warning.""" - - @deprecated_v2("2.0", "3.0") - class Foo: - pass - - with pytest.deprecated_call(match="deprecated"): - assert Foo() - - -def test_class_remove(deprecated_v3: DeprecationHandler): - """A class existing past its removal version raises an error.""" - with pytest.raises(DeprecatedError): - - @deprecated_v3("2.0", "3.0") + @deprecated("2.0", "3.0") class Foo: pass + with pytest.warns(warning, match=message): + assert Foo() -def test_arguments_pending(deprecated_v1: DeprecationHandler): - """Calling a pending deprecation argument displays associated warning.""" - - @deprecated_v1.argument("2.0", "3.0", "three") - def foo(one, two): - return True - - # too many arguments, can only deprecate keyword arguments - with pytest.raises(TypeError): - assert foo(1, 2, 3) - - # alerting user to pending deprecation - with pytest.deprecated_call(match="pending deprecation"): - assert foo(1, 2, three=3) - # normal usage not needing deprecation - assert foo(1, 2) +@parametrize_dev +def test_arguments( + deprecated: DeprecationHandler, + warning: DevDeprecationType | None, + message: str | None, +) -> None: + """Calling a deprecated argument displays associated warning (or error).""" + with nullcontext() if warning else pytest.raises(DeprecatedError): - -def test_arguments_deprecated(deprecated_v2: DeprecationHandler): - """Calling a deprecated argument displays associated warning.""" - - @deprecated_v2.argument("2.0", "3.0", "three") - def foo(one, two): - return True - - # too many arguments, can only deprecate keyword arguments - with pytest.raises(TypeError): - assert foo(1, 2, 3) - - # alerting user to pending deprecation - with pytest.deprecated_call(match="deprecated"): - assert foo(1, 2, three=3) - - # normal usage not needing deprecation - assert foo(1, 2) - - -def test_arguments_remove(deprecated_v3: DeprecationHandler): - """An argument existing past its removal version raises an error.""" - with pytest.raises(DeprecatedError): - - @deprecated_v3.argument("2.0", "3.0", "three") + @deprecated.argument("2.0", "3.0", "three") def foo(one, two): return True - -def test_action_pending(deprecated_v1: DeprecationHandler): - """Calling a pending deprecation argparse.Action displays associated warning.""" - parser = ArgumentParser() - parser.add_argument( - "--foo", action=deprecated_v1.action("2.0", "3.0", _StoreTrueAction) - ) - - with pytest.deprecated_call(match="pending deprecation"): - parser.parse_args(["--foo"]) - - -def test_action_deprecated(deprecated_v2: DeprecationHandler): - """Calling a deprecated argparse.Action displays associated warning.""" - parser = ArgumentParser() - parser.add_argument( - "--foo", action=deprecated_v2.action("2.0", "3.0", _StoreTrueAction) - ) - - with pytest.deprecated_call(match="deprecated"): - parser.parse_args(["--foo"]) - - -def test_action_remove(deprecated_v3: DeprecationHandler): - """An argparse.Action existing past its removal version raises an error.""" - with pytest.raises(DeprecatedError): - ArgumentParser().add_argument( - "--foo", action=deprecated_v3.action("2.0", "3.0", _StoreTrueAction) + # too many arguments, can only deprecate keyword arguments + with pytest.raises(TypeError): + assert foo(1, 2, 3) + + # alerting user to pending deprecation + with pytest.warns(warning, match=message): + assert foo(1, 2, three=3) + + # normal usage not needing deprecation + assert foo(1, 2) + + +@parametrize_user +def test_action( + deprecated: DeprecationHandler, + warning: UserDeprecationType | None, + message: str | None, +) -> None: + """Calling a deprecated argparse.Action displays associated warning (or error).""" + with nullcontext() if warning else pytest.raises(DeprecatedError): + parser = ArgumentParser() + parser.add_argument( + "--foo", + action=deprecated.action("2.0", "3.0", _StoreTrueAction), ) - -def test_module_pending(deprecated_v1: DeprecationHandler): - """Importing a pending deprecation module displays associated warning.""" - with pytest.deprecated_call(match="pending deprecation"): - deprecated_v1.module("2.0", "3.0") - - -def test_module_deprecated(deprecated_v2: DeprecationHandler): - """Importing a deprecated module displays associated warning.""" - with pytest.deprecated_call(match="deprecated"): - deprecated_v2.module("2.0", "3.0") - - -def test_module_remove(deprecated_v3: DeprecationHandler): - """A module existing past its removal version raises an error.""" - with pytest.raises(DeprecatedError): - deprecated_v3.module("2.0", "3.0") - - -def test_constant_pending(deprecated_v1: DeprecationHandler): - """Using a pending deprecation constant displays associated warning.""" - deprecated_v1.constant("2.0", "3.0", "SOME_CONSTANT", 42) - module = sys.modules[__name__] - - with pytest.deprecated_call(match="pending deprecation"): - module.SOME_CONSTANT - - -def test_constant_deprecated(deprecated_v2: DeprecationHandler): - """Using a deprecated constant displays associated warning.""" - deprecated_v2.constant("2.0", "3.0", "SOME_CONSTANT", 42) - module = sys.modules[__name__] - - with pytest.deprecated_call(match="deprecated"): - module.SOME_CONSTANT - - -def test_constant_remove(deprecated_v3: DeprecationHandler): - """A constant existing past its removal version raises an error.""" - with pytest.raises(DeprecatedError): - deprecated_v3.constant("2.0", "3.0", "SOME_CONSTANT", 42) - - -def test_topic_pending(deprecated_v1: DeprecationHandler): - """Reaching a pending deprecation topic displays associated warning.""" - with pytest.deprecated_call(match="pending deprecation"): - deprecated_v1.topic("2.0", "3.0", topic="Some special topic") - - -def test_topic_deprecated(deprecated_v2: DeprecationHandler): - """Reaching a deprecated topic displays associated warning.""" - with pytest.deprecated_call(match="deprecated"): - deprecated_v2.topic("2.0", "3.0", topic="Some special topic") - - -def test_topic_remove(deprecated_v3: DeprecationHandler): - """A topic reached past its removal version raises an error.""" - with pytest.raises(DeprecatedError): - deprecated_v3.topic("2.0", "3.0", topic="Some special topic") - - -def test_version_fallback(): - """Test that conda_build can run even if deprecations can't parse the version.""" - deprecated = DeprecationHandler(None) # type: ignore + with pytest.warns(warning, match=message): + parser.parse_args(["--foo"]) + + +@parametrize_dev +def test_module( + deprecated: DeprecationHandler, + warning: DevDeprecationType | None, + message: str | None, +) -> None: + """Importing a deprecated module displays associated warning (or error).""" + with ( + pytest.warns(warning, match=message) + if warning + else pytest.raises(DeprecatedError) + ): + deprecated.module("2.0", "3.0") + + +@parametrize_dev +def test_constant( + deprecated: DeprecationHandler, + warning: DevDeprecationType | None, + message: str | None, +) -> None: + """Using a deprecated constant displays associated warning (or error).""" + with nullcontext() if warning else pytest.raises(DeprecatedError): + deprecated.constant("2.0", "3.0", "SOME_CONSTANT", 42) + module = sys.modules[__name__] + + with pytest.warns(warning, match=message): + module.SOME_CONSTANT + + +@parametrize_dev +def test_topic( + deprecated: DeprecationHandler, + warning: DevDeprecationType | None, + message: str | None, +) -> None: + """Reaching a deprecated topic displays associated warning (or error).""" + with ( + pytest.warns(warning, match=message) + if warning + else pytest.raises(DeprecatedError) + ): + deprecated.topic("2.0", "3.0", topic="Some special topic") + + +def test_version_fallback() -> None: + """Test that conda can run even if deprecations can't parse the version.""" + deprecated = DeprecationHandler(None) # type: ignore[arg-type] assert deprecated._version_less_than("0") assert deprecated._version_tuple is None - version = deprecated._version_object # type: ignore + version: Version = deprecated._version_object # type: ignore[assignment] assert version.major == version.minor == version.micro == 0