diff --git a/README.rst b/README.rst index bdf4abd..8de05ee 100644 --- a/README.rst +++ b/README.rst @@ -2,23 +2,29 @@ Debtcollector ============= -.. image:: https://governance.openstack.org/tc/badges/debtcollector.svg +.. image:: https://governance.openstack.org/tc/badges/debtcollector + :target: https://governance.openstack.org/tc/reference/projects/oslo.html -.. Change things from this point on - -.. image:: https://img.shields.io/pypi/v/debtcollector.svg +.. image:: https://img.shields.io/pypi/v/debtcollector :target: https://pypi.org/project/debtcollector/ :alt: Latest Version +.. image:: https://img.shields.io/pypi/dm/debtcollector + :target: https://pypi.org/project/debtcollector/ + :alt: Downloads + +.. image:: https://img.shields.io/pypi/types/debtcollector + :target: https://pypi.org/project/debtcollector/ + :alt: Typing Status + A collection of Python deprecation patterns and strategies that help you collect your technical debt in a non-destructive manner. The goal of this library is to provide well documented developer facing deprecation patterns that start of with a basic set and can expand into a larger set of patterns as time goes on. The desired output of these patterns -is to apply the warnings module to emit DeprecationWarning or -PendingDeprecationWarning or similar derivative to developers using libraries -(or potentially applications) about future deprecations. - +is to apply the ``warnings`` module to emit ``DeprecationWarning``, +``PendingDeprecationWarning`` or similar derivative to developers using +libraries (or potentially applications) about future deprecations. * Free software: Apache license * Documentation: https://docs.openstack.org/debtcollector/latest diff --git a/debtcollector/__init__.py b/debtcollector/__init__.py index 762eaf3..12b91f1 100644 --- a/debtcollector/__init__.py +++ b/debtcollector/__init__.py @@ -10,6 +10,8 @@ # License for the specific language governing permissions and limitations # under the License. +from __future__ import annotations + import importlib.metadata from debtcollector import _utils @@ -18,14 +20,14 @@ __version__ = importlib.metadata.version('debtcollector') def deprecate( - prefix, - postfix=None, - message=None, - version=None, - removal_version=None, - stacklevel=3, - category=DeprecationWarning, -): + prefix: str, + postfix: str | None = None, + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + category: type[Warning] = DeprecationWarning, +) -> None: """Helper to deprecate some thing using generated message format. :param prefix: prefix string used as the prefix of the output message diff --git a/debtcollector/_utils.py b/debtcollector/_utils.py index dba3347..054a877 100644 --- a/debtcollector/_utils.py +++ b/debtcollector/_utils.py @@ -12,8 +12,14 @@ # License for the specific language governing permissions and limitations # under the License. +from __future__ import annotations + +import builtins +from collections.abc import Callable import functools import inspect +import types +from typing import Any import warnings # See https://docs.python.org/3/library/builtins.html @@ -21,7 +27,11 @@ _BUILTIN_MODULES = ('builtins', 'exceptions') _enabled = True -def deprecation(message, stacklevel=None, category=None): +def deprecation( + message: str, + stacklevel: int | None = None, + category: type[Warning] | None = None, +) -> None: """Warns about some type of deprecation that has been (or will be) made. This helper function makes it easier to interact with the warnings module @@ -39,7 +49,7 @@ def deprecation(message, stacklevel=None, category=None): the deprecation warnings). """ if not _enabled: - return + return None if category is None: category = DeprecationWarning if stacklevel is None: @@ -47,8 +57,12 @@ def deprecation(message, stacklevel=None, category=None): else: warnings.warn(message, category=category, stacklevel=stacklevel) + return None -def get_qualified_name(obj): + +def get_qualified_name( + obj: Callable[..., Any] | types.ModuleType | builtins.function, +) -> tuple[bool, str]: # Prefer the py3.x name (if we can get at it...) try: return (True, obj.__qualname__) @@ -57,8 +71,12 @@ def get_qualified_name(obj): def generate_message( - prefix, postfix=None, message=None, version=None, removal_version=None -): + prefix: str, + postfix: str | None = None, + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, +) -> str: """Helper to generate a common message 'style' for deprecation helpers.""" message_components = [prefix] if version: @@ -79,12 +97,15 @@ def generate_message( return ''.join(message_components) -def get_assigned(decorator): +def get_assigned(decorator: Any) -> tuple[str, ...]: """Helper to fix/workaround https://bugs.python.org/issue3445""" return functools.WRAPPER_ASSIGNMENTS -def get_class_name(obj, fully_qualified=True): +def get_class_name( + obj: type[Any] | Callable[..., Any], + fully_qualified: bool = True, +) -> str: """Get class name for object. If object is a type, fully qualified name of the type is returned. @@ -107,7 +128,7 @@ def get_class_name(obj, fully_qualified=True): return obj.__name__ -def get_method_self(method): +def get_method_self(method: Any) -> Any: """Gets the ``self`` object attached to this method (or none).""" if not inspect.ismethod(method): return None @@ -117,11 +138,12 @@ def get_method_self(method): return None -def get_callable_name(function): +def get_callable_name(function: Callable[..., Any]) -> str: """Generate a name from callable. Tries to do the best to guess fully qualified callable name. """ + parts: tuple[str, str] | tuple[str, str, str] method_self = get_method_self(function) if method_self is not None: # This is a bound method. @@ -150,9 +172,10 @@ def get_callable_name(function): else: parts = (function.__module__, function.__name__) else: - im_class = type(function) - if im_class is type: + if type(function) is type: im_class = function + else: + im_class = type(function) try: parts = (im_class.__module__, im_class.__qualname__) except AttributeError: diff --git a/debtcollector/fixtures/disable.py b/debtcollector/fixtures/disable.py index 5db1f85..8ced00d 100644 --- a/debtcollector/fixtures/disable.py +++ b/debtcollector/fixtures/disable.py @@ -30,6 +30,6 @@ class DisableFixture(fixtures.Fixture): """ - def _setUp(self): + def _setUp(self) -> None: self.addCleanup(setattr, _utils, "_enabled", True) _utils._enabled = False diff --git a/debtcollector/moves.py b/debtcollector/moves.py index 44433c7..2c40d0d 100644 --- a/debtcollector/moves.py +++ b/debtcollector/moves.py @@ -12,8 +12,12 @@ # License for the specific language governing permissions and limitations # under the License. +from __future__ import annotations + +from collections.abc import Callable import functools import inspect +from typing import Any, ParamSpec, TypeVar import wrapt @@ -24,26 +28,35 @@ _CLASS_MOVED_PREFIX_TPL = "Class '%s' has moved to '%s'" _MOVED_CALLABLE_POSTFIX = "()" _FUNC_MOVED_PREFIX_TPL = "Function '%s' has moved to '%s'" +P = ParamSpec('P') +R = TypeVar('R') +T = TypeVar('T') + def _moved_decorator( - kind, - new_attribute_name, - message=None, - version=None, - removal_version=None, - stacklevel=3, - attr_postfix=None, - category=None, -): + kind: str, + new_attribute_name: str, + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + attr_postfix: str | None = None, + category: type[Warning] | None = None, +) -> Callable[[Callable[P, R]], Callable[P, R]]: """Decorates a method/property that was moved to another location.""" - def decorator(f): + def decorator(f: Callable[P, R]) -> Callable[P, R]: fully_qualified, old_attribute_name = _utils.get_qualified_name(f) if attr_postfix: old_attribute_name += attr_postfix @wrapt.decorator - def wrapper(wrapped, instance, args, kwargs): + def wrapper( + wrapped: Callable[P, R], + instance: object | None, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> R: base_name = _utils.get_class_name(wrapped, fully_qualified=False) if fully_qualified: old_name = old_attribute_name @@ -68,15 +81,15 @@ def _moved_decorator( def moved_function( - new_func, - old_func_name, - old_module_name, - message=None, - version=None, - removal_version=None, - stacklevel=3, - category=None, -): + new_func: Callable[P, R], + old_func_name: str, + old_module_name: str, + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + category: type[Warning] | None = None, +) -> Callable[P, R]: """Deprecates a function that was moved to another location. This generates a wrapper around ``new_func`` that will emit a deprecation @@ -96,7 +109,7 @@ def moved_function( ) @functools.wraps(new_func, assigned=_utils.get_assigned(new_func)) - def old_new_func(*args, **kwargs): + def old_new_func(*args: P.args, **kwargs: P.kwargs) -> R: _utils.deprecation( out_message, stacklevel=stacklevel, category=category ) @@ -133,27 +146,29 @@ class moved_read_only_property: def __init__( self, - old_name, - new_name, - version=None, - removal_version=None, - stacklevel=3, - category=None, + old_name: str, + new_name: str, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + category: type[Warning] | None = None, ): self._old_name = old_name self._new_name = new_name self._message = _utils.generate_message( - f"Read-only property '{self._old_name}' has moved" - f" to '{self._new_name}'", + f"Read-only property '{self._old_name}' has moved " + f"to '{self._new_name}'", version=version, removal_version=removal_version, ) self._stacklevel = stacklevel self._category = category - def __get__(self, instance, owner): + def __get__(self, instance: Any, owner: type | None = None) -> Any: _utils.deprecation( - self._message, stacklevel=self._stacklevel, category=self._category + self._message, + stacklevel=self._stacklevel, + category=self._category, ) # This handles the descriptor being applied on a # instance or a class and makes both work correctly... @@ -165,13 +180,13 @@ class moved_read_only_property: def moved_method( - new_method_name, - message=None, - version=None, - removal_version=None, - stacklevel=3, - category=None, -): + new_method_name: str, + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + category: type[Warning] | None = None, +) -> Callable[[Callable[P, R]], Callable[P, R]]: """Decorates an *instance* method that was moved to another location.""" if not new_method_name.endswith(_MOVED_CALLABLE_POSTFIX): new_method_name += _MOVED_CALLABLE_POSTFIX @@ -188,13 +203,13 @@ def moved_method( def moved_property( - new_attribute_name, - message=None, - version=None, - removal_version=None, - stacklevel=3, - category=None, -): + new_attribute_name: str, + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + category: type[Warning] | None = None, +) -> Callable[[Callable[P, R]], Callable[P, R]]: """Decorates an *instance* property that was moved to another location.""" return _moved_decorator( 'Property', @@ -208,15 +223,15 @@ def moved_property( def moved_class( - new_class, - old_class_name, - old_module_name, - message=None, - version=None, - removal_version=None, - stacklevel=3, - category=None, -): + new_class: type[T], + old_class_name: str, + old_module_name: str, + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + category: type[Warning] | None = None, +) -> type[T]: """Deprecates a class that was moved to another location. This creates a 'new-old' type that can be used for a @@ -241,17 +256,17 @@ def moved_class( removal_version=removal_version, ) - def decorator(f): + def decorator(f: Callable[P, R]) -> Callable[P, R]: @functools.wraps(f, assigned=_utils.get_assigned(f)) - def wrapper(self, *args, **kwargs): + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: _utils.deprecation( out_message, stacklevel=stacklevel, category=category ) - return f(self, *args, **kwargs) + return f(*args, **kwargs) return wrapper old_class = type(old_class_name, (new_class,), {}) old_class.__module__ = old_module_name - old_class.__init__ = decorator(old_class.__init__) + old_class.__init__ = decorator(old_class.__init__) # type: ignore return old_class diff --git a/debtcollector/py.typed b/debtcollector/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/debtcollector/removals.py b/debtcollector/removals.py index a31a1a8..93965c0 100644 --- a/debtcollector/removals.py +++ b/debtcollector/removals.py @@ -12,19 +12,40 @@ # License for the specific language governing permissions and limitations # under the License. +from __future__ import annotations + +import builtins +from collections.abc import Callable import functools import inspect +import types +from typing import Any +from typing import overload +from typing import ParamSpec +from typing import TypeVar import wrapt from debtcollector import _utils +P = ParamSpec('P') +R = TypeVar('R') +T = TypeVar('T') -def _get_qualified_name(obj): + +def _get_qualified_name( + obj: Callable[..., Any] | types.ModuleType | builtins.function, +) -> str: return _utils.get_qualified_name(obj)[1] -def _fetch_first_result(fget, fset, fdel, apply_func, value_not_found=None): +def _fetch_first_result( + fget: Callable[[Any], Any] | None, + fset: Callable[[Any, Any], None] | None, + fdel: Callable[[Any], None] | None, + apply_func: Callable[[Callable[..., Any] | builtins.function], str], + value_not_found: str | None = None, +) -> str | None: """Fetch first non-none/empty result of applying ``apply_func``.""" for f in filter(None, (fget, fset, fdel)): result = apply_func(f) @@ -61,17 +82,24 @@ class removed_property(property): 'delete': "Deleting the '%s' property is deprecated", } + stacklevel: int + category: type[Warning] + version: str | None + removal_version: str | None + message: str | None + _message_cache: dict[str, str] + def __init__( self, - fget=None, - fset=None, - fdel=None, - doc=None, - stacklevel=3, - category=DeprecationWarning, - version=None, - removal_version=None, - message=None, + fget: Callable[[Any], Any] | None = None, + fset: Callable[[Any, Any], None] | None = None, + fdel: Callable[[Any], None] | None = None, + doc: str | None = None, + stacklevel: int = 3, + category: type[Warning] = DeprecationWarning, + version: str | None = None, + removal_version: str | None = None, + message: str | None = None, ): if doc is None and inspect.isfunction(fget): doc = getattr(fget, '__doc__', None) @@ -83,7 +111,7 @@ class removed_property(property): self.message = message self._message_cache = {} - def _fetch_message_from_cache(self, kind): + def _fetch_message_from_cache(self, kind: str) -> str: try: out_message = self._message_cache[kind] except KeyError: @@ -104,7 +132,11 @@ class removed_property(property): self._message_cache[kind] = out_message return out_message - def __call__(self, fget, **kwargs): + def __call__( + self, + fget: Callable[[Any], Any], + **kwargs: Any, + ) -> removed_property: return type(self)( fget, self.fset, @@ -117,7 +149,7 @@ class removed_property(property): kwargs.get('message', self.message), ) - def __delete__(self, obj): + def __delete__(self, obj: Any) -> None: if self.fdel is None: raise AttributeError("can't delete attribute") out_message = self._fetch_message_from_cache('delete') @@ -126,17 +158,17 @@ class removed_property(property): ) self.fdel(obj) - def __set__(self, obj, value): + def __set__(self, instance: Any, value: Any) -> None: if self.fset is None: raise AttributeError("can't set attribute") out_message = self._fetch_message_from_cache('set') _utils.deprecation( out_message, stacklevel=self.stacklevel, category=self.category ) - self.fset(obj, value) + self.fset(instance, value) - def __get__(self, obj, value): - if obj is None: + def __get__(self, instance: Any, owner: type | None = None) -> Any: + if instance is None: return self if self.fget is None: raise AttributeError("unreadable attribute") @@ -144,9 +176,9 @@ class removed_property(property): _utils.deprecation( out_message, stacklevel=self.stacklevel, category=self.category ) - return self.fget(obj) + return self.fget(instance) - def getter(self, fget): + def getter(self, fget: Callable[[Any], Any], /) -> removed_property: return type(self)( fget, self.fset, @@ -159,7 +191,7 @@ class removed_property(property): self.message, ) - def setter(self, fset): + def setter(self, fset: Callable[[Any, Any], None], /) -> removed_property: return type(self)( self.fget, fset, @@ -172,7 +204,7 @@ class removed_property(property): self.message, ) - def deleter(self, fdel): + def deleter(self, fdel: Callable[[Any], None], /) -> removed_property: return type(self)( self.fget, self.fset, @@ -186,14 +218,36 @@ class removed_property(property): ) +@overload def remove( - f=None, - message=None, - version=None, - removal_version=None, - stacklevel=3, - category=None, -): + f: Callable[P, R], + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + category: type[Warning] | None = None, +) -> Callable[P, R]: ... + + +@overload +def remove( + f: None = None, + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + category: type[Warning] | None = None, +) -> Callable[[Callable[P, R]], Callable[P, R]]: ... + + +def remove( + f: Callable[P, R] | None = None, + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + category: type[Warning] | None = None, +) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]: """Decorates a function, method, or class to emit a deprecation warning Due to limitations of the wrapt library (and python) itself, if this @@ -223,7 +277,12 @@ def remove( ) @wrapt.decorator - def wrapper(f, instance, args, kwargs): + def wrapper( + wrapped: Callable[P, R], + instance: Any, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> R: qualified, f_name = _utils.get_qualified_name(f) if qualified: if inspect.isclass(f): @@ -240,7 +299,10 @@ def remove( if inspect.isclass(f): prefix_pre = "Using class" thing_post = '' - module_name = _get_qualified_name(inspect.getmodule(f)) + module = inspect.getmodule(f) + if module is None: + raise TypeError('Could not retrieve module for {f}') + module_name = _get_qualified_name(module) if module_name == '__main__': f_name = _utils.get_class_name( f, fully_qualified=False @@ -250,7 +312,10 @@ def remove( # Decorator was a used on a function else: thing_post = '()' - module_name = _get_qualified_name(inspect.getmodule(f)) + module = inspect.getmodule(f) + if module is None: + raise TypeError('Could not retrieve module for {f}') + module_name = _get_qualified_name(module) if module_name != '__main__': f_name = _utils.get_callable_name(f) # Decorator was used on a classmethod or instancemethod @@ -277,21 +342,20 @@ def remove( _utils.deprecation( out_message, stacklevel=stacklevel, category=category ) - return f(*args, **kwargs) + return wrapped(*args, **kwargs) return wrapper(f) def removed_kwarg( - old_name, - message=None, - version=None, - removal_version=None, - stacklevel=3, - category=None, -): + old_name: str, + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + category: type[Warning] | None = None, +) -> Callable[[Callable[P, R]], Callable[P, R]]: """Decorates a kwarg accepting function to deprecate a removed kwarg.""" - prefix = f"Using the '{old_name}' argument is deprecated" out_message = _utils.generate_message( prefix, @@ -302,30 +366,37 @@ def removed_kwarg( ) @wrapt.decorator - def wrapper(f, instance, args, kwargs): + def wrapper( + wrapped: Callable[P, R], + instance: Any, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> R: if old_name in kwargs: _utils.deprecation( out_message, stacklevel=stacklevel, category=category ) - return f(*args, **kwargs) + return wrapped(*args, **kwargs) - return wrapper + # FIXME(stephenfin): Depends on [1] or similar + # [1] https://github.com/GrahamDumpleton/wrapt/pull/306 + return wrapper # type: ignore[return-value] def removed_class( - cls_name, - replacement=None, - message=None, - version=None, - removal_version=None, - stacklevel=3, - category=None, -): + cls_name: str, + replacement: None = None, + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + category: type[Warning] | None = None, +) -> Callable[[T], T]: """Decorates a class to denote that it will be removed at some point.""" - def _wrap_it(old_init, out_message): + def _wrap_it(old_init: Any, out_message: str) -> Any: @functools.wraps(old_init, assigned=_utils.get_assigned(old_init)) - def new_init(self, *args, **kwargs): + def new_init(self: Any, *args: Any, **kwargs: Any) -> Any: _utils.deprecation( out_message, stacklevel=stacklevel, category=category ) @@ -333,19 +404,17 @@ def removed_class( return new_init - def _check_it(cls): + def _cls_decorator(cls: T) -> T: if not inspect.isclass(cls): _qual, type_name = _utils.get_qualified_name(type(cls)) raise TypeError( - f"Unexpected class type '{type_name}' (expected" - " class type only)" + f"Unexpected class type '{type_name}' (expected " + f"class type only)" ) - def _cls_decorator(cls): - _check_it(cls) out_message = _utils.generate_message( - f"Using class '{cls_name}' (either directly or via inheritance)" - " is deprecated", + f"Using class '{cls_name}' (either directly or via inheritance) " + f"is deprecated", postfix=None, message=message, version=version, @@ -358,14 +427,14 @@ def removed_class( def removed_module( - module, - replacement=None, - message=None, - version=None, - removal_version=None, - stacklevel=3, - category=None, -): + module: types.ModuleType | str, + replacement: str | None = None, + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + category: type[Warning] | None = None, +) -> None: """Helper to be called inside a module to emit a deprecation warning :param str replacment: A location (or information about) of any potential @@ -387,8 +456,8 @@ def removed_module( else: _qual, type_name = _utils.get_qualified_name(type(module)) raise TypeError( - f"Unexpected module type '{type_name}' (expected string or" - " module type only)" + f"Unexpected module type '{type_name}' (expected string or " + f"module type only)" ) prefix = f"The '{module_name}' module usage is deprecated" if replacement: diff --git a/debtcollector/renames.py b/debtcollector/renames.py index 3de6daa..e2cd88d 100644 --- a/debtcollector/renames.py +++ b/debtcollector/renames.py @@ -12,6 +12,11 @@ # License for the specific language governing permissions and limitations # under the License. +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + import wrapt from debtcollector import _utils @@ -20,16 +25,17 @@ _KWARG_RENAMED_POSTFIX_TPL = ", please use the '%s' argument instead" _KWARG_RENAMED_PREFIX_TPL = "Using the '%s' argument is deprecated" +# TODO(stephenfin): Figure out typing for return values def renamed_kwarg( - old_name, - new_name, - message=None, - version=None, - removal_version=None, - stacklevel=3, - category=None, - replace=False, -): + old_name: str, + new_name: str, + message: str | None = None, + version: str | None = None, + removal_version: str | None = None, + stacklevel: int = 3, + category: type[Warning] | None = None, + replace: bool = False, +) -> Any: """Decorates a kwarg accepting function to deprecate a renamed kwarg.""" prefix = _KWARG_RENAMED_PREFIX_TPL % old_name @@ -43,7 +49,12 @@ def renamed_kwarg( ) @wrapt.decorator - def decorator(wrapped, instance, args, kwargs): + def decorator( + wrapped: Callable[..., Any], + instance: Any, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> Any: if old_name in kwargs: _utils.deprecation( out_message, stacklevel=stacklevel, category=category diff --git a/debtcollector/tests/test_deprecation.py b/debtcollector/tests/test_deprecation.py index cedb444..b0d6e32 100644 --- a/debtcollector/tests/test_deprecation.py +++ b/debtcollector/tests/test_deprecation.py @@ -165,11 +165,11 @@ class ThingB: def green_tristars(self): return 'green' - @green_tristars.setter + @green_tristars.setter # type: ignore[no-redef] def green_tristars(self, value): pass - @green_tristars.deleter + @green_tristars.deleter # type: ignore[no-redef] def green_tristars(self): pass @@ -244,7 +244,7 @@ class MovedInheritableClassTest(test_base.TestCase): self.assertEqual(PendingDeprecationWarning, w.category) def test_existing_refer_subclass(self): - class MyOldThing(OldHotness): + class MyOldThing(OldHotness): # type: ignore[valid-type,misc] pass with warnings.catch_warnings(record=True) as capture: @@ -512,7 +512,7 @@ class RemovalTests(test_base.TestCase): warnings.simplefilter("always") o = ThingB() self.assertEqual('green', o.green_tristars) - o.green_tristars = 'b' + o.green_tristars = 'b' # type: ignore[method-assign] del o.green_tristars self.assertEqual(3, len(capture)) w = capture[0] diff --git a/debtcollector/updating.py b/debtcollector/updating.py index e77ff2d..752d0c3 100644 --- a/debtcollector/updating.py +++ b/debtcollector/updating.py @@ -12,9 +12,13 @@ # License for the specific language governing permissions and limitations # under the License. -import wrapt +from __future__ import annotations +from collections.abc import Callable from inspect import signature +from typing import Any + +import wrapt from debtcollector import _utils @@ -26,15 +30,16 @@ _KWARG_UPDATED_PREFIX_TPL = ( ) +# TODO(stephenfin): Figure out typing for return values def updated_kwarg_default_value( - name, - old_value, - new_value, - message=None, - version=None, - stacklevel=3, - category=FutureWarning, -): + name: str, + old_value: str, + new_value: str, + message: str | None = None, + version: str | None = None, + stacklevel: int = 3, + category: type[Warning] = FutureWarning, +) -> Any: """Decorates a kwarg accepting function to change the default value""" prefix = _KWARG_UPDATED_PREFIX_TPL % (name, new_value) @@ -43,12 +48,17 @@ def updated_kwarg_default_value( prefix, postfix=postfix, message=message, version=version ) - def decorator(f): + def decorator(f: Callable[..., Any]) -> Callable[..., Any]: sig = signature(f) varnames = list(sig.parameters.keys()) @wrapt.decorator - def wrapper(wrapped, instance, args, kwargs): + def wrapper( + wrapped: Callable[..., Any], + instance: Any, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> Any: explicit_params = set(varnames[: len(args)] + list(kwargs.keys())) allparams = set(varnames) default_params = set(allparams - explicit_params) diff --git a/pyproject.toml b/pyproject.toml index 57d65d1..2e71739 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Typing :: Typed", ] [project.urls] @@ -35,6 +36,20 @@ packages = [ "debtcollector" ] +[tool.mypy] +python_version = "3.10" +show_column_numbers = true +show_error_context = true +strict = true +disable_error_code = ["import-untyped"] +exclude = "(?x)(doc | releasenotes)" + +[[tool.mypy.overrides]] +module = ["debtcollector.tests.*"] +disallow_untyped_calls = false +disallow_untyped_defs = false +disallow_subclassing_any = false + [tool.ruff] line-length = 79 diff --git a/tox.ini b/tox.ini index 4fe36d1..7838442 100644 --- a/tox.ini +++ b/tox.ini @@ -4,9 +4,9 @@ envlist = py3,pep8 [testenv] deps = - -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} - -r{toxinidir}/test-requirements.txt - -r{toxinidir}/requirements.txt + -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -r{toxinidir}/test-requirements.txt + -r{toxinidir}/requirements.txt commands = stestr run --slowest {posargs} @@ -14,12 +14,25 @@ commands = commands = oslo_debug_helper {posargs} [testenv:pep8] +description = + Run style checks. deps = - pre-commit>=2.6.0 # MIT + pre-commit {[testenv:docs]deps} + {[testenv:mypy]deps} commands = pre-commit run -a sphinx-build -b doctest doc/source doc/build + {[testenv:mypy]commands} + +[testenv:mypy] +description = + Run type checks. +deps = + {[testenv]deps} + mypy +commands = + mypy --cache-dir="{envdir}/mypy_cache" {posargs:debtcollector} [testenv:venv] commands = {posargs} @@ -39,12 +52,16 @@ deps = -r{toxinidir}/doc/requirements.txt commands = sphinx-build -W --keep-going -b html -d doc/build/doctrees doc/source doc/build/html +[testenv:releasenotes] +deps = {[testenv:docs]deps} +commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html + [flake8] # We only enable the hacking (H) checks select = H exclude = .venv,.git,.tox,dist,doc,*lib/python*,*egg,build -[testenv:releasenotes] -deps = {[testenv:docs]deps} -commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html - +[hacking] +import_exceptions = + collections.abc + typing