diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e696281 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +dist +.python-version \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..63b4b68 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 93d9419..193fb1e 100644 --- a/README.md +++ b/README.md @@ -1 +1,49 @@ -# Shitlist + +![Shitlist](https://github.com/samboyd/shitlist/assets/logo.svg?raw=true) + +--- + +Shitlist is a deprecation tool for python. It checks that deprecated code is only lessened in your codebase by +testing that any new code doesn't use already deprecated code. + +Add shitlist to your build script to check that deprecated code is gradually remove from your codebase. + +Inspired by a post by Simon Eskildsen, [Shitlist driven development (2016)](https://sirupsen.com/shitlists) + +## Installation + + + +```bash +pip install shitlist +``` + + +## Usage + +Initialize the config file. This will create a file `.shitlist` in the project root directory +```bash +shitlist init +``` + +Deprecate some code +```python +import shitlist + +@shitlist.deprecate( + alternative='You should use `new_function` because of X, Y & Z' +) +def old_function(): + pass +``` + +Update the config file with the newly deprecated code. Shitlist will look for usages of all your deprecated code +and update the config file +```bash +shitlist update +``` + +Test +```bash +shitlist test +``` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..bc873ef Binary files /dev/null and b/assets/logo.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4818815 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "shitlist" +version = "0.0.1" +authors = [ + { name="Sam Boyd", email="samboyd10@gmail.com" }, +] +description = "Shitlist deprecation" +readme = "README.md" +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +[project.urls] +"Homepage" = "https://github.com/samboyd/shitlist" +"Bug Tracker" = "https://github.com/samboyd/shitlist/issues" + +[project.scripts] +"shitlist"= "shitlist.cli:main" + +[tool.setuptools] +packages = ["shitlist"] + +[tool.pytest.ini_options] +pythonpath = [ + "." +] +addopts = "--ignore=dist --ignore=assets" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..35beb28 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +attrs==22.1.0 +build==0.9.0 +click==8.1.3 +exceptiongroup==1.0.0 +freezegun==1.2.2 +iniconfig==1.1.1 +packaging==21.3 +pep517==0.13.0 +PyHamcrest==2.0.4 +pyparsing==3.0.9 +pytest==7.2.0 +pytest-mock==3.10.0 +python-dateutil==2.8.2 +six==1.16.0 +tomli==2.0.1 diff --git a/shitlist/__init__.py b/shitlist/__init__.py new file mode 100644 index 0000000..8ddbf49 --- /dev/null +++ b/shitlist/__init__.py @@ -0,0 +1,203 @@ +import ast +import enum +import logging +import os +from pathlib import PosixPath +from typing import List, Callable, Optional + +from shitlist.config import Config +from shitlist.decorator_use_collector import DecoratorUseCollector +from shitlist.deprecated_code_use_collector import DeprecatedCodeUseCollector + +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) +logger = logging.getLogger(__name__) + + +class DeprecatedException(Exception): + pass + + +class UndefinedAlternativeException(Exception): + pass + + +class ErrorLevel(enum.Enum): + error = 'error' + warn = 'warn' + + +usages = [] +error_level = ErrorLevel.error + + +class WrongTypeError(Exception): + pass + + +def get_func_name(func: Callable): + filepath = func.__code__.co_filename + filepath = filepath.replace(ROOT_DIR, '') + func_name = func.__qualname__ + return f'{filepath}::{func_name}' + + +def deprecate(alternative: str): + if alternative is None: + raise UndefinedAlternativeException("alternative code not defined") + + def wrapped_deprecate(func): + # wrap a function + if type(func).__name__ == 'function': + def wrapper(): + if get_func_name(func) not in usages: + if error_level == ErrorLevel.error: + raise RuntimeError() + else: + func_name = func.__qualname__ + logger.info( + f'function {func_name} is registered on a shitlist and therefore should not' + f' be used by new code' + ) + func() + + wrapper.shitlist_deprecate = True + wrapper.wrapped_function = get_func_name(func) + + return wrapper + else: + raise WrongTypeError() + + return wrapped_deprecate + + +def gen_for_path( + root_path: PosixPath, + ignore_directories=[] +): + result = set() + walker = TreeWalker( + root_dir=root_path, + ignore_directories=ignore_directories + ) + + while walker.has_next: + path = walker.next_file() + module_name = path.stem + collector = DecoratorUseCollector(modulename=module_name) + with open(path, 'r') as f: + collector.visit(ast.parse(f.read())) + + module_relative_path = str(path).replace(str(root_path) + '/', '') + if '__init__' in module_relative_path: + module_relative_path = module_relative_path.replace('/__init__.py', '') + module_relative_path = module_relative_path.replace('.py', '').replace('/', '.') + result.update([f'{module_relative_path}::{thing}' for thing in collector.nodes_with_decorators]) + + return sorted(list(result)) + + +def test(existing_config: Config, new_config: Config): + for thing in existing_config.deprecated_code: + exiting_usages = set(existing_config.usage.get(thing, [])) + new_usages = set(new_config.usage.get(thing, [])) + dif = new_usages.difference(exiting_usages) + if len(dif) > 0: + raise DeprecatedException(f'Deprecated function {thing}, has new usages {dif}') + + +class TreeWalker: + has_next: bool + + def __init__(self, root_dir: PosixPath, ignore_directories: List[str] = []): + self.root_dir = root_dir + self._walker = os.walk(root_dir) + self._current_dir = None + self._current_files = [] + self.has_next = True + self.ignore_directories: List[PosixPath] = [root_dir / d for d in ignore_directories] + + self._gen_next() + + def _gen_next(self): + try: + self._current_dir, _, files = next(self._walker) + self._current_files = [f for f in files if f[-3:] == '.py'] # TODO could error on short named files + if not self._current_files or self.directory_should_be_ignored(self._current_dir): + self._gen_next() + except StopIteration: + self.has_next = False + + def next_file(self) -> Optional[PosixPath]: + if self.has_next: + next_file = self._current_files.pop() + full_path = PosixPath(self._current_dir) / next_file + + if len(self._current_files) == 0: + self._gen_next() + + return full_path + + def directory_should_be_ignored(self, dir) -> bool: + return any([True for ig_dir in self.ignore_directories if str(ig_dir) in dir]) + + +def find_usages( + root_path: PosixPath, + deprecated_code: List[str], + ignore_directories=[] +): + result = {} + walker = TreeWalker( + root_dir=root_path, + ignore_directories=ignore_directories + ) + + while walker.has_next: + path = walker.next_file() + module_name = path.stem + relative_path = str(path).replace(f'{root_path}/', '') + if module_name == '__init__': + relative_path = relative_path.replace('/__init__.py', '') + + package = relative_path.replace('.py', '').replace('/', '.') + + for thing in deprecated_code: + module, _, function_name = thing.rpartition('::') + module_package = module.split('.')[0] + collector = DeprecatedCodeUseCollector( + deprecated_code=function_name, + modulename=module, + package=module_package + ) + + with open(path, 'r') as f: + collector.visit(ast.parse(f.read())) + + if thing in result: + result[thing].extend([f'{package}::{u}' for u in collector.used_at]) + else: + result[thing] = [f'{package}::{u}' for u in collector.used_at] + return result + + +def update(existing_config: Config, new_config: Config): + merged_config = Config( + deprecated_code=new_config.deprecated_code, + usage=new_config.usage, + ignore_directories=existing_config.ignore_directories, + removed_usages=existing_config.removed_usages, + successfully_removed_things=existing_config.successfully_removed_things + ) + + merged_config.successfully_removed_things.extend([ + t for t in existing_config.deprecated_code if t not in new_config.deprecated_code + ]) + + for thing, new_usage in new_config.usage.items(): + if thing in existing_config.usage: + existing_usage = existing_config.usage[thing] + removed_usages = [u for u in existing_usage if u not in new_usage] + if removed_usages: + merged_config.removed_usages[thing] = list(removed_usages) + + return merged_config diff --git a/shitlist/cli.py b/shitlist/cli.py new file mode 100644 index 0000000..974e97e --- /dev/null +++ b/shitlist/cli.py @@ -0,0 +1,158 @@ +import logging +import os +from pathlib import PosixPath + +import click + +import shitlist + +logger = logging.getLogger(__name__) +logging.basicConfig( + level=logging.INFO, + format='%(message)s' +) + +config_filepath = '.shitlist' + + +class NoConfigFileException(Exception): + pass + + +@click.group() +def init_cli(): + pass + + +@init_cli.command() +def init(): + """Initialize the shitlist coniguration file + + Creates the .shitlist file in the project root directory + \f + """ + if os.path.exists(config_filepath): + logger.info('Initialized file already exists') + return + + click.echo(f"Initializing config file in {config_filepath}") + + cwd = os.getcwd() + + config = shitlist.Config() + config.write(config_filepath) + + +@click.group() +def test_cli(): + pass + + +@test_cli.command() +def test(): + """Test new usages of deprecated code + + The test fails if you introduce new usages of deprecated code + """ + if not os.path.exists(config_filepath): + logger.info('Cannot test there is no config file present') + raise NoConfigFileException() + + existing_config = shitlist.Config.from_file(config_filepath) + + cwd = PosixPath(os.getcwd()) + new_config = shitlist.Config.from_path( + cwd, + ignore_directories=existing_config.ignore_directories + ) + + shitlist.test( + existing_config=existing_config, + new_config=new_config + ) + + logger.info('Successfully tested deprecated code') + + if [dc for dc in new_config.deprecated_code if dc not in existing_config.deprecated_code]: + logger.info( + 'Warning: there is newly deprecated code not represented in config. ' + 'Run `shitlist update`' + ) + + +@click.group() +def update_cli(): + pass + + +@update_cli.command() +def update(): + """Update the config with removed usages + + Update the shitlist config with any newly deprecated code + """ + if not os.path.exists(config_filepath): + logger.info('Cannot test there is no config file present') + return + + existing_config = shitlist.Config.from_file(config_filepath) + + cwd = PosixPath(os.getcwd()) + + new_config = shitlist.Config.from_path( + cwd, + ignore_directories=existing_config.ignore_directories + ) + + shitlist.test( + existing_config=existing_config, + new_config=new_config + ) + + merged_config = shitlist.update( + existing_config=existing_config, + new_config=new_config + ) + + merged_config.write(config_filepath) + + +@click.group() +def progress_cli(): + pass + + +@progress_cli.command() +def progress(): + """Display deprecated code burn down table""" + + if not os.path.exists(config_filepath): + logger.info('Cannot test there is no config file present') + return + + config = shitlist.Config.from_file(config_filepath) + + progress_map = { + dc: (len(config.removed_usages.get(dc, [])), len(config.usage[dc]) + len(config.removed_usages.get(dc, []))) + for dc in config.deprecated_code + } + + logger.info('Progress removing deprecated code:') + + for dc in config.deprecated_code: + remaining, total = progress_map[dc] + + if total == 0: + logger.info(f' 100%\t[##########]\t{dc}') + else: + out_of_ten = int((remaining / total) * 10) + l = '#' * out_of_ten + s = '-' * (10 - out_of_ten) + logger.info(f' {out_of_ten * 10}%\t[{l}{s}]\t{dc}') + + +cli = click.CommandCollection(sources=[init_cli, test_cli, update_cli, progress_cli]) + + +def main(): + cli() diff --git a/shitlist/config.py b/shitlist/config.py new file mode 100644 index 0000000..4d73a4e --- /dev/null +++ b/shitlist/config.py @@ -0,0 +1,68 @@ +import json +from pathlib import PosixPath +from typing import List, Dict + +import shitlist + + +class Config: + ignore_directories: List[str] + deprecated_code: List[str] + usage: Dict[str, List[str]] + + def __init__( + self, + deprecated_code: List[str] = [], + usage: Dict[str, List[str]] = dict(), + removed_usages: Dict[str, List[str]] = dict(), + successfully_removed_things: List[str] = [], + ignore_directories: List[str] = [] + ): + self.deprecated_code = deprecated_code + self.usage = usage + self.removed_usages = removed_usages + self.successfully_removed_things = successfully_removed_things + self.ignore_directories = ignore_directories + + @staticmethod + def from_file(path: str) -> 'Config': + with open(path, 'r') as f: + return Config( + **json.load(f) + ) + + @staticmethod + def from_path(path: PosixPath, ignore_directories: List[str] = []) -> 'Config': + deprecated_code = shitlist.gen_for_path(path, ignore_directories=ignore_directories) + usage = shitlist.find_usages(path, deprecated_code, ignore_directories=ignore_directories) + + return Config( + deprecated_code=deprecated_code, + usage=usage + ) + + def __eq__(self, other: 'Config'): + return ( + self.deprecated_code == other.deprecated_code and + self.usage == other.usage and + self.removed_usages == other.removed_usages and + self.successfully_removed_things == other.successfully_removed_things + ) + + def __dict__(self): + return dict( + ignore_directories=self.ignore_directories, + deprecated_code=self.deprecated_code, + usage=self.usage, + removed_usages=self.removed_usages, + successfully_removed_things=self.successfully_removed_things, + ) + + def __repr__(self): + return f'Config({self.__dict__()})' + + def write(self, path): + with open(path, 'w', encoding='utf-8') as file: + json.dump(self.__dict__(), file, ensure_ascii=False, indent=4) + file.write('\n') + file.flush() diff --git a/shitlist/decorator_use_collector.py b/shitlist/decorator_use_collector.py new file mode 100644 index 0000000..3f92473 --- /dev/null +++ b/shitlist/decorator_use_collector.py @@ -0,0 +1,108 @@ +import ast +from collections import ChainMap +from types import MappingProxyType as readonlydict + + +class DecoratorUseCollector(ast.NodeVisitor): + def __init__(self, modulename, package=''): + self.modulename = modulename + # used to resolve from ... import ... references + self.package = package + self.modulepackage, _, self.modulestem = modulename.rpartition('.') + # track scope namespaces, with a mapping of imported names (bound name to original) + # If a name references None it is used for a different purpose in that scope + # and so masks a name in the global namespace. + self.scopes = ChainMap() + self.used_at = [] # list of (name, alias, line) entries + self.nodes_with_decorators = [] + + def _check_decorators(self, node): + for decorator in node.decorator_list: + # This will match calls to `@shitlist.deprecate(..)` + if ( + isinstance(decorator, ast.Call) and + 'func' in decorator.__dict__ and + isinstance(decorator.func, ast.Attribute) and + decorator.func.attr == 'deprecate' and + 'value' in decorator.func.__dict__ and + decorator.func.value.id == 'shitlist' + ): + self.nodes_with_decorators.append(node.name) + return + + # This will match calls to `@deprecate(..)` where deprecated has been imported from shitlist + if ( + isinstance(decorator, ast.Call) and + 'func' in decorator.__dict__ and + isinstance(decorator.func, ast.Name) and + decorator.func.id == 'deprecate' and + self.scopes.get('deprecate', None) == 'shitlist' + ): + self.nodes_with_decorators.append(node.name) + + def visit_FunctionDef(self, node): + self.scopes = self.scopes.new_child() + self._check_decorators(node) + self.generic_visit(node) + self.scopes = self.scopes.parents + + def visit_Lambda(self, node): + # lambdas are just functions, albeit with no statements + # self.visit_Function(node) + pass + + def visit_ClassDef(self, node): + # class scope is a special local scope that is re-purposed to form + # the class attributes. By using a read-only dict proxy here this code + # we can expect an exception when a class body contains an import + # statement or uses names that'd mask an imported name. + self.scopes = self.scopes.new_child(readonlydict({})) + self._check_decorators(node) + self.generic_visit(node) + self.scopes = self.scopes.parents + + def visit_Import(self, node): + self.scopes.update({ + a.asname or a.name: a.name + for a in node.names + if a.name == self.modulename + }) + + def visit_ImportFrom(self, node): + # resolve relative imports; from . import , from .. import + source = node.module # can be None + if node.level: + package = self.package + if node.level > 1: + # go up levels as needed + package = '.'.join(self.package.split('.')[:-(node.level - 1)]) + source = f'{package}.{source}' if source else package + if self.modulename == source: + # names imported from our target module + self.scopes.update({ + a.asname or a.name: f'{self.modulename}.{a.name}' + for a in node.names + }) + else: + # from package import module import, where package.module is what we want + self.scopes.update({ + a.asname or a.name: node.module + for a in node.names + }) + + def visit_Name(self, node): + if not isinstance(node.ctx, ast.Load): + # store or del operation, means the name is masked in the current scope + try: + self.scopes[node.id] = None + except TypeError: + # class scope, which we made read-only. These names can't mask + # anything so just ignore these. + pass + return + # find scope this name was defined in, starting at the current scope + imported_name = self.scopes.get(node.id) + if imported_name is None: + return + + self.used_at.append((imported_name, node.id, node.lineno)) diff --git a/shitlist/deprecated_code_use_collector.py b/shitlist/deprecated_code_use_collector.py new file mode 100644 index 0000000..b94ba2b --- /dev/null +++ b/shitlist/deprecated_code_use_collector.py @@ -0,0 +1,113 @@ +import ast +import collections +from _ast import AST +from collections import ChainMap +from types import MappingProxyType as readonlydict + + +class DeprecatedCodeUseCollector(ast.NodeVisitor): + def __init__(self, deprecated_code: str, modulename, package=''): + self.deprecated_code = deprecated_code + self.modulename = modulename + # used to resolve from ... import ... references + self.package = package + self.modulepackage, _, self.modulestem = modulename.rpartition('.') + # track scope namespaces, with a mapping of imported names (bound name to original) + # If a name references None it is used for a different purpose in that scope + # and so masks a name in the global namespace. + self.import_scopes = ChainMap() + self.scope_names = collections.deque() + self.used_at = [] # list of (name, alias, line) entries + + def generic_visit(self, node): + """Called if no explicit visitor function exists for a node.""" + prev_field, prev_value = None, None + for field, value in ast.iter_fields(node): + if isinstance(value, list): + for item in value: + if isinstance(item, AST): + self.visit(item) + elif isinstance(value, AST): + self.visit(value) + elif field == "attr" and value == self.deprecated_code: + self.visit_attr(value, prev_value) + rev_field, prev_value = field, value + + def visit_FunctionDef(self, node): + self.import_scopes = self.import_scopes.new_child() + self.scope_names.append(node.name) + self.generic_visit(node) + self.import_scopes = self.import_scopes.parents + self.scope_names.pop() + + def visit_Lambda(self, node): + # lambdas are just functions, albeit with no statements + # self.visit_Function(node) + pass + + def visit_ClassDef(self, node): + # class scope is a special local scope that is re-purposed to form + # the class attributes. By using a read-only dict proxy here this code + # we can expect an exception when a class body contains an import + # statement or uses names that'd mask an imported name. + self.import_scopes = self.import_scopes.new_child(readonlydict({})) + self.scope_names.append(node.name) + self.generic_visit(node) + self.import_scopes = self.import_scopes.parents + self.scope_names.pop() + + def visit_Import(self, node): + self.import_scopes.update({ + a.asname or a.name: a.name + for a in node.names + if a.name == self.modulename + }) + + def visit_ImportFrom(self, node): + # resolve relative imports; from . import , from .. import + source = node.module # can be None + if node.level: + package = self.package + if node.level > 1: + # go up levels as needed + package = '.'.join(self.package.split('.')[:-(node.level - 1)]) + source = f'{package}.{source}' if source else package + if self.modulename == source: + # names imported from our target module + self.import_scopes.update({ + a.asname or a.name: f'{self.modulename}.{a.name}' + for a in node.names + }) + elif self.modulepackage and self.modulepackage == source: + # from package import module import, where package.module is what we want + self.import_scopes.update({ + a.asname or a.name: self.modulename + for a in node.names + if a.name == self.modulestem + }) + + def visit_Name(self, node): + if not isinstance(node.ctx, ast.Load): + # store or del operation, means the name is masked in the current scope + try: + self.import_scopes[node.id] = None + except TypeError: + # class scope, which we made read-only. These names can't mask + # anything so just ignore these. + pass + return + # find scope this name was defined in, starting at the current scope + imported_name = self.import_scopes.get(node.id) + if imported_name is None: + return + # pdb.set_trace() + if self.deprecated_code == node.id: + self.used_at.append(self.scope_names[0]) + + def visit_attr(self, node, prev_node): + imported_name = self.import_scopes.get(prev_node.id) + if imported_name is None: + return + # pdb.set_trace() + if self.deprecated_code == node: + self.used_at.append(self.scope_names[0]) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/example_module/__init__.py b/tests/example_module/__init__.py new file mode 100644 index 0000000..41f3a9f --- /dev/null +++ b/tests/example_module/__init__.py @@ -0,0 +1,16 @@ +import shitlist + + +def not_wrapped(): + wrapped_2() + return 0 + + +@shitlist.deprecate(alternative='test') +def wrapped_1(): + return 1 + + +@shitlist.deprecate(alternative='test') +def wrapped_2(): + return 1 diff --git a/tests/example_module/example_file.py b/tests/example_module/example_file.py new file mode 100644 index 0000000..1cb72c9 --- /dev/null +++ b/tests/example_module/example_file.py @@ -0,0 +1,10 @@ +import shitlist +from tests.example_module import wrapped_2 +from tests import example_module + + +@shitlist.deprecate(alternative='test') +def wrapped_3(): + wrapped_2() + example_module.wrapped_1() + return 1 diff --git a/tests/example_module/submodule/__init__.py b/tests/example_module/submodule/__init__.py new file mode 100644 index 0000000..8595abd --- /dev/null +++ b/tests/example_module/submodule/__init__.py @@ -0,0 +1,11 @@ +import shitlist + + +def not_wrapped(): + wrapped() + return 0 + + +@shitlist.deprecate(alternative='test') +def wrapped(): + return 1 diff --git a/tests/example_module/submodule/submodule_example_file.py b/tests/example_module/submodule/submodule_example_file.py new file mode 100644 index 0000000..a082d37 --- /dev/null +++ b/tests/example_module/submodule/submodule_example_file.py @@ -0,0 +1,8 @@ +from shitlist import deprecate + + +@deprecate( + alternative='test' +) +def wrapped(): + return 1 diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..eb983a9 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,5 @@ +-r ../requirements.txt + +pytest +pyHamcrest +pytest-mock \ No newline at end of file diff --git a/tests/shitlist/test_shitlist.py b/tests/shitlist/test_shitlist.py new file mode 100644 index 0000000..06d44a2 --- /dev/null +++ b/tests/shitlist/test_shitlist.py @@ -0,0 +1,258 @@ +from pathlib import PosixPath + +import pytest +from hamcrest import assert_that, has_items, equal_to + +import shitlist + + +@shitlist.deprecate(alternative='test') +def func_test(): + return 0 + + +@shitlist.deprecate(alternative='test') +def another_func_test(): + return 1 + + +def test_raises_if_not_function(): + with pytest.raises(shitlist.WrongTypeError): + @shitlist.deprecate(alternative='test') + class TestClass: + pass + + +def test_raises_runtime_error(): + with pytest.raises(RuntimeError) as e_info: + @shitlist.deprecate(alternative='test') + def func(): + return 0 + + func() + + +@pytest.mark.skip +def test_passes_check(mocker): + @shitlist.deprecate(alternative='test') + def func(): + return 0 + + mocker.patch('shitlist.usages', new=['test_passes_check..func']) + + func() + + +def test_generate_shitlist_for_path(pytestconfig): + test_root = PosixPath(pytestconfig.rootpath) + result = shitlist.gen_for_path(test_root) + + expected_result = [ + 'tests.example_module::wrapped_1', + 'tests.example_module::wrapped_2', + 'tests.example_module.example_file::wrapped_3', + 'tests.example_module.submodule::wrapped', + 'tests.example_module.submodule.submodule_example_file::wrapped', + ] + + assert_that( + result, + has_items(*expected_result) + ) + + +def test_shitlist_test_throws_an_exception_if_theres_a_new_usage_of_a_deprecated_thing(): + existing_config = shitlist.Config( + deprecated_code=[ + 'thing_1', + 'thing_2', + 'thing_3' + ], + usage={ + 'thing_1': ['usage_1_of_thing_1', 'usage_2_of_thing_1'], + 'thing_2': ['usage_1_of_thing_2', 'usage_2_of_thing_2'], + 'thing_3': ['usage_1_of_thing_3', 'usage_2_of_thing_3'], + } + ) + + new_config = shitlist.Config( + deprecated_code=[ + 'thing_1', + 'thing_2', + 'thing_3' + ], + usage={ + 'thing_1': ['usage_1_of_thing_1', 'usage_2_of_thing_1'], + 'thing_2': ['usage_1_of_thing_2', 'usage_2_of_thing_2'], + 'thing_3': ['usage_1_of_thing_3', 'usage_2_of_thing_3', 'usage_3_of_thing_3'], + } + ) + + with pytest.raises(shitlist.DeprecatedException): + shitlist.test( + existing_config=existing_config, + new_config=new_config + ) + + +@pytest.mark.skip +def test_shitlist_test_should_fail_if_reintroduce_a_previously_deprecated_thing(): + assert_that(True, equal_to(False)) + + +def test_shitlist_test_passes(): + existing_config = shitlist.Config( + deprecated_code=[ + 'thing_1', + 'thing_2', + 'thing_3' + ], + usage={ + 'thing_1': ['usage_1_of_thing_1', 'usage_2_of_thing_1'], + 'thing_2': ['usage_1_of_thing_2', 'usage_2_of_thing_2'], + 'thing_3': ['usage_1_of_thing_3', 'usage_2_of_thing_3'], + } + ) + + new_config = shitlist.Config( + deprecated_code=[ + 'thing_1', + 'thing_3' + ], + usage={ + 'thing_1': ['usage_1_of_thing_1', 'usage_2_of_thing_1'], + 'thing_3': [], + 'thing_not_in_existing_config': ['usage'] + } + ) + + shitlist.test( + existing_config=existing_config, + new_config=new_config + ) + + +def test_find_usages(pytestconfig): + test_root = PosixPath(pytestconfig.rootpath) + + deprecated_code = [ + 'tests.example_module::wrapped_2', + 'tests.example_module::wrapped_1' + ] + + result = shitlist.find_usages(test_root, deprecated_code) + + expected_result = { + 'tests.example_module::wrapped_2': ['tests.example_module.example_file::wrapped_3'], + 'tests.example_module::wrapped_1': ['tests.example_module.example_file::wrapped_3'] + } + assert_that(result, equal_to(expected_result)) + + +def test_update_config(): + existing_config = shitlist.Config( + deprecated_code=[ + 'thing_1', + 'thing_2', + 'thing_3' + ], + usage={ + 'thing_1': ['usage_1_of_thing_1', 'usage_2_of_thing_1'], + 'thing_2': ['usage_1_of_thing_2', 'usage_2_of_thing_2'], + 'thing_3': ['usage_1_of_thing_3', 'usage_2_of_thing_3'], + }, + removed_usages={ + 'thing_1': ['usage_3_of_thing_1'], + }, + successfully_removed_things=['thing_4'] + ) + + new_config = shitlist.Config( + deprecated_code=[ + 'thing_1', + 'thing_3', + 'thing_not_in_existing_config' + ], + usage={ + 'thing_1': ['usage_1_of_thing_1', 'usage_2_of_thing_1'], + 'thing_3': ['usage_2_of_thing_3'], + 'thing_not_in_existing_config': ['usage'] + } + ) + + updated_config = shitlist.update( + existing_config=existing_config, + new_config=new_config + ) + + expected_config = shitlist.Config( + deprecated_code=[ + 'thing_1', + 'thing_3', + 'thing_not_in_existing_config' + ], + usage={ + 'thing_1': ['usage_1_of_thing_1', 'usage_2_of_thing_1'], + 'thing_3': ['usage_2_of_thing_3'], + 'thing_not_in_existing_config': ['usage'] + }, + removed_usages={ + 'thing_1': ['usage_3_of_thing_1'], + 'thing_3': ['usage_1_of_thing_3'] + }, + successfully_removed_things=[ + 'thing_4', + 'thing_2' + ] + ) + + assert_config_are_equal(updated_config, expected_config) + + +def test_ignores_directories(pytestconfig): + test_root = PosixPath(pytestconfig.rootpath) / 'tests' + + walker = shitlist.TreeWalker( + root_dir=test_root / 'example_module', + ignore_directories=['submodule'] + ) + + assert_that(walker.has_next, equal_to(True)) + + assert_that(walker.next_file().name, equal_to('example_file.py')) + assert_that(walker.next_file().name, equal_to('__init__.py')) + + assert_that(walker.has_next, equal_to(False)) + + +def assert_config_are_equal(config_1: shitlist.Config, config_2: shitlist.Config): + assert_that( + config_1.deprecated_code, + equal_to(config_2.deprecated_code), + 'property deprecated_code are not equal' + ) + + assert_that( + config_1.usage, + equal_to(config_2.usage), + 'property usage are not equal' + ) + + assert_that( + config_1.removed_usages, + equal_to(config_2.removed_usages), + 'property removed_usages are not equal' + ) + + assert_that( + config_1.successfully_removed_things, + equal_to(config_2.successfully_removed_things), + 'property successfully_removed_things are not equal' + ) + + +def test_deprecated_code_must_define_alternative(): + with pytest.raises(shitlist.UndefinedAlternativeException): + @shitlist.deprecate(alternative=None) + def some_func(): + pass