diff --git a/.importanizerc b/.importanizerc index 70c7925..b7ad405 100644 --- a/.importanizerc +++ b/.importanizerc @@ -1,11 +1,15 @@ { "exclude": [ + "*/.tox/*", "*/test_data/*.py" ], "groups": [ { "type": "stdlib" }, + { + "type": "sitepackages" + }, { "type": "remainder" }, diff --git a/.travis.yml b/.travis.yml index bf3b0ee..e181da4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ language: python python: - - "3.4" + - "3.6" - "2.7" - "pypy" diff --git a/AUTHORS.rst b/AUTHORS.rst index 2ee2c81..9d117b9 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -9,5 +9,5 @@ Development Lead Contributors ~~~~~~~~~~~~ -Benjamin Abel - https://github.com/benjaminabel -Pamela McA'Nulty - https://github.com/PamelaM +* Benjamin Abel - https://github.com/benjaminabel +* Pamela McA'Nulty - https://github.com/PamelaM diff --git a/HISTORY.rst b/HISTORY.rst index bf3644a..9b19d94 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,18 @@ History ------- +0.5.0 (2017-05-03) +~~~~~~~~~~~~~~~~~~ + +* Added ``--ci`` flag to validate import organization in files +* Added ``sitepackages`` import group. Thanks `Pamela `_. + See ``README`` for more info +* Added pipe handling (e.g. ``cat foo.py | importanize``) +* Fixed bug which incorrectly sorted imports with aliases (e.g. ``import foo as bar``) +* Files are not overridden when imports are already organized. + Useful in precommit hooks which detect changed files. +* Released as Python `wheel `_ + 0.4.1 (2015-07-28) ~~~~~~~~~~~~~~~~~~ diff --git a/Makefile b/Makefile index ed4a366..28037b2 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: clean-pyc clean-build docs clean -NOSE_FLAGS=-sv --with-doctest --rednose +NOSE_FLAGS=-sv --with-doctest --rednose --exclude=test_data COVER_CONFIG_FLAGS=--with-coverage --cover-package=importanize,tests --cover-tests --cover-erase COVER_REPORT_FLAGS=--cover-html --cover-html-dir=htmlcov COVER_FLAGS=${COVER_CONFIG_FLAGS} ${COVER_REPORT_FLAGS} @@ -45,6 +45,7 @@ clean-test-all: clean-test lint: flake8 importanize tests + python -m importanize tests/ importanize/ tests/ --ci test: nosetests ${NOSE_FLAGS} tests/ @@ -59,9 +60,11 @@ check: lint clean-build clean-pyc clean-test test-coverage release: clean python setup.py sdist upload + python setup.py bdist_wheel upload dist: clean python setup.py sdist + python setup.py bdist_wheel ls -l dist docs: diff --git a/importanize/__init__.py b/importanize/__init__.py index 6110a41..9cf9f41 100755 --- a/importanize/__init__.py +++ b/importanize/__init__.py @@ -4,7 +4,7 @@ __author__ = 'Miroslav Shubernetskiy' __email__ = 'miroslav@miki725.com' -__version__ = '0.4.1' +__version__ = '0.5' __description__ = ( 'Utility for organizing Python imports using PEP8 or custom rules' ) diff --git a/importanize/__main__.py b/importanize/__main__.py new file mode 100644 index 0000000..d5fe87d --- /dev/null +++ b/importanize/__main__.py @@ -0,0 +1,333 @@ +# -*- coding: utf-8 -*- +from __future__ import print_function, unicode_literals +import argparse +import inspect +import json +import logging +import os +import sys +from fnmatch import fnmatch +from stat import S_ISFIFO + +import pathlib2 as pathlib +import six + +from . import __description__, __version__, formatters +from .formatters import DEFAULT_FORMATTER +from .groups import ImportGroups +from .parser import ( + find_imports_from_lines, + get_text_artifacts, + parse_statements, +) +from .utils import force_text, read + + +LOGGING_FORMAT = '%(message)s' +IMPORTANIZE_CONFIG = '.importanizerc' +PEP8_CONFIG = { + 'groups': [ + { + 'type': 'stdlib', + }, + { + 'type': 'sitepackages', + }, + { + 'type': 'remainder', + }, + { + 'type': 'local', + } + ], +} +VERBOSITY_MAPPING = { + 0: logging.ERROR, + 1: logging.INFO, + 2: logging.DEBUG, +} + +# initialize FORMATTERS dict +FORMATTERS = { + formatter.name: formatter + for formatter in vars(formatters).values() + if (inspect.isclass(formatter) and + formatter is not formatters.Formatter and + issubclass(formatter, formatters.Formatter)) +} + +# setup logging +logging.basicConfig(format=LOGGING_FORMAT) +logging.getLogger('').setLevel(logging.ERROR) +log = logging.getLogger(__name__) + + +def find_config(): + path = pathlib.Path.cwd() + default_config = None + + while path != pathlib.Path(path.root): + config_path = path / IMPORTANIZE_CONFIG + if config_path.exists(): + default_config = six.text_type(config_path) + break + else: + path = path.parent + + return default_config + + +parser = argparse.ArgumentParser( + description=__description__, +) +parser.add_argument( + 'path', + type=six.text_type, + nargs='*', + help='Path either to a file or directory where ' + 'all Python files imports will be organized.', +) +parser.add_argument( + '-c', '--config', + type=argparse.FileType('rb'), + help='Path to importanize config json file. ' + 'If "{}" is present in either current folder ' + 'or any parent folder, that config ' + 'will be used. Otherwise crude default pep8 ' + 'config will be used.' + ''.format(IMPORTANIZE_CONFIG), +) +parser.add_argument( + '-f', '--formatter', + type=six.text_type, + default=DEFAULT_FORMATTER, + choices=sorted(FORMATTERS.keys()), + help='Formatter used.' +) +parser.add_argument( + '--print', + action='store_true', + default=False, + help='If provided, instead of changing files, modified ' + 'files will be printed to stdout.' +) +parser.add_argument( + '--no-header', + action='store_false', + default=True, + dest='header', + help='If provided, when printing files will not print header ' + 'before each file. ' + 'Useful to leave when multiple files are importanized.' +) +parser.add_argument( + '--ci', + action='store_true', + default=False, + help='When used CI mode will check if file contains expected ' + 'imports as per importanize configuration.' +) +parser.add_argument( + '--version', + action='store_true', + default=False, + help='Show the version number of importanize' +) +parser.add_argument( + '-v', '--verbose', + action='count', + default=0, + help='Print out fascinated debugging information. ' + 'Can be supplied multiple times to increase verbosity level', +) + + +class CIFailure(Exception): + pass + + +def run_importanize_on_text(text, config, args): + file_artifacts = get_text_artifacts(text) + + # Get formatter from args or config + formatter = FORMATTERS.get(args.formatter or config.get('formatter'), + DEFAULT_FORMATTER) + log.debug('Using {} formatter'.format(formatter)) + + lines_iterator = enumerate(iter(text.splitlines())) + imports = list(parse_statements(find_imports_from_lines(lines_iterator))) + + groups = ImportGroups() + + for c in config['groups']: + groups.add_group(c) + + for i in imports: + groups.add_statement_to_group(i) + + formatted_imports = groups.formatted(formatter=formatter) + line_numbers = groups.all_line_numbers() + + lines = text.splitlines() + for line_number in sorted(groups.all_line_numbers(), reverse=True): + lines.pop(line_number) + + first_import_line_number = min(line_numbers) if line_numbers else 0 + i = first_import_line_number + + while i is not None and len(lines) > i: + if not lines[i]: + lines.pop(i) + else: + i = None + + lines = ( + lines[:first_import_line_number] + + formatted_imports.splitlines() + + ([''] * 2 + if lines[first_import_line_number:] and formatted_imports + else []) + + lines[first_import_line_number:] + + [''] + ) + + lines = file_artifacts.get('sep', '\n').join(lines) + + if args.ci and text != lines: + raise CIFailure() + + return lines + + +def run(source, config, args, path=None): + if isinstance(source, six.string_types): + msg = 'About to importanize' + if path: + msg += ' {}'.format(path) + log.debug(msg) + + try: + organized = run_importanize_on_text(source, config, args) + + except CIFailure: + msg = 'Imports not organized' + if path: + msg += ' in {}'.format(path) + print(msg, file=sys.stderr) + raise + + else: + if args.print and args.header and path: + print('=' * len(six.text_type(path))) + print(six.text_type(path)) + print('-' * len(six.text_type(path))) + + if args.print: + print(organized.encode('utf-8') if not six.PY3 else organized) + + else: + if source == organized: + msg = 'Nothing to do' + if path: + msg += ' in {}'.format(path) + log.info(msg) + + else: + path.write_text(organized) + + msg = 'Successfully importanized' + if path: + msg += ' {}'.format(path) + log.info(msg) + + return organized + + elif source.is_file(): + if config.get('exclude'): + if any(map(lambda i: fnmatch(six.text_type(source.resolve()), i), + config.get('exclude'))): + log.info('Skipping {}'.format(source)) + return + + text = source.read_text('utf-8') + return run(text, config, args, source) + + elif source.is_dir(): + files = ( + f for f in source.iterdir() + if not f.is_file() or f.is_file() and f.suffixes == ['.py'] + ) + + all_successes = True + for f in files: + try: + run(f, config, args, f) + except CIFailure: + all_successes = False + + if not all_successes: + raise CIFailure() + + +def is_piped(): + return S_ISFIFO(os.fstat(0).st_mode) + + +def main(args=None): + args = args if args is not None else sys.argv[1:] + args = parser.parse_args(args=args) + + # adjust logging level + (logging.getLogger('') + .setLevel(VERBOSITY_MAPPING.get(args.verbose, 0))) + + log.debug('Running importanize with {}'.format(args)) + + config_path = getattr(args.config, 'name', '') or find_config() + + if args.version: + msg = ( + 'importanize\n' + '===========\n' + '{description}\n\n' + 'version: {version}\n' + 'python: {python}\n' + 'config: {config}\n' + 'source: https://github.com/miki725/importanize' + ) + print(msg.format( + description=__description__, + version=__version__, + python=sys.executable, + config=config_path or '', + )) + return 0 + + config = json.loads(read(config_path)) if config_path else PEP8_CONFIG + + to_importanize = [pathlib.Path(i) for i in (args.path or ['.'])] + + if is_piped() and not args.path: + to_importanize = [force_text(sys.stdin.read())] + args.print = True + args.header = False + + if args.ci: + args.print = False + args.header = False + + all_successes = True + + for p in to_importanize: + try: + run(p, config, args) + except CIFailure: + all_successes = False + except Exception: + log.exception('Error running importanize') + return 1 + + return int(not all_successes) + + +sys.exit(main()) if __name__ == '__main__' else None diff --git a/importanize/groups.py b/importanize/groups.py index 2cdc54b..0adaf65 100644 --- a/importanize/groups.py +++ b/importanize/groups.py @@ -8,7 +8,7 @@ import six from .formatters import DEFAULT_FORMATTER -from .utils import is_std_lib, is_site_package +from .utils import is_site_package, is_std_lib @six.python_2_unicode_compatible @@ -121,6 +121,7 @@ class RemainderGroup(BaseImportGroup): def should_add_statement(self, statement): return True + # -- RemainderGroup goes last and catches everything left over GROUP_MAPPING = OrderedDict(( ('stdlib', StdLibGroup), @@ -150,7 +151,7 @@ def add_group(self, config): raise ValueError(msg) if config['type'] not in GROUP_MAPPING: - msg = ('"{}" is not supported import group') + msg = ('"{}" is not supported import group'.format(config['type'])) raise ValueError(msg) self.groups.append(GROUP_MAPPING[config['type']](config)) diff --git a/importanize/main.py b/importanize/main.py deleted file mode 100644 index 4c3d9a1..0000000 --- a/importanize/main.py +++ /dev/null @@ -1,256 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import print_function, unicode_literals -import argparse -import inspect -import json -import logging -import operator -import os -import sys -from fnmatch import fnmatch - -import six - -from . import __description__, __version__, formatters -from .formatters import DEFAULT_FORMATTER -from .groups import ImportGroups -from .parser import ( - find_imports_from_lines, - get_file_artifacts, - parse_statements, -) -from .utils import read - - -LOGGING_FORMAT = '%(levelname)s %(name)s %(message)s' -IMPORTANIZE_CONFIG = '.importanizerc' -PEP8_CONFIG = { - 'groups': [ - { - 'type': 'stdlib', - }, - { - 'type': 'sitepackages', - }, - { - 'type': 'remainder', - }, - { - 'type': 'local', - } - ], -} -VERBOSITY_MAPPING = { - 0: logging.ERROR, - 1: logging.INFO, - 2: logging.DEBUG, -} - -# initialize FORMATTERS dict -FORMATTERS = { - formatter.name: formatter - for formatter in vars(formatters).values() - if (inspect.isclass(formatter) and - formatter is not formatters.Formatter and - issubclass(formatter, formatters.Formatter)) -} - -# setup logging -logging.basicConfig(format=LOGGING_FORMAT) -logging.getLogger('').setLevel(logging.ERROR) -log = logging.getLogger(__name__) - - -def find_config(): - path = os.getcwd() - default_config = None - found_default = '' - - while path != os.sep: - config_path = os.path.join(path, IMPORTANIZE_CONFIG) - if os.path.exists(config_path): - default_config = config_path - found_default = (' Found configuration file at {}' - ''.format(default_config)) - break - else: - path = os.path.dirname(path) - - return default_config, found_default - - -default_config, found_default = find_config() - -parser = argparse.ArgumentParser( - description=__description__, -) -parser.add_argument( - 'path', - type=six.text_type, - nargs='*', - default=['.'], - help='Path either to a file or directory where ' - 'all Python files imports will be organized.', -) -parser.add_argument( - '-c', '--config', - type=six.text_type, - default=default_config, - help='Path to importanize config json file. ' - 'If "{}" is present in either current folder ' - 'or any parent folder, that config ' - 'will be used. Otherwise crude default pep8 ' - 'config will be used.{}' - ''.format(IMPORTANIZE_CONFIG, - found_default), -) -parser.add_argument( - '-f', '--formatter', - type=six.text_type, - default=DEFAULT_FORMATTER, - choices=sorted(FORMATTERS.keys()), - help='Formatter used.' -) -parser.add_argument( - '--print', - action='store_true', - default=False, - help='If provided, instead of changing files, modified ' - 'files will be printed to stdout.' -) -parser.add_argument( - '--version', - action='store_true', - default=False, - help='Show the version number of importanize' -) -parser.add_argument( - '-v', '--verbose', - action='count', - default=0, - help='Print out fascinated debugging information. ' - 'Can be supplied multiple times to increase verbosity level', -) - - -def run_importanize(path, config, args): - if config.get('exclude'): - if any(map(lambda i: fnmatch(path, i), config.get('exclude'))): - log.info('Skipping {}'.format(path)) - return False - - text = read(path) - file_artifacts = get_file_artifacts(path) - - # Get formatter from args or config - formatter = FORMATTERS.get(args.formatter or config.get('formatter'), - DEFAULT_FORMATTER) - log.debug('Using {} formatter'.format(formatter)) - - lines_iterator = enumerate(iter(text.splitlines())) - imports = list(parse_statements(find_imports_from_lines(lines_iterator))) - - groups = ImportGroups() - - for c in config['groups']: - groups.add_group(c) - - for i in imports: - groups.add_statement_to_group(i) - - formatted_imports = groups.formatted(formatter=formatter) - line_numbers = groups.all_line_numbers() - - lines = text.splitlines() - for line_number in sorted(groups.all_line_numbers(), reverse=True): - lines.pop(line_number) - - first_import_line_number = min(line_numbers) if line_numbers else 0 - i = first_import_line_number - - while i is not None and len(lines) > i: - if not lines[i]: - lines.pop(i) - else: - i = None - - lines = ( - lines[:first_import_line_number] + - formatted_imports.splitlines() + - ([''] * 2 - if lines[first_import_line_number:] and formatted_imports - else []) + - lines[first_import_line_number:] + - [''] - ) - - lines = file_artifacts.get('sep', '\n').join(lines) - - if args.print: - print(lines.encode('utf-8') if not six.PY3 else lines) - else: - with open(path, 'wb') as fid: - fid.write(lines.encode('utf-8')) - - log.info('Successfully importanized {}'.format(path)) - return True - - -def run(path, config, args): - if not os.path.isdir(path): - try: - run_importanize(path, config, args) - except Exception as e: - log.exception('Error running importanize for {}' - ''.format(path)) - parser.error(six.text_type(e)) - - else: - for dirpath, dirnames, filenames in os.walk(path): - python_files = filter( - operator.methodcaller('endswith', '.py'), - filenames - ) - for file in python_files: - path = os.path.join(dirpath, file) - if args.print: - print('=' * len(path)) - print(path) - print('-' * len(path)) - try: - run_importanize(path, config, args) - except Exception as e: - log.exception('Error running importanize for {}' - ''.format(path)) - parser.error(six.text_type(e)) - - -def main(): - args = parser.parse_args() - - # adjust logging level - (logging.getLogger('') - .setLevel(VERBOSITY_MAPPING.get(args.verbose, 0))) - - log.debug('Running importanize with {}'.format(args)) - - if args.version: - msg = ( - 'importanize\n' - '===========\n' - '{}\n\n' - 'version: {}\n' - 'python: {}\n' - 'source: https://github.com/miki725/importanize' - ) - print(msg.format(__description__, __version__, sys.executable)) - sys.exit(0) - - if args.config is None: - config = PEP8_CONFIG - else: - config = json.loads(read(args.config)) - - for p in args.path: - path = os.path.abspath(p) - run(path, config, args) diff --git a/importanize/parser.py b/importanize/parser.py index 881db0a..37da3e8 100644 --- a/importanize/parser.py +++ b/importanize/parser.py @@ -5,7 +5,7 @@ import six from .statements import DOTS, ImportLeaf, ImportStatement -from .utils import list_split, read +from .utils import list_split STATEMENT_COMMENTS = ('noqa',) @@ -36,14 +36,14 @@ def normalized(self): return self[1:] -def get_file_artifacts(path): +def get_text_artifacts(text): """ Get artifacts for the given file. Parameters ---------- path : str - Path to a file + File content to analyze Returns ------- @@ -55,7 +55,7 @@ def get_file_artifacts(path): 'sep': '\n', } - lines = read(path).splitlines(True) + lines = text.splitlines(True) if len(lines) > 1 and lines[0][-2:] == '\r\n': artifacts['sep'] = '\r\n' diff --git a/importanize/statements.py b/importanize/statements.py index 8691517..68b0fc8 100755 --- a/importanize/statements.py +++ b/importanize/statements.py @@ -104,8 +104,19 @@ class ImportStatement(ComparatorMixin): def __init__(self, line_numbers, stem, leafs=None, comments=None, **kwargs): + as_name = None + + if ' as ' in stem: + stem, as_name = list_strip(stem.split(' as ')) + if leafs: + as_name = None + + if stem == as_name: + as_name = None + self.line_numbers = line_numbers self.stem = stem + self.as_name = as_name self.leafs = leafs or [] self.comments = comments or [] self.file_artifacts = kwargs.get('file_artifacts', {}) @@ -126,7 +137,10 @@ def root_module(self): def as_string(self): if not self.leafs: - return 'import {}'.format(self.stem) + data = 'import {}'.format(self.stem) + if self.as_name: + data += ' as {}'.format(self.as_name) + return data else: return ( 'from {} import {}' diff --git a/importanize/utils.py b/importanize/utils.py index 361b071..49dc0eb 100644 --- a/importanize/utils.py +++ b/importanize/utils.py @@ -1,42 +1,22 @@ # -*- coding: utf-8 -*- from __future__ import print_function, unicode_literals +import imp import operator import os import sys -from contextlib import contextmanager -from importlib import import_module -@contextmanager -def ignore_site_packages_paths(): +def _get_module_path(module_name): paths = sys.path[:] + if os.getcwd() in sys.path: + paths.remove(os.getcwd()) + try: - # remove working directory so that all - # local imports fail - if os.getcwd() in sys.path: - sys.path.remove(os.getcwd()) - # remove all third-party paths - # so that only stdlib imports will succeed - sys.path = list(set(filter( - None, - filter(lambda i: all(('site-packages' not in i, - 'python' in i or 'pypy' in i)), - map(operator.methodcaller('lower'), sys.path)) - ))) - yield - finally: - sys.path = paths - - -def _safe_import_module(module_name): - imported_module = sys.modules.pop(module_name, None) - try: - return import_module(module_name) + # TODO deprecated in Py3. + # TODO Find better way for py2 and py3 compatibility. + return imp.find_module(module_name, paths)[1] except ImportError: - return None - finally: - if imported_module: - sys.modules[module_name] = imported_module + return '' def is_std_lib(module_name): @@ -46,16 +26,17 @@ def is_std_lib(module_name): if module_name in sys.builtin_module_names: return True - with ignore_site_packages_paths(): - return bool(_safe_import_module(module_name)) + module_path = _get_module_path(module_name) + if 'site-packages' in module_path: + return False + return 'python' in module_path or 'pypy' in module_path def is_site_package(module_name): if not module_name: return False - module = _safe_import_module(module_name) - module_path = getattr(module, "__file__", "") + module_path = _get_module_path(module_name) if "site-packages" not in module_path: return False return "python" in module_path or "pypy" in module_path @@ -85,3 +66,17 @@ def list_split(iterable, split): if segment: yield segment + + +def force_text(data): + try: + return data.decode('utf-8') + except AttributeError: + return data + + +def force_bytes(data): + try: + return data.encode('utf-8') + except AttributeError: + return data diff --git a/requirements-dev.txt b/requirements-dev.txt index 7bd7749..59eae6e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,5 +3,6 @@ coverage flake8 mock nose +pdbpp rednose tox diff --git a/requirements.txt b/requirements.txt index e323a45..c7de579 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ six>=1.9 +pathlib2 diff --git a/setup.py b/setup.py index a59aacd..a17987d 100755 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ def read(fname): tests_require=test_requirements, entry_points={ 'console_scripts': [ - 'importanize = importanize.main:main', + 'importanize = importanize.__main__:main', ] }, keywords=' '.join([ diff --git a/tests/test_data/config.json b/tests/test_data/config.json new file mode 100644 index 0000000..fa465d7 --- /dev/null +++ b/tests/test_data/config.json @@ -0,0 +1,16 @@ +{ + "groups": [ + { + "type": "stdlib" + }, + { + "type": "sitepackages" + }, + { + "type": "remainder" + }, + { + "type": "local" + } + ] +} diff --git a/tests/test_data/input.txt b/tests/test_data/input.py similarity index 94% rename from tests/test_data/input.txt rename to tests/test_data/input.py index 8b9cff3..4cf3b95 100644 --- a/tests/test_data/input.txt +++ b/tests/test_data/input.py @@ -10,6 +10,7 @@ from ..othermodule import rainbows from a import b from a.b import c +import flake8 as lint # in site-package from a.b import d import z diff --git a/tests/test_data/output_grouped.txt b/tests/test_data/output_grouped.py similarity index 94% rename from tests/test_data/output_grouped.txt rename to tests/test_data/output_grouped.py index 6be7f89..8176c14 100644 --- a/tests/test_data/output_grouped.txt +++ b/tests/test_data/output_grouped.py @@ -5,6 +5,7 @@ from os import path as ospath import coverage # in site-packages +import flake8 as lint # in site-package import something # with comment import z diff --git a/tests/test_data/output_inline_grouped.txt b/tests/test_data/output_inline_grouped.py similarity index 96% rename from tests/test_data/output_inline_grouped.txt rename to tests/test_data/output_inline_grouped.py index 4f38d79..3908cea 100644 --- a/tests/test_data/output_inline_grouped.txt +++ b/tests/test_data/output_inline_grouped.py @@ -5,6 +5,7 @@ from os import path as ospath import coverage # in site-packages +import flake8 as lint # in site-package import something # with comment import z diff --git a/tests/test_groups.py b/tests/test_groups.py index 2cad360..9436b04 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -11,8 +11,8 @@ LocalGroup, PackagesGroup, RemainderGroup, - StdLibGroup, SitePackagesGroup, + StdLibGroup, ) from importanize.statements import ImportLeaf, ImportStatement diff --git a/tests/test_main.py b/tests/test_main.py index daa70e3..680f7c2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,327 +1,280 @@ # -*- coding: utf-8 -*- from __future__ import print_function, unicode_literals -import logging -import os +import sys import unittest +from copy import deepcopy import mock import six +from pathlib2 import Path -from importanize.main import ( +from importanize import __version__ +from importanize.__main__ import ( + IMPORTANIZE_CONFIG, PEP8_CONFIG, + CIFailure, find_config, main, - parser, run, - run_importanize, + run_importanize_on_text, ) -from importanize.utils import read -TESTING_MODULE = 'importanize.main' +TESTING_MODULE = 'importanize.__main__' class TestMain(unittest.TestCase): - @mock.patch(TESTING_MODULE + '.read') - def test_run_importanize_skip(self, mock_read): - conf = { - 'exclude': ['*foo.py'], - } - self.assertFalse( - run_importanize('/path/to/importanize/file/foo.py', conf, None) + test_data = Path(__file__).parent / 'test_data' + + input_text = (test_data / 'input.py').read_text() + output_grouped = (test_data / 'output_grouped.py').read_text() + output_inline_grouped = ( + test_data / 'output_inline_grouped.py' + ).read_text() + + def test_run_importanize_on_text_grouped(self): + actual = run_importanize_on_text( + self.input_text, + PEP8_CONFIG, + mock.Mock(formatter='grouped', + ci=False), ) - self.assertFalse(mock_read.called) - @mock.patch(TESTING_MODULE + '.print', create=True) - def test_run_importanize_print(self, mock_print): - test_file = os.path.join(os.path.dirname(__file__), - 'test_data', - 'input.txt') - expected_file = os.path.join(os.path.dirname(__file__), - 'test_data', - 'output_grouped.txt') - expected = ( - read(expected_file) - if six.PY3 - else read(expected_file).encode('utf-8') + self.assertEqual(actual, self.output_grouped) + + def test_run_importanize_on_text_inline_grouped(self): + actual = run_importanize_on_text( + self.input_text, + PEP8_CONFIG, + mock.Mock(formatter='inline-grouped', + ci=False), ) - self.assertTrue( - run_importanize( - test_file, + self.assertEqual(actual, self.output_inline_grouped) + + def test_run_importanize_on_text_ci_failed(self): + with self.assertRaises(CIFailure): + run_importanize_on_text( + self.input_text, PEP8_CONFIG, - mock.MagicMock(print=True, - formatter='grouped')) + mock.Mock(formatter='grouped', + ci=True), + ) + + def test_run_importanize_on_text_ci_passed(self): + actual = run_importanize_on_text( + self.output_grouped, + PEP8_CONFIG, + mock.Mock(formatter='grouped', + ci=True), ) - # self.assertMultiLineEqual(mock_print.call_args[0][0], expected) - mock_print.assert_called_once_with(expected) - - @mock.patch(TESTING_MODULE + '.print', create=True) - def test_run_importanize_print_inline_group_formatter(self, mock_print): - test_file = os.path.join(os.path.dirname(__file__), - 'test_data', - 'input.txt') - expected_file = os.path.join(os.path.dirname(__file__), - 'test_data', - 'output_inline_grouped.txt') - expected = ( - read(expected_file) - if six.PY3 - else read(expected_file).encode('utf-8') + self.assertEqual(actual, self.output_grouped) + + @mock.patch.object(Path, 'write_text') + def test_run_text_to_file_organized(self, mock_write_text): + actual = run( + self.input_text, + PEP8_CONFIG, + mock.Mock(formatter='grouped', + ci=False, + print=False), + Path(__file__), ) - self.assertTrue( - run_importanize( - test_file, - PEP8_CONFIG, - mock.MagicMock(print=True, - formatter='inline-grouped')) + self.assertEqual(actual, self.output_grouped) + mock_write_text.assert_called_once_with(self.output_grouped) + + @mock.patch.object(Path, 'write_text') + def test_run_text_to_file_nothing_to_do(self, mock_write_text): + actual = run( + self.output_grouped, + PEP8_CONFIG, + mock.Mock(formatter='grouped', + ci=False, + print=False), + Path(__file__), ) - mock_print.assert_called_once_with(expected) + + self.assertEqual(actual, self.output_grouped) + mock_write_text.assert_not_called() @mock.patch(TESTING_MODULE + '.print', create=True) - def test_run_importanize_with_unavailable_formatter(self, mock_print): - test_file = os.path.join(os.path.dirname(__file__), - 'test_data', - 'input.txt') - expected_file = os.path.join(os.path.dirname(__file__), - 'test_data', - 'output_grouped.txt') - - expected = ( - read(expected_file) - if six.PY3 - else read(expected_file).encode('utf-8') + def test_run_text_print(self, mock_print): + actual = run( + self.input_text, + PEP8_CONFIG, + mock.Mock(formatter='grouped', + ci=False, + print=True, + header=True), + Path('foo'), ) - self.assertTrue( - run_importanize( - test_file, - PEP8_CONFIG, - mock.MagicMock(print=True, - formatter='UnavailableFormatter')) - ) - mock_print.assert_called_once_with(expected) - - @mock.patch(TESTING_MODULE + '.open', create=True) - def test_run_importanize_write(self, mock_open): - test_file = os.path.join(os.path.dirname(__file__), - 'test_data', - 'input.txt') - expected_file = os.path.join(os.path.dirname(__file__), - 'test_data', - 'output_grouped.txt') - expected = read(expected_file).encode('utf-8') - - self.assertTrue( - run_importanize( - test_file, - PEP8_CONFIG, - mock.MagicMock(print=False, - formatter='grouped')) - ) - mock_open.assert_called_once_with(test_file, 'wb') - mock_open.return_value \ - .__enter__.return_value \ - .write.assert_called_once_with(expected) - - @mock.patch(TESTING_MODULE + '.open', create=True) - def test_run_importanize_write_inline_group_formatter(self, mock_open): - test_file = os.path.join(os.path.dirname(__file__), - 'test_data', - 'input.txt') - expected_file = os.path.join(os.path.dirname(__file__), - 'test_data', - 'output_inline_grouped.txt') - expected = read(expected_file).encode('utf-8') - - self.assertTrue( - run_importanize( - test_file, - PEP8_CONFIG, - mock.MagicMock(print=False, - formatter='inline-grouped')) - ) - mock_open.assert_called_once_with(test_file, 'wb') - mock_open.return_value \ - .__enter__.return_value \ - .write.assert_called_once_with(expected) - - @mock.patch(TESTING_MODULE + '.run_importanize') - @mock.patch('os.path.isdir') - def test_run_single_file(self, mock_isdir, mock_run_importanize): - mock_isdir.return_value = False - run( - mock.sentinel.path, - mock.sentinel.config, - mock.sentinel.args, - ) - mock_run_importanize.assert_called_once_with( - mock.sentinel.path, - mock.sentinel.config, - mock.sentinel.args, - ) + self.assertEqual(actual, self.output_grouped) + mock_print.assert_has_calls([ + mock.call('==='), + mock.call('foo'), + mock.call('---'), + mock.call(self.output_grouped), + ]) - @mock.patch(TESTING_MODULE + '.parser') - @mock.patch(TESTING_MODULE + '.run_importanize') - @mock.patch('os.path.isdir') - def test_run_single_file_exception(self, - mock_isdir, - mock_run_importanize, - mock_parser): - mock_isdir.return_value = False - mock_run_importanize.side_effect = ValueError - run( - mock.sentinel.path, - mock.sentinel.config, - mock.sentinel.args, - ) - mock_run_importanize.assert_called_once_with( - mock.sentinel.path, - mock.sentinel.config, - mock.sentinel.args, + @mock.patch(TESTING_MODULE + '.print', create=True) + def test_run_text_print_no_file(self, mock_print): + actual = run( + self.input_text, + PEP8_CONFIG, + mock.Mock(formatter='grouped', + ci=False, + print=True, + header=True), ) - mock_parser.error.assert_called_once_with(mock.ANY) - - @mock.patch(TESTING_MODULE + '.print', mock.MagicMock(), create=True) - @mock.patch(TESTING_MODULE + '.run_importanize') - @mock.patch('os.walk') - @mock.patch('os.path.isdir') - def test_run_folder(self, - mock_isdir, - mock_walk, - mock_run_importanize): - mock_isdir.return_value = True - mock_walk.return_value = [ - ( - 'root', - ['dir1', 'dir2'], - ['foo.py', 'bar.txt'], - ), - ] - - conf = mock.MagicMock(print=True) - run( - mock.sentinel.path, - mock.sentinel.config, - conf, + + self.assertEqual(actual, self.output_grouped) + mock_print.assert_has_calls([ + mock.call(self.output_grouped), + ]) + + @mock.patch(TESTING_MODULE + '.print', create=True) + def test_run_text_print_no_header(self, mock_print): + actual = run( + self.input_text, + PEP8_CONFIG, + mock.Mock(formatter='grouped', + ci=False, + print=True, + header=False), + Path('foo'), ) - mock_run_importanize.assert_called_once_with( - os.path.join('root', 'foo.py'), - mock.sentinel.config, - conf, + + self.assertEqual(actual, self.output_grouped) + mock_print.assert_has_calls([ + mock.call(self.output_grouped), + ]) + + @mock.patch(TESTING_MODULE + '.print', create=True) + def test_run_file_skipped(self, mock_print): + config = deepcopy(PEP8_CONFIG) + config['exclude'] = ['*/test_data/*.py'] + + actual = run( + self.test_data / 'input.py', + config, + mock.Mock(formatter='grouped', + ci=False, + print=True, + header=False), ) - @mock.patch(TESTING_MODULE + '.print', mock.MagicMock(), create=True) - @mock.patch(TESTING_MODULE + '.parser') - @mock.patch(TESTING_MODULE + '.run_importanize') - @mock.patch('os.walk') - @mock.patch('os.path.isdir') - def test_run_folder_exception(self, - mock_isdir, - mock_walk, - mock_run_importanize, - mock_parser): - mock_run_importanize.side_effect = ValueError - mock_isdir.return_value = True - mock_walk.return_value = [ - ( - 'root', - ['dir1', 'dir2'], - ['foo.py', 'bar.txt'], - ), - ] - - conf = mock.MagicMock(print=True) - run( - mock.sentinel.path, - mock.sentinel.config, - conf, + self.assertIsNone(actual) + mock_print.assert_not_called() + + @mock.patch(TESTING_MODULE + '.print', create=True) + def test_run_file(self, mock_print): + actual = run( + self.test_data / 'input.py', + PEP8_CONFIG, + mock.Mock(formatter='grouped', + ci=False, + print=True, + header=False), ) - mock_run_importanize.assert_called_once_with( - os.path.join('root', 'foo.py'), - mock.sentinel.config, - conf, + + self.assertEqual(actual, self.output_grouped) + mock_print.assert_called_once_with(self.output_grouped) + + @mock.patch(TESTING_MODULE + '.print', create=True) + def test_run_dir(self, mock_print): + actual = run( + self.test_data, + PEP8_CONFIG, + mock.Mock(formatter='grouped', + ci=False, + print=True, + header=False), ) - mock_parser.error.assert_called_once_with(mock.ANY) - @mock.patch('os.path.exists') - @mock.patch('os.getcwd') - def test_find_config(self, mock_getcwd, mock_exists): - mock_getcwd.return_value = os.path.join('path', 'to', 'importanize') - mock_exists.side_effect = False, True + self.assertIsNone(actual) + mock_print.assert_has_calls([ + mock.call(self.output_grouped), + ]) - config, found = find_config() + @mock.patch(TESTING_MODULE + '.print', create=True) + def test_run_dir_co(self, mock_print): + with self.assertRaises(CIFailure): + run( + self.test_data, + PEP8_CONFIG, + mock.Mock(formatter='grouped', + ci=True, + print=True, + header=False), + ) - self.assertEqual(config, os.path.join('path', 'to', '.importanizerc')) - self.assertTrue(bool(found)) + @mock.patch.object(Path, 'cwd') + def test_find_config(self, mock_cwd): + mock_cwd.return_value = Path(__file__) - @mock.patch(TESTING_MODULE + '.run') - @mock.patch('logging.getLogger') - @mock.patch.object(parser, 'parse_args') - def test_main_without_config(self, - mock_parse_args, - mock_get_logger, - mock_run): - args = mock.MagicMock( - verbose=1, - version=False, - path=[os.path.join('path', '..')], - config=None, + config = find_config() + + expected_config = Path(__file__).parent.parent.joinpath( + IMPORTANIZE_CONFIG ) - mock_parse_args.return_value = args + self.assertEqual(config, six.text_type(expected_config.resolve())) - main() + @mock.patch(TESTING_MODULE + '.print', create=True) + def test_main_version(self, mock_print): - mock_parse_args.assert_called_once_with() - mock_get_logger.assert_called_once_with('') - mock_get_logger().setLevel.assert_called_once_with(logging.INFO) - mock_run.assert_called_once_with(os.getcwd(), PEP8_CONFIG, args) + self.assertEqual(main(['--version']), 0) - @mock.patch(TESTING_MODULE + '.read') - @mock.patch(TESTING_MODULE + '.run') - @mock.patch('json.loads') - @mock.patch('logging.getLogger') - @mock.patch.object(parser, 'parse_args') - def test_main_with_config(self, - mock_parse_args, - mock_get_logger, - mock_loads, - mock_run, - mock_read): - args = mock.MagicMock( - verbose=1, - version=False, - path=[os.path.join('path', '..')], - config=mock.sentinel.config, - ) - mock_parse_args.return_value = args + self.assertEqual(mock_print.call_count, 1) + version = mock_print.mock_calls[0][1][0] + self.assertIn('version: {}'.format(__version__), version) - main() + @mock.patch(TESTING_MODULE + '.S_ISFIFO', mock.Mock(return_value=True)) + @mock.patch(TESTING_MODULE + '.print', create=True) + @mock.patch.object(sys, 'stdin') + def test_main_piped(self, mock_stdin, mock_print): + mock_stdin.read.return_value = self.input_text + actual = main([]) - mock_parse_args.assert_called_once_with() - mock_get_logger.assert_called_once_with('') - mock_get_logger().setLevel.assert_called_once_with(logging.INFO) - mock_read.assert_called_once_with(mock.sentinel.config) - mock_loads.assert_called_once_with(mock_read.return_value) - mock_run.assert_called_once_with( - os.getcwd(), mock_loads.return_value, args - ) + self.assertEqual(actual, 0) + mock_print.assert_called_once_with(self.output_grouped) + @mock.patch(TESTING_MODULE + '.S_ISFIFO', mock.Mock(return_value=False)) @mock.patch(TESTING_MODULE + '.print', create=True) - @mock.patch('sys.exit') - @mock.patch.object(parser, 'parse_args') - def test_main_version(self, mock_parse_args, mock_exit, mock_print): - mock_exit.side_effect = SystemExit - mock_parse_args.return_value = mock.MagicMock( - verbose=1, - version=True, - ) + def test_main_not_piped(self, mock_print): + actual = main([ + six.text_type(self.test_data / 'input.py'), + '--config', six.text_type(self.test_data / 'config.json'), + '--print', + '--no-header', + ]) + + self.assertEqual(actual, 0) + mock_print.assert_called_once_with(self.output_grouped) + + @mock.patch(TESTING_MODULE + '.S_ISFIFO', mock.Mock(return_value=False)) + @mock.patch(TESTING_MODULE + '.print', create=True) + def test_main_not_piped_ci(self, mock_print): + actual = main([ + six.text_type(self.test_data / 'input.py'), + '--config', six.text_type(self.test_data / 'config.json'), + '--ci', + ]) + + self.assertEqual(actual, 1) + + @mock.patch(TESTING_MODULE + '.S_ISFIFO', mock.Mock(return_value=False)) + @mock.patch(TESTING_MODULE + '.print', create=True) + @mock.patch(TESTING_MODULE + '.run') + def test_main_not_piped_exception(self, mock_run, mock_print): + mock_run.side_effect = ValueError - with self.assertRaises(SystemExit): - main() + actual = main([ + six.text_type(self.test_data / 'input.py'), + '--config', six.text_type(self.test_data / 'config.json'), + '--ci', + ]) - mock_parse_args.assert_called_once_with() - mock_exit.assert_called_once_with(0) - mock_print.assert_called_once_with(mock.ANY) + self.assertEqual(actual, 1) diff --git a/tests/test_parser.py b/tests/test_parser.py index e2cc0c2..001742e 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -2,13 +2,12 @@ from __future__ import print_function, unicode_literals import unittest -import mock import six from importanize.parser import ( Token, find_imports_from_lines, - get_file_artifacts, + get_text_artifacts, parse_statements, tokenize_import_lines, ) @@ -30,23 +29,18 @@ def test_normalized(self): class TestParsing(unittest.TestCase): - @mock.patch(TESTING_MODULE + '.read') - def test_get_file_artifacts(self, mock_read): - mock_read.return_value = 'Hello\nWorld\n' - actual = get_file_artifacts(mock.sentinel.path) + def test_get_text_artifacts(self): + actual = get_text_artifacts('Hello\nWorld\n') self.assertDictEqual(actual, { 'sep': '\n', }) - mock_read.assert_called_once_with(mock.sentinel.path) - mock_read.return_value = 'Hello\r\nWorld\n' - actual = get_file_artifacts(mock.sentinel.path) + actual = get_text_artifacts('Hello\r\nWorld\n') self.assertDictEqual(actual, { 'sep': '\r\n', }) - mock_read.return_value = 'Hello' - actual = get_file_artifacts(mock.sentinel.path) + actual = get_text_artifacts('Hello') self.assertDictEqual(actual, { 'sep': '\n', }) diff --git a/tests/test_statements.py b/tests/test_statements.py index 9abfd12..5ae615f 100644 --- a/tests/test_statements.py +++ b/tests/test_statements.py @@ -104,16 +104,39 @@ def test_hash_mock(self, mock_as_string): class TestImportStatement(unittest.TestCase): def test_init(self): actual = ImportStatement(mock.sentinel.line_numbers, - mock.sentinel.stem) + 'foo') self.assertEqual(actual.line_numbers, mock.sentinel.line_numbers) - self.assertEqual(actual.stem, mock.sentinel.stem) + self.assertEqual(actual.stem, 'foo') + self.assertIsNone(actual.as_name) + self.assertEqual(actual.leafs, []) + + actual = ImportStatement(mock.sentinel.line_numbers, + 'foo as bar') + self.assertEqual(actual.line_numbers, mock.sentinel.line_numbers) + self.assertEqual(actual.stem, 'foo') + self.assertEqual(actual.as_name, 'bar') + self.assertEqual(actual.leafs, []) + + actual = ImportStatement(mock.sentinel.line_numbers, + 'foo as foo') + self.assertEqual(actual.line_numbers, mock.sentinel.line_numbers) + self.assertEqual(actual.stem, 'foo') + self.assertIsNone(actual.as_name) self.assertEqual(actual.leafs, []) actual = ImportStatement(mock.sentinel.line_numbers, - mock.sentinel.stem, + 'foo', + mock.sentinel.leafs) + self.assertEqual(actual.line_numbers, mock.sentinel.line_numbers) + self.assertEqual(actual.stem, 'foo') + self.assertEqual(actual.leafs, mock.sentinel.leafs) + + actual = ImportStatement(mock.sentinel.line_numbers, + 'foo as bar', mock.sentinel.leafs) self.assertEqual(actual.line_numbers, mock.sentinel.line_numbers) - self.assertEqual(actual.stem, mock.sentinel.stem) + self.assertEqual(actual.stem, 'foo') + self.assertIsNone(actual.as_name) self.assertEqual(actual.leafs, mock.sentinel.leafs) def test_root_module(self): @@ -140,6 +163,7 @@ def _test(stem, leafs, expected): self.assertEqual(statement.as_string(), expected) _test('a', [], 'import a') + _test('a as b', [], 'import a as b') _test('a.b.c', [], 'import a.b.c') _test('a.b', ['c'], 'from a.b import c') _test('a.b', ['c', 'd'], 'from a.b import c, d') diff --git a/tests/test_utils.py b/tests/test_utils.py index f2aff26..e369279 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,46 +1,21 @@ # -*- coding: utf-8 -*- from __future__ import print_function, unicode_literals -import os -import sys import unittest import mock from importanize.utils import ( - ignore_site_packages_paths, - is_std_lib, + force_bytes, + force_text, is_site_package, + is_std_lib, + list_split, list_strip, read, ) class TestUtils(unittest.TestCase): - def _test_ignore_site_packages_paths(self, raise_msg=None): - sys.path.append(os.getcwd()) - paths = sys.path[:] - - try: - with ignore_site_packages_paths(): - self.assertNotEqual(sys.path, paths) - self.assertLess(len(sys.path), len(paths)) - if raise_msg: - raise ValueError(raise_msg) - except ValueError as e: - if raise_msg not in str(e): - # -- This only happens if there's a bug in this test - raise # pragma: no cover - - self.assertIn(os.getcwd(), sys.path) - self.assertListEqual(sys.path, paths) - sys.path.remove(os.getcwd()) - - def test_site_packages_paths(self): - self._test_ignore_site_packages_paths(raise_msg=None) - - def test_site_packages_paths_exception(self): - self._test_ignore_site_packages_paths(raise_msg="TEST EXCEPTION") - def test_is_std_lib(self): self.assertFalse(is_std_lib('')) @@ -69,7 +44,6 @@ def test_is_std_lib(self): 'operator', 'optparse', 'os', - 'pdb', 'pickle', 'pprint', 'random', @@ -159,3 +133,17 @@ def test_read(self, mock_open): .read.return_value .decode.return_value) ) + + def test_list_split(self): + self.assertEqual( + list(list_split(['foo', '/', 'bar'], '/')), + [['foo'], ['bar']] + ) + + def test_force_text(self): + self.assertEqual(force_text(b'foo'), u'foo') + self.assertEqual(force_text(u'foo'), u'foo') + + def test_force_bytes(self): + self.assertEqual(force_bytes('foo'), b'foo') + self.assertEqual(force_bytes(b'foo'), b'foo') diff --git a/tox.ini b/tox.ini index 5ab0192..bc27627 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py34, pypy +envlist = py27, py36, pypy [testenv] setenv = @@ -12,4 +12,4 @@ whitelist_externals = make [flake8] -exclude = tests/test_data/* +exclude = .tox, tests/test_data/*