diff --git a/.circleci/config.yml b/.circleci/config.yml index ea7148b39b..63f27ef112 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -108,6 +108,10 @@ jobs: git fetch .circleci/is-version-number-acceptable.sh + - run: + name: Check expired deprecations + command: make check-deprecated + submit_coverage: docker: - image: python:3.7 diff --git a/CHANGELOG.md b/CHANGELOG.md index ec66f40e3c..0454cb6d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +# 36.0.0 [#1044](https://github.com/openfisca/openfisca-core/pull/1044) + +Follows discussion on #1033 + +#### New features + +- Introduce `make check-deprecated` + - Allows for listing the features marked as deprecated. + +Example: + +``` +$ make check-deprecated + +[⚙] Check for features marked as deprecated... +[!] commons.dummy.__init__:17 => Deprecated since: 34.7.0. Expiration status: 36.0.0 (current: 36.0.0). +[/] 18% |█████████·········································| +``` + ### 35.5.1 [#1046](https://github.com/openfisca/openfisca-core/pull/1046) #### Non-technical changes diff --git a/Makefile b/Makefile index 2691268681..b5c73a5ff8 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ print_pass = echo $$(tput setaf 2)[✓]$$(tput sgr0) $$(tput setaf 8)$1$$(tput s ## Similar to `print_work`, but this will read the comments above a task, and ## print them to the user at the start of each task. The `$1` is a function ## argument. -print_help = sed -n "/^$1/ { x ; p ; } ; s/\#\#/$(print_work)/ ; s/\./…/ ; x" ${MAKEFILE_LIST} +print_help = sed -n "/^$1/ { x ; p ; } ; s/\#\#/\r$(print_work)/ ; s/\./…/ ; x" ${MAKEFILE_LIST} ## Same as `make`. .DEFAULT_GOAL := all diff --git a/openfisca_core/commons/__init__.py b/openfisca_core/commons/__init__.py index db41ed1874..42c1ada223 100644 --- a/openfisca_core/commons/__init__.py +++ b/openfisca_core/commons/__init__.py @@ -23,6 +23,7 @@ from .dummy import Dummy # noqa: F401 +from .decorators import deprecated # noqa: F401 from .formulas import apply_thresholds, concat, switch # noqa: F401 from .misc import empty_clone, stringify_array # noqa: F401 from .rates import average_rate, marginal_rate # noqa: F401 diff --git a/openfisca_core/commons/decorators.py b/openfisca_core/commons/decorators.py new file mode 100644 index 0000000000..b4e7891dbe --- /dev/null +++ b/openfisca_core/commons/decorators.py @@ -0,0 +1,111 @@ +import functools +import warnings +import typing +from typing import Any, Callable, Sequence, TypeVar + +T = Callable[..., Any] +F = TypeVar("F", bound = T) + + +class deprecated: + """Allows (soft) deprecating a functionality of OpenFisca. + + Attributes: + since: + Since when the functionality is deprecated. + expires: + When will it be removed forever? + + Args: + since: + Since when the functionality is deprecated. + expires: + When will it be removed forever? Note that this value, if set to a + valid semantic version, it has to be a major one. + + Raises: + ValueError: + When :attr:`expires` is set to a version, but not to a major one. + + + Examples: + >>> @deprecated(since = "35.5.0", expires = "in the future") + ... def obsolete(): + ... return "I'm obsolete!" + + >>> repr(obsolete) + '' + + >>> str(obsolete) + '' + + .. versionadded:: 36.0.0 + + """ + + since: str + expires: str + + def __init__(self, *, since: str, expires: str) -> None: + self.since = since + self.expires = self._parse(expires) + + def __call__(self, function: F) -> F: + """Wraps a function to return another one, decorated. + + Args: + function: The function or method to decorate. + + Returns: + :obj:`callable`: The decorated function. + + Examples: + >>> def obsolete(): + ... return "I'm obsolete!" + + >>> decorator = deprecated( + ... since = "35.5.0", + ... expires = "in the future", + ... ) + + >>> decorator(obsolete) + + + """ + + def wrapper(*args: Any, **kwds: Any) -> Any: + message: Sequence[str] + message = [ + f"{function.__qualname__} has been deprecated since", + f"version {self.since}, and will be removed in", + f"{self.expires}.", + ] + + warnings.warn(" ".join(message), DeprecationWarning) + return function(*args, **kwds) + + functools.update_wrapper(wrapper, function) + return typing.cast(F, wrapper) + + @staticmethod + def _parse(expires: str) -> str: + minor: str + patch: str + message: Sequence[str] + + if expires.find(".") == -1: + return expires + + _, minor, patch, *_ = expires.split(".") + + if minor != "0" or patch != "0": + message = [ + "Deprecations can only expire on major releases.", + f"Or, {expires} is not a major one.", + "To learn more about semantic versioning:", + "https://semver.org/" + ] + + raise ValueError(" ".join(message)) + + return expires diff --git a/openfisca_core/commons/dummy.py b/openfisca_core/commons/dummy.py index 4136a0d429..732ed49a65 100644 --- a/openfisca_core/commons/dummy.py +++ b/openfisca_core/commons/dummy.py @@ -1,13 +1,18 @@ -import warnings +from .decorators import deprecated class Dummy: - """A class that does nothing.""" + """A class that did nothing. + Examples: + >>> Dummy() + None: - message = [ - "The 'Dummy' class has been deprecated since version 34.7.0,", - "and will be removed in the future.", - ] - warnings.warn(" ".join(message), DeprecationWarning) - pass + ... diff --git a/openfisca_core/commons/tests/__init__.py b/openfisca_core/commons/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openfisca_core/commons/tests/test_decorators.py b/openfisca_core/commons/tests/test_decorators.py new file mode 100644 index 0000000000..d49f809656 --- /dev/null +++ b/openfisca_core/commons/tests/test_decorators.py @@ -0,0 +1,31 @@ +import re + +import pytest + +from openfisca_core.commons import deprecated + + +def test_deprecated(): + """The decorated function throws a deprecation warning when used.""" + + since = "yesterday" + expires = "tomorrow" + message = re.compile(f"^.*{since}.*{expires}.*$") + + @deprecated(since = since, expires = expires) + def function(a: int, b: float) -> float: + return a + b + + with pytest.warns(DeprecationWarning, match = message): + assert function(1, 2.) == 3. + + +def test_deprecated_when_illegal(): + """Raises an error when the deprecation expiration is not a major.""" + + since = "yesterday" + expires = "1.2.3" + message = "Deprecations can only expire on major releases" + + with pytest.raises(ValueError, match = message): + deprecated(since = since, expires = expires) diff --git a/openfisca_core/commons/tests/test_dummy.py b/openfisca_core/commons/tests/test_dummy.py new file mode 100644 index 0000000000..d4ecec3842 --- /dev/null +++ b/openfisca_core/commons/tests/test_dummy.py @@ -0,0 +1,10 @@ +import pytest + +from openfisca_core.commons import Dummy + + +def test_dummy_deprecation(): + """Dummy throws a deprecation warning when instantiated.""" + + with pytest.warns(DeprecationWarning): + assert Dummy() diff --git a/openfisca_tasks/__init__.py b/openfisca_tasks/__init__.py new file mode 100644 index 0000000000..f203d211dc --- /dev/null +++ b/openfisca_tasks/__init__.py @@ -0,0 +1 @@ +from ._check_deprecated import CheckDeprecated # noqa: F401 diff --git a/openfisca_tasks/__main__.py b/openfisca_tasks/__main__.py new file mode 100644 index 0000000000..87d7879a22 --- /dev/null +++ b/openfisca_tasks/__main__.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import sys + +import openfisca_tasks as tasks + +from ._progress_bar import ProgressBar +from ._protocols import HasExit, SupportsProgress + + +if __name__ == "__main__": + task: HasExit + task = tasks.__getattribute__(sys.argv[1])() + + progress: SupportsProgress + progress = ProgressBar() + + task(progress) + sys.exit(task.exit) diff --git a/openfisca_tasks/_check_deprecated.py b/openfisca_tasks/_check_deprecated.py new file mode 100644 index 0000000000..5e31edc711 --- /dev/null +++ b/openfisca_tasks/_check_deprecated.py @@ -0,0 +1,188 @@ +import ast +import pathlib +import pkg_resources +import subprocess +import sys +import textwrap +from typing import Sequence + +from typing_extensions import Literal + +from ._protocols import SupportsProgress + +EXIT_OK: Literal[0] +EXIT_OK = 0 + +EXIT_KO: Literal[1] +EXIT_KO = 1 + +FILES: Sequence[str] +FILES = \ + subprocess \ + .run(["git", "ls-files", "*.py"], stdout = subprocess.PIPE) \ + .stdout \ + .decode("utf-8") \ + .split() + +VERSION: str +VERSION = \ + pkg_resources \ + .get_distribution("openfisca_core") \ + .version + + +class CheckDeprecated(ast.NodeVisitor): + """Prints the list of features marked as deprecated. + + Attributes: + count: + The index of the current ``node`` traversal. Defaults to ``0``. + exit: + The exit code for the task handler. + files: + The list of files to analyse. + nodes: + The corresponding :mod:`ast` of each ``file``. + version: + The version to use for the expiration check. + + Args: + files: + The list of files to analyse. Defaults to the list of ``.py`` files + tracked by ``git``. + version: + The version to use for the expiration check. Defaults to the + current version of :mod:`.openfisca_core`. + + .. versionadded:: 36.0.0 + + """ + + count: int + exit: Literal[0, 1] + files: Sequence[str] + nodes: Sequence[ast.Module] + progress: SupportsProgress + total: int + version: str + + def __init__( + self, + files: Sequence[str] = FILES, + version: str = VERSION, + ) -> None: + self.exit = EXIT_OK + self.files = files + self.nodes = [self._node(file) for file in self.files] + self.total = len(self.nodes) + self.version = version + + def __call__(self, progress: SupportsProgress) -> None: + self.progress = progress + self.progress.init() + + # We use ``count`` to link each ``node`` with the corresponding + # ``file``. + for count, node in enumerate(self.nodes): + self.count = count + self.visit(node) + self.progress.push(self.count, self.total) + + self.progress.wipe() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + """Defines the ``visit()`` function to inspect the ``node``. + + Args: + node: The :mod:`ast` node to inspect. + + Attributes: + keywords: + The decorator's keywords, see :mod:`.decorators`. + file: + The path of a file containing a module. + path: + The resolved ``file`` path. + module: + The name of the module. + lineno: + The line number of each ``node``. + since: + The ``since`` keyword's value. + expires: + The ``expires`` keyword's value. + message: + The message we will print to the user. + + .. versionadded:: 36.0.0 + + """ + + keywords: Sequence[str] + file: str + path: pathlib.Path + module: str + lineno: int + since: str + expires: str + message: Sequence[str] + + # We look for the corresponding ``file``. + file = self.files[self.count] + + # We find the absolute path of the file. + path = pathlib.Path(file).resolve() + + # We build the module name with the name of the parent path, a + # folder, and the name of the file, without the extension. + module = f"{path.parts[-2]}.{path.stem}" + + # We assume the function is defined just one line after the + # decorator. + lineno = node.lineno + 1 + + for decorator in node.decorator_list: + # We cast the ``decorator`` to ``callable``. + if not isinstance(decorator, ast.Call): + continue + + # We only print out the deprecated functions. + if "deprecated" not in ast.dump(decorator): + continue + + # We cast each keyword to ``str``. + keywords = [ + kwd.value.s + for kwd in decorator.keywords + if isinstance(kwd.value, ast.Str) + ] + + # Finally we assign each keyword to a variable. + since, expires = keywords + + message = [ + f"{module}.{node.name}:{lineno} =>", + f"Deprecated since: {since}.", + f"Expiration status: {expires}", + f"(current: {self.version}).", + ] + + self.progress.warn(f"{' '.join(message)}") + + # If there is at least one expired deprecation, the handler + # will exit with an error. + if self._isthis(expires): + self.exit = EXIT_KO + self.progress.fail() + + sys.stdout.write("\n") + + def _isthis(self, version: str) -> bool: + return self.version == version + + def _node(self, file: str) -> ast.Module: + source: str + + with open(file, "r") as f: + source = textwrap.dedent(f.read()) + return ast.parse(source, file, "exec") diff --git a/openfisca_tasks/_progress_bar.py b/openfisca_tasks/_progress_bar.py new file mode 100644 index 0000000000..43cb55c45c --- /dev/null +++ b/openfisca_tasks/_progress_bar.py @@ -0,0 +1,71 @@ +from typing import Sequence + +import sys +import termcolor + + +WORK_ICON: str +WORK_ICON = termcolor.colored("[/]", "cyan") + +WARN_ICON: str +WARN_ICON = termcolor.colored("[i]", "yellow") + +FAIL_ICON: str +FAIL_ICON = termcolor.colored("[!]", "red") + +BAR_ICON: str +BAR_ICON = termcolor.colored("|", "green") + +ACC_ICON: str +ACC_ICON = termcolor.colored("✓", "green") + +ETA_ICON: str +ETA_ICON = termcolor.colored("·", "green") + +BAR_SIZE: int +BAR_SIZE = 50 + + +class ProgressBar: + + def init(self) -> None: + sys.stdout.write(self._init_message()) + + def push(self, count: int, total: int) -> None: + done: int + + done = (count + 1) * 100 // total + + sys.stdout.write(self._push_message(done)) + + def warn(self, message: str) -> None: + sys.stdout.write(f"{WARN_ICON} {message}") + + def fail(self) -> None: + sys.stdout.write(f"\r{FAIL_ICON}") + + def wipe(self) -> None: + sys.stdout.write(self._wipe_message()) + + def _init_message(self) -> str: + return f"{WORK_ICON} 0% {BAR_ICON}{ETA_ICON * BAR_SIZE}{BAR_ICON}\r" + + def _push_message(self, done: int) -> str: + message: Sequence[str] + spaces: str + + spaces = "" + spaces += [' ', ''][done >= BAR_SIZE * 2] + spaces += [' ', ''][done >= BAR_SIZE // 5] + + message = [ + f"{WORK_ICON} {done}% {spaces}{BAR_ICON}" + f"{ACC_ICON * (done // 2)}" + f"{ETA_ICON * (BAR_SIZE - done // 2)}" + f"{BAR_ICON}\r" + ] + + return "".join(message) + + def _wipe_message(self) -> str: + return f"{' ' * BAR_SIZE * 2}\r" diff --git a/openfisca_tasks/_protocols.py b/openfisca_tasks/_protocols.py new file mode 100644 index 0000000000..8b307e2550 --- /dev/null +++ b/openfisca_tasks/_protocols.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import abc + +from typing_extensions import Protocol + + +class HasExit(Protocol): + exit: int + + @abc.abstractmethod + def __call__(self, __progress: SupportsProgress) -> None: + ... + + +class SupportsProgress(Protocol): + + @abc.abstractmethod + def init(self) -> None: + ... + + @abc.abstractmethod + def push(self, __count: int, __total: int) -> None: + ... + + @abc.abstractmethod + def warn(self, __message: str) -> None: + ... + + @abc.abstractmethod + def fail(self) -> None: + ... + + @abc.abstractmethod + def wipe(self) -> None: + ... diff --git a/openfisca_tasks/publish.mk b/openfisca_tasks/publish.mk index 2f34599fd9..b6d5016a6c 100644 --- a/openfisca_tasks/publish.mk +++ b/openfisca_tasks/publish.mk @@ -5,3 +5,9 @@ build: setup.py @$(call print_help,$@:) @python $? bdist_wheel @find dist -name "*.whl" -exec pip install --force-reinstall {}[dev] \; + +## Check for features marked as deprecated. +check-deprecated: + @$(call print_help,$@:) + @python -m openfisca_tasks CheckDeprecated + @$(call print_pass,$@:) diff --git a/openfisca_tasks/tests/__init__.py b/openfisca_tasks/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openfisca_tasks/tests/test_check_deprecated.py b/openfisca_tasks/tests/test_check_deprecated.py new file mode 100644 index 0000000000..5395b4ab6c --- /dev/null +++ b/openfisca_tasks/tests/test_check_deprecated.py @@ -0,0 +1,96 @@ +import inspect +import os +import sys +import tempfile +from typing import NamedTuple + +import pytest + +from openfisca_tasks import CheckDeprecated + +from .._protocols import SupportsProgress + + +class Module: + """Some module with an expired function.""" + + def __init__(self, expires = "never"): + self.module = [ + b"from openfisca_core.commons import deprecated", + b"", + b"", + f"@deprecated(since = 'today', expires = '{expires}')".encode(), + b"def function() -> None:", + b" ..." + ] + + def __enter__(self): + self.file = tempfile.NamedTemporaryFile() + self.name = ".".join(self.file.name.split("/")[-2:]) + self.file.write(b"\n".join(self.module)) + self.file.seek(0) + return self.file, self.name + + def __exit__(self, *__): + self.file.close() + + +@pytest.fixture +def progress(): + + def name(): + return inspect.stack()[1][3] + + class Call(NamedTuple): + name: str + args: str = None + + class ProgressBar(SupportsProgress): + + def init(self): + self.called = [] + + def push(self, __count, __total): + ... + + def warn(self, message): + self.called.append(Call(name(), message)) + + def fail(self): + self.called.append(Call(name())) + + def wipe(self): + ... + + return ProgressBar() + + +def test_find_deprecated(progress): + """Prints out the features marked as deprecated.""" + + with Module() as (file, name): + checker = CheckDeprecated([file.name]) + checker(progress) + + with pytest.raises(SystemExit) as exit: + sys.exit(checker.exit) + + assert exit.value.code == os.EX_OK + assert progress.called[-1].name == "warn" + assert f"{name}.function:5" in progress.called[-1].args + + +def test_find_deprecated_when_expired(progress): + """Raises an error when at least one deprecation has expired.""" + + version = "1.0.0" + + with Module(version) as (file, _): + checker = CheckDeprecated([file.name], version) + checker(progress) + + with pytest.raises(SystemExit) as exit: + sys.exit(checker.exit) + + assert exit.value.code != os.EX_OK + assert progress.called[-1].name == "fail" diff --git a/setup.cfg b/setup.cfg index 4f98591eeb..0698e4f6c6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,16 +8,19 @@ hang-closing = true ignore = E128,E251,F403,F405,E501,W503,W504 in-place = true -rst-roles = any, class, exc, meth, obj -rst-directives = attribute +rst-roles = any, attr, class, exc, meth, mod, obj +rst-directives = attribute, deprecated, versionadded [tool:pytest] addopts = --showlocals --doctest-modules --disable-pytest-warnings -testpaths = tests +testpaths = openfisca_tasks tests python_files = **/*.py [mypy] ignore_missing_imports = True +[mypy-openfisca_tasks.tests.*] +ignore_errors = True + [mypy-openfisca_core.scripts.*] ignore_errors = True diff --git a/setup.py b/setup.py index 396435263d..9ff7670317 100644 --- a/setup.py +++ b/setup.py @@ -28,15 +28,17 @@ 'flake8-bugbear >= 19.3.0, < 20.0.0', 'flake8-print >= 3.1.0, < 4.0.0', 'flake8-rst-docstrings < 1.0.0', - 'pytest-cov >= 2.6.1, < 3.0.0', 'mypy >= 0.701, < 0.800', 'openfisca-country-template >= 3.10.0, < 4.0.0', - 'openfisca-extension-template >= 1.2.0rc0, < 2.0.0' + 'openfisca-extension-template >= 1.2.0rc0, < 2.0.0', + 'pytest-cov >= 2.6.1, < 3.0.0', + 'termcolor == 1.1.0', + 'typing-extensions == 3.10.0.2', ] + api_requirements setup( name = 'OpenFisca-Core', - version = '35.5.1', + version = '36.0.0', author = 'OpenFisca Team', author_email = 'contact@openfisca.org', classifiers = [