From a8846e2141bc47349a23684288b04e55162eca69 Mon Sep 17 00:00:00 2001 From: "Augusto W. Andreoli" Date: Mon, 1 Mar 2021 17:24:44 +0100 Subject: [PATCH] test: slightly increase coverage, use Fuss classes (#301) * test: check, apply, assert violations, all in the same helper method * test: some more flake8() and API calls * test: keep non-breaking space needed by some tests * test: violations and missing coverage lines * docs: improve contribution guidelines * docs: to install hooks / open HTML indexes with Invoke * refactor: use Python 3.6 type annotations --- CHANGELOG.md | 2 +- CONTRIBUTING.rst | 39 ++-- docs/conf.py | 4 +- docs/contributing.rst | 2 + docs/targets.rst | 19 +- setup.cfg | 6 +- src/nitpick/core.py | 10 +- src/nitpick/exceptions.py | 16 +- src/nitpick/formats.py | 16 +- src/nitpick/generic.py | 9 +- src/nitpick/plugins/pre_commit.py | 25 +-- src/nitpick/schemas.py | 8 +- src/nitpick/style.py | 14 +- src/nitpick/violations.py | 5 +- tasks.py | 43 +++-- tests/helpers.py | 103 ++++++---- tests/test_config.py | 19 +- tests/test_generic.py | 35 +++- tests/test_json.py | 202 ++++++++++++-------- tests/test_plugin.py | 28 +-- tests/test_pre_commit.py | 301 +++++++++++++++++++----------- tests/test_pyproject_toml.py | 6 +- tests/test_setup_cfg.py | 35 +++- tests/test_style.py | 180 ++++++++++-------- tests/test_text.py | 57 +++--- tests/test_violations.py | 66 +++++++ 26 files changed, 767 insertions(+), 483 deletions(-) create mode 100644 tests/test_violations.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e33295..b3d175af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,9 @@ - apply changes to setup.cfg ([#288](https://github.com/andreoliwa/nitpick/issues/288)) ([f878630](https://github.com/andreoliwa/nitpick/commit/f87863066642cdab112d3145c488c9a780e7c98d)) - **cli:** add 'ls' command to list configured files ([cfc031b](https://github.com/andreoliwa/nitpick/commit/cfc031bdf30105dec9a8952bfb9657aec939b3b6)) - **cli:** add 'run' command to display violations ([a67bfa8](https://github.com/andreoliwa/nitpick/commit/a67bfa8bdaef2461853a237819cd35622c5935e9)) +- **cli:** experimental CLI interface (alpha version) ([#255](https://github.com/andreoliwa/nitpick/issues/255)) ([c9ca5dc](https://github.com/andreoliwa/nitpick/commit/c9ca5dc3cc4586b459e2c58fb2e61d80aa3f1e5d)) - **cli:** filter only the desired files on ls/run commands ([#265](https://github.com/andreoliwa/nitpick/issues/265)) ([f5e4a9c](https://github.com/andreoliwa/nitpick/commit/f5e4a9c47583cd809941ca96ec2ffbdbf0c92c6f)) - drop support for Python 3.5 ([#251](https://github.com/andreoliwa/nitpick/issues/251)) ([9f84a60](https://github.com/andreoliwa/nitpick/commit/9f84a608a4ca02e8a96ec8eaaf55e5cb207b35e3)), closes [#250](https://github.com/andreoliwa/nitpick/issues/250) -- experimental CLI interface (alpha version) ([#255](https://github.com/andreoliwa/nitpick/issues/255)) ([c9ca5dc](https://github.com/andreoliwa/nitpick/commit/c9ca5dc3cc4586b459e2c58fb2e61d80aa3f1e5d)) ## [0.23.1](https://github.com/andreoliwa/nitpick/compare/v0.23.0...v0.23.1) (2020-11-02) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 10a984cd..b52bc6c0 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,5 +1,3 @@ -.. include:: targets.rst - ============ Contributing ============ @@ -18,7 +16,7 @@ Bug reports or feature requests Documentation improvements ========================== -nitpick could always use more documentation, whether as part of the +Nitpick_ could always use more documentation, whether as part of the official docs, in docstrings, or even on the web in blog posts, articles, and such. @@ -35,35 +33,41 @@ To set up Nitpick_ for local development: git clone git@github.com:your_name_here/nitpick.git cd nitpick -3. Install Poetry_ globally using the recommended way. +3. Install Poetry_ globally using `the recommended way `_. -4. Install packages:: +4. Install Invoke_. You can use pipx_ to install it globally: ``pipx install invoke``. - poetry install +5. Install dependencies and pre-commit_ hooks:: - # Output: - # Installing dependencies from lock file - # ... + invoke install --hooks -5. Create a branch for local development:: +6. Create a branch for local development:: git checkout -b name-of-your-bugfix-or-feature Now you can make your changes locally. -6. When you're done making changes, run pre-commit checks and tests with:: +7. When you're done making changes, run tests and checks locally with:: + # Quick tests and checks make + # Or use this to simulate a full CI build with tox + invoke ci-build -7. Commit your changes and push your branch to GitHub:: +8. Commit your changes and push your branch to GitHub:: git add . - git commit -m "feat: detailed description of your changes" + + # For a feature: + git commit -m "feat: short description of your feature" + # For a bug fix: + git commit -m "fix: short description of what you fixed" + git push origin name-of-your-bugfix-or-feature -8. Submit a pull request through the GitHub website. +9. Submit a pull request through the GitHub website. -9. If your pull request is accepted, all your commits will be squashed into one, and the `Conventional Commits Format `_ will be used on the commit message. +10. If your pull request is accepted, all your commits will be squashed into one, and the `Conventional Commits Format `_ will be used on the commit message. Pull Request Guidelines ----------------------- @@ -72,11 +76,10 @@ If you need some code review or feedback while you're developing the code just m For merging, you should: -1. Include passing tests (run ``make test``) [1]_. +1. Include passing tests (run ``invoke test``) [1]_. 2. Update documentation when there's new API, functionality etc. 3. Add yourself to ``AUTHORS.rst``. -.. [1] If you don't have all the necessary python versions available locally you can rely on Travis - it will - `run the tests `_ for each change you add in the pull request. +.. [1] If you don't have all the necessary python versions available locally you can rely on GitHub Workflows: `tests will run `_ for each change you add in the pull request. It will be slower though ... diff --git a/docs/conf.py b/docs/conf.py index b2ca6c13..4643a0b5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -153,7 +153,7 @@ # -- Options for LaTeX output ------------------------------------------------ -latex_elements = { +latex_elements: Dict[str, str] = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', @@ -166,7 +166,7 @@ # Latex figure (float) alignment # # 'figure_align': 'htbp', -} # type: Dict[str, str] +} # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, diff --git a/docs/contributing.rst b/docs/contributing.rst index e582053e..939bd73e 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -1 +1,3 @@ +.. include:: targets.rst + .. include:: ../CONTRIBUTING.rst diff --git a/docs/targets.rst b/docs/targets.rst index 23b1cb19..1e6b6d49 100644 --- a/docs/targets.rst +++ b/docs/targets.rst @@ -1,21 +1,22 @@ -.. _nitpick-style.toml: -.. _default style file: https://raw.githubusercontent.com/andreoliwa/nitpick/v0.24.1/nitpick-style.toml - +.. _Bash: https://www.gnu.org/software/bash/ .. _black: https://github.com/psf/black +.. _commitlint: https://commitlint.js.org/ +.. _default style file: https://raw.githubusercontent.com/andreoliwa/nitpick/v0.24.1/nitpick-style.toml .. _Django: https://www.djangoproject.com .. _flake8: https://gitlab.com/pycqa/flake8/ .. _Flask CLI: https://flask.palletsprojects.com/en/1.1.x/cli/ +.. _Invoke: https://github.com/pyinvoke/invoke +.. _IPython: https://ipython.org .. _isort: https://github.com/PyCQA/isort/ .. _mypy: https://github.com/python/mypy/ +.. _nitpick-style.toml: .. _Nitpick: https://github.com/andreoliwa/nitpick/ +.. _package.json: https://docs.npmjs.com/files/package.json .. _Pipenv: https://github.com/pypa/pipenv/ +.. _pipx: https://github.com/pipxproject/pipx .. _Poetry: https://github.com/python-poetry/poetry/ .. _pre-commit: https://pre-commit.com/ .. _Pylint: https://www.pylint.org -.. _TOML: https://github.com/toml-lang/toml -.. _IPython: https://ipython.org -.. _package.json: https://docs.npmjs.com/files/package.json -.. _Bash: https://www.gnu.org/software/bash/ -.. _commitlint: https://commitlint.js.org/ -.. _pytest: https://pytest.org/ .. _pyproject-toml-poetry: https://github.com/python-poetry/poetry/blob/master/docs/docs/pyproject.md +.. _pytest: https://pytest.org/ +.. _TOML: https://github.com/toml-lang/toml diff --git a/setup.cfg b/setup.cfg index 4a4ceff5..9b12c439 100644 --- a/setup.cfg +++ b/setup.cfg @@ -56,6 +56,7 @@ setenv = PY_IGNORE_IMPORTMISMATCH = 1 commands = python -m pip --version + # https://pytest-cov.readthedocs.io/en/latest/config.html#caveats # https://docs.pytest.org/en/stable/skipping.html python -m pytest --cov-config=setup.cfg --cov --cov-append --cov-report=term-missing --doctest-modules -s -rxXs {posargs:-vv} @@ -71,10 +72,10 @@ description = Lint all files with pre-commit basepython = python3.8 platform = linux|darwin # pylint needs both these extras: -# lint: install pylint itself -# test: for pylint to inspect tests extras = + # Install pylint itself lint + # For pylint to inspect tests test deps = pip>=19.2 @@ -100,7 +101,6 @@ description = Build the HTML docs using Sphinx (sphinx-build, API docs, link che basepython = python3.8 platform = linux|darwin extras = doc -# Options to debug Sphinx: -nWT --keep-going -vvv commands = sphinx-apidoc --force --module-first --separate --implicit-namespaces --output-dir docs/source src/nitpick/ python3 docs/generate_rst.py diff --git a/src/nitpick/core.py b/src/nitpick/core.py index 53e5c688..5cc78977 100644 --- a/src/nitpick/core.py +++ b/src/nitpick/core.py @@ -8,7 +8,6 @@ import click from loguru import logger -from nitpick.constants import PROJECT_NAME from nitpick.exceptions import QuitComplainingError from nitpick.generic import filter_names, relative_to_current_dir from nitpick.plugins.info import FileInfo @@ -99,9 +98,8 @@ def enforce_present_absent(self, *partial_names: str) -> Iterator[Fuss]: def enforce_style(self, *partial_names: str, apply=True) -> Iterator[Fuss]: """Read the merged style and enforce the rules in it. - 1. Get all root keys from the merged style - 2. All except "nitpick" are file names. - 3. For each file name, find the plugin(s) that can handle the file. + 1. Get all root keys from the merged style (every key is a filename, except "nitpick"). + 2. For each file name, find the plugin(s) that can handle the file. :param partial_names: Names of the files to enforce configs for. :param apply: Flag to apply changes, if the plugin supports it (default: True). @@ -114,10 +112,6 @@ def enforce_style(self, *partial_names: str, apply=True) -> Iterator[Fuss]: logger.info(f"{config_key}: Finding plugins to enforce style") # 2. - if config_key == PROJECT_NAME: - continue - - # 3. info = FileInfo.create(self.project, config_key) # pylint: disable=no-member for plugin_class in self.project.plugin_manager.hook.can_handle(info=info): # type: Type[NitpickPlugin] diff --git a/src/nitpick/exceptions.py b/src/nitpick/exceptions.py index a5c4669c..8e4f487e 100644 --- a/src/nitpick/exceptions.py +++ b/src/nitpick/exceptions.py @@ -37,18 +37,16 @@ def pre_commit_without_dash(path_from_root: str) -> bool: return False @staticmethod - def jsonfile_section(style_errors: Dict[str, Any], is_merged_style: bool) -> bool: + def jsonfile_section(style_errors: Dict[str, Any]) -> bool: """The [nitpick.JSONFile] is not needed anymore; JSON files are now detected by the extension.""" has_nitpick_jsonfile_section = style_errors.get(PROJECT_NAME, {}).pop("JSONFile", None) if has_nitpick_jsonfile_section: - if not style_errors[PROJECT_NAME]: - style_errors.pop(PROJECT_NAME) - if not is_merged_style: - warnings.warn( - "The [nitpick.JSONFile] section is not needed anymore; just declare your JSON files directly", - DeprecationWarning, - ) - return True + style_errors.pop(PROJECT_NAME) + warnings.warn( + "The [nitpick.JSONFile] section is not needed anymore; just declare your JSON files directly", + DeprecationWarning, + ) + return True return False diff --git a/src/nitpick/formats.py b/src/nitpick/formats.py index edb498d9..b25c8578 100644 --- a/src/nitpick/formats.py +++ b/src/nitpick/formats.py @@ -31,11 +31,11 @@ def __init__( self.format_class = format_class - self.missing = None # type: Optional[BaseFormat] - self.missing_dict = {} # type: Union[JsonDict, YamlData] + self.missing: Optional[BaseFormat] = None + self.missing_dict: Union[JsonDict, YamlData] = {} - self.diff = None # type: Optional[BaseFormat] - self.diff_dict = {} # type: Union[JsonDict, YamlData] + self.diff: Optional[BaseFormat] = None + self.diff_dict: Union[JsonDict, YamlData] = {} @property def has_changes(self) -> bool: @@ -45,7 +45,7 @@ def has_changes(self) -> bool: @staticmethod def _normalize_value(value: Union[JsonDict, YamlData, "BaseFormat"]) -> JsonDict: if isinstance(value, BaseFormat): - dict_value = value.as_data # type: JsonDict + dict_value: JsonDict = value.as_data else: dict_value = value return flatten(dict_value) @@ -110,7 +110,7 @@ def __init__( raise RuntimeError("Inform at least one argument: path, string or data") self._ignore_keys = ignore_keys or [] - self._reformatted = None # type: Optional[str] + self._reformatted: Optional[str] = None self._loaded = False @abc.abstractmethod @@ -147,10 +147,10 @@ def _create_comparison(self, expected: Union[JsonDict, YamlData, "BaseFormat"]): if not self._ignore_keys: return Comparison(self.as_data or {}, expected or {}, self.__class__) - actual_original = self.as_data or {} # type: Union[JsonDict, YamlData] + actual_original: Union[JsonDict, YamlData] = self.as_data or {} actual_copy = actual_original.copy() if isinstance(actual_original, dict) else actual_original - expected_original = expected or {} # type: Union[JsonDict, YamlData, "BaseFormat"] + expected_original: Union[JsonDict, YamlData, "BaseFormat"] = expected or {} if isinstance(expected_original, dict): expected_copy = expected_original.copy() elif isinstance(expected_original, BaseFormat): diff --git a/src/nitpick/generic.py b/src/nitpick/generic.py index 386d972d..8796cb58 100644 --- a/src/nitpick/generic.py +++ b/src/nitpick/generic.py @@ -32,18 +32,11 @@ def flatten(dict_, parent_key="", separator=".", current_lists=None) -> JsonDict Use :py:meth:`unflatten()` to revert. Adapted from `this StackOverflow question `_. - - >>> expected = {'root.sub1': 1, 'root.sub2.deep': 3, 'sibling': False} - >>> flatten({"root": {"sub1": 1, "sub2": {"deep": 3}}, "sibling": False}) == expected - True - >>> expected = {'parent."with.dot".again': True, 'parent."my.my"': 1, "parent.123": "numeric-key"} - >>> flatten({"parent": {"with.dot": {"again": True}, "my.my": 1, 123: "numeric-key"}}) == expected - True """ if current_lists is None: current_lists = {} - items = [] # type: List[Tuple[str, Any]] + items: List[Tuple[str, Any]] = [] for key, value in dict_.items(): quoted_key = f"{DOUBLE_QUOTE}{key}{DOUBLE_QUOTE}" if separator in str(key) else key new_key = str(parent_key) + separator + str(quoted_key) if parent_key else quoted_key diff --git a/src/nitpick/plugins/pre_commit.py b/src/nitpick/plugins/pre_commit.py index db6d080a..de67cab5 100644 --- a/src/nitpick/plugins/pre_commit.py +++ b/src/nitpick/plugins/pre_commit.py @@ -69,8 +69,8 @@ class Violations(ViolationEnum): REPO_DOES_NOT_EXIST = (333, ": repo {repo!r} does not exist under {key!r}") MISSING_KEY_IN_REPO = (334, ": missing {key!r} in repo {repo!r}") STYLE_FILE_MISSING_NAME = (335, ": style file is missing {key!r} in repo {repo!r}") - MISSING_KEY_IN_HOOK = (336, ": style file is missing {key!r} in hook:\n{yaml}") - MISSING_HOOK_WITH_ID = (337, ": missing hook with id {id!r}:\n{yaml}") + MISSING_KEY_IN_HOOK = (336, ": style file is missing {key!r} in hook:") + MISSING_HOOK_WITH_ID = (337, ": missing hook with id {id!r}:") class PreCommitPlugin(NitpickPlugin): @@ -94,15 +94,10 @@ def initial_contents(self) -> str: original_repos = original.pop(KEY_REPOS, []) suggested: Dict[str, Any] = {KEY_REPOS: []} if original_repos else {} for repo in original_repos: - new_repo = dict(repo) - hooks_or_yaml = repo.get(KEY_HOOKS, repo.get(KEY_YAML, {})) - if KEY_YAML in repo: - repo_list = YAMLFormat(string=hooks_or_yaml).as_list - suggested[KEY_REPOS].extend(repo_list) - else: - # TODO: show a deprecation warning for this case - new_repo[KEY_HOOKS] = YAMLFormat(string=hooks_or_yaml).as_data - suggested[KEY_REPOS].append(new_repo) + if KEY_YAML not in repo: + continue + repo_list = YAMLFormat(string=repo[KEY_YAML]).as_list + suggested[KEY_REPOS].extend(repo_list) suggested.update(original) return YAMLFormat(data=suggested).reformatted @@ -176,7 +171,6 @@ def enforce_repo_old_format(self, index: int, repo_data: OrderedDict) -> Iterato yield self.reporter.make_fuss(Violations.MISSING_KEY_IN_REPO, key=KEY_HOOKS, repo=repo_name) return - actual_hooks = actual_repo_dict.get(KEY_HOOKS) or [] yaml_expected_hooks = repo_data.get(KEY_HOOKS) if not yaml_expected_hooks: yield self.reporter.make_fuss(Violations.STYLE_FILE_MISSING_NAME, key=KEY_HOOKS, repo=repo_name) @@ -187,13 +181,10 @@ def enforce_repo_old_format(self, index: int, repo_data: OrderedDict) -> Iterato hook_id = expected_dict.get(KEY_ID) expected_yaml = self.format_hook(expected_dict).rstrip() if not hook_id: - yield self.reporter.make_fuss(Violations.MISSING_KEY_IN_HOOK, key=KEY_ID, yaml=expected_yaml) + yield self.reporter.make_fuss(Violations.MISSING_KEY_IN_HOOK, expected_yaml, key=KEY_ID) continue - actual_dict = find_object_by_key(actual_hooks, KEY_ID, hook_id) - if not actual_dict: - yield self.reporter.make_fuss(Violations.MISSING_HOOK_WITH_ID, id=hook_id, yaml=expected_yaml) - continue + yield self.reporter.make_fuss(Violations.MISSING_HOOK_WITH_ID, expected_yaml, id=hook_id) @staticmethod def format_hook(expected_dict) -> str: diff --git a/src/nitpick/schemas.py b/src/nitpick/schemas.py index 6f2ebfcb..192631dc 100644 --- a/src/nitpick/schemas.py +++ b/src/nitpick/schemas.py @@ -14,13 +14,11 @@ def flatten_marshmallow_errors(errors: Dict) -> str: """Flatten Marshmallow errors to a string.""" formatted = [] for field, data in SortedDict(flatten(errors)).items(): - if isinstance(data, list): + if isinstance(data, (list, tuple)): messages_per_field = [f"{field}: {', '.join(data)}"] - elif isinstance(data, dict): - messages_per_field = [f"{field}[{index}]: {', '.join(messages)}" for index, messages in data.items()] else: - # This should never happen; if it does, let's just convert to a string - messages_per_field = [str(errors)] + # This should not happen; if it does, let's just convert to a string + messages_per_field = [f"{field}: {data}"] formatted.append("\n".join(messages_per_field)) return "\n".join(formatted) diff --git a/src/nitpick/style.py b/src/nitpick/style.py index dae93a7d..dfd3c365 100644 --- a/src/nitpick/style.py +++ b/src/nitpick/style.py @@ -138,7 +138,7 @@ def include_multiple_styles(self, chosen_styles: StrOrList) -> Iterator[Fuss]: self._all_styles.add(toml_dict) - sub_styles = search_dict(NITPICK_STYLES_INCLUDE_JMEX, toml_dict, []) # type: StrOrList + sub_styles: StrOrList = search_dict(NITPICK_STYLES_INCLUDE_JMEX, toml_dict, []) if sub_styles: yield from self.include_multiple_styles(sub_styles) @@ -169,7 +169,7 @@ def _validate_config(self, config_dict: Dict) -> Tuple[Dict, Dict]: all_errors.update(errors) if not valid_schema: - Deprecation.jsonfile_section(all_errors, False) + Deprecation.jsonfile_section(all_errors) validation_errors.update(all_errors) return toml_dict, validation_errors @@ -177,19 +177,15 @@ def get_style_path(self, style_uri: str) -> Optional[Path]: """Get the style path from the URI. Add the .toml extension if it's missing.""" clean_style_uri = style_uri.strip() - remote = None + remote = False if clean_style_uri.startswith(DOT_SLASH): remote = False elif is_url(clean_style_uri) or is_url(self._first_full_path): remote = True - elif clean_style_uri: - remote = False if remote is True: return self.fetch_style_from_url(clean_style_uri) - if remote is False: - return self.fetch_style_from_local_path(clean_style_uri) - return None + return self.fetch_style_from_local_path(clean_style_uri) def fetch_style_from_url(self, url: str) -> Optional[Path]: """Fetch a style file from a URL, saving the contents in the cache dir.""" @@ -275,7 +271,7 @@ def merge_toml_dict(self) -> JsonDict: return {} merged_dict = self._all_styles.merge() # TODO: check if the merged style file is still needed - merged_style_path = self.cache_dir / MERGED_STYLE_TOML # type: Path + merged_style_path: Path = self.cache_dir / MERGED_STYLE_TOML toml = TOMLFormat(data=merged_dict) attempt = 1 diff --git a/src/nitpick/violations.py b/src/nitpick/violations.py index 5656fac2..7ff226a6 100644 --- a/src/nitpick/violations.py +++ b/src/nitpick/violations.py @@ -103,7 +103,10 @@ def make_fuss(self, violation: ViolationEnum, suggestion: str = "", fixed=False, formatted = violation.message base = self.violation_base_code if violation.add_code else 0 Reporter.increment(fixed) - return Fuss(fixed, self.info.path_from_root if self.info else "", base + violation.code, formatted, suggestion) + # Remove right whitespace from suggestion (new lines, spaces, etc.) + return Fuss( + fixed, self.info.path_from_root if self.info else "", base + violation.code, formatted, suggestion.rstrip() + ) @classmethod def reset(cls): diff --git a/tasks.py b/tasks.py index 03235adc..4d709381 100644 --- a/tasks.py +++ b/tasks.py @@ -16,8 +16,9 @@ def install(c, deps=True, hooks=False): Poetry install is needed to create the Nitpick plugin entries on setuptools, used by pluggy. """ if deps: + print("Nitpick runs in Python 3.6 and later, but development is done in 3.6") c.run("poetry env use python3.6") - c.run("poetry install -E test -E lint --remove-untracked", pty=True) + c.run("poetry install -E test -E lint --remove-untracked") if hooks: c.run("pre-commit install --install-hooks") c.run("pre-commit install --hook-type commit-msg") @@ -41,9 +42,9 @@ def update(c, deps=True, hooks=False): install(c, deps, hooks) -@task -def test(c): - """Run tests with pytest; use the command from tox config.""" +@task(help={"coverage": "Run and display the coverage HTML report", "open": "Open the HTML index"}) +def test(c, coverage=False, open=False): + """Run tests and coverage using the commands from tox config.""" parser = ConfigParser() parser.read("setup.cfg") pytest_cmd = ( @@ -51,7 +52,16 @@ def test(c): .replace("{posargs:", "") .replace("}", "") ) - c.run(f"poetry run {pytest_cmd}", pty=True) + c.run(f"poetry run {pytest_cmd}") + + if coverage: + for line in parser["testenv:report"]["commands"].splitlines(): + if not line: + continue + c.run(f"poetry run {line}") + + if open: + c.run("open htmlcov/index.html") @task @@ -63,22 +73,25 @@ def nitpick(c): @task def pylint(c): """Run pylint for all files.""" - c.run("poetry run pylint src/", pty=True) + c.run("poetry run pylint src/") @task def pre_commit(c): """Run pre-commit for all files.""" - c.run("pre-commit run --all-files", pty=True) + c.run("pre-commit run --all-files") -@task -def doc(c): +@task(help={"open": "Open the HTML index"}) +def doc(c, open=False): """Build documentation.""" c.run("mkdir -p docs/_static") c.run("rm -rf docs/source") c.run("tox -e docs") + if open: + c.run("open .tox/docs_out/index.html") + @task(help={"full": "Full build using tox", "recreate": "Recreate tox environment"}) def ci_build(c, full=False, recreate=False): @@ -104,5 +117,13 @@ def clean(c): namespace = Collection(install, update, test, nitpick, pylint, pre_commit, doc, ci_build, clean) -# Echo all commands in all tasks by default (like 'make' does) -namespace.configure({"run": {"echo": True}}) +namespace.configure( + { + "run": { + # Echo all commands in all tasks by default (like 'make' does) + "echo": True, + # Use a pseudo-terminal to display colorful output + "pty": True, + } + } +) diff --git a/tests/helpers.py b/tests/helpers.py index 54138020..d6f2d912 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -4,7 +4,7 @@ from pathlib import Path from pprint import pprint from textwrap import dedent -from typing import List, Set +from typing import Dict, Iterable, List, Optional, Set import pytest from click.testing import CliRunner @@ -28,6 +28,15 @@ from nitpick.typedefs import Flake8Error, PathOrStr, StrOrList from nitpick.violations import Fuss, Reporter +FIXTURES_DIR: Path = Path(__file__).parent / "fixtures" +STYLES_DIR: Path = Path(__file__).parent.parent / "styles" + +# Non-breaking space +NBSP = "\xc2\xa0" + +SUGGESTION_BEGIN = "\x1b[32m" +SUGGESTION_END = "\x1b[0m" + # TODO: fix Windows tests XFAIL_ON_WINDOWS = pytest.mark.xfail(condition=sys.platform == "win32", reason="Different path separator on Windows") @@ -42,10 +51,6 @@ def assert_conditions(*args): class ProjectMock: """A mocked Python project to help on tests.""" - # TODO: use Python 3.6 type annotations - fixtures_dir: Path = Path(__file__).parent / "fixtures" - styles_dir: Path = Path(__file__).parent.parent / "styles" - def __init__(self, tmp_path: Path, **kwargs) -> None: """Create the root dir and make it the current dir (needed by NitpickChecker).""" self._actual_violations: Set[Fuss] = set() @@ -66,8 +71,8 @@ def create_symlink(self, link_name: str, target_dir: Path = None, target_file: s :param target_dir: Target directory (default: fixture directory). :param target_file: Target file name (default: source file name). """ - path = self.root_dir / link_name # type: Path - full_source_path = Path(target_dir or self.fixtures_dir) / (target_file or link_name) + path: Path = self.root_dir / link_name + full_source_path = Path(target_dir or FIXTURES_DIR) / (target_file or link_name) if not full_source_path.exists(): raise RuntimeError(f"Source file does not exist: {full_source_path}") path.symlink_to(full_source_path) @@ -75,7 +80,7 @@ def create_symlink(self, link_name: str, target_dir: Path = None, target_file: s self.files_to_lint.append(path) return self - def simulate_run(self, *partial_names: str, offline=False, api=True, flake8=True, apply=False) -> "ProjectMock": + def _simulate_run(self, *partial_names: str, offline=False, api=True, flake8=True, apply=False) -> "ProjectMock": """Simulate a manual flake8 run and using the API. - Clear the singleton cache. @@ -103,17 +108,61 @@ def simulate_run(self, *partial_names: str, offline=False, api=True, flake8=True return self - def flake8(self): + def flake8(self, offline=False): """Test only the flake8 plugin, no API.""" - return self.simulate_run(api=False) + return self._simulate_run(offline=offline, api=False) - def api_check(self): + def api_check(self, *partial_names: str): """Test only the API in check mode, no flake8 plugin.""" - return self.simulate_run(flake8=False, apply=False) + return self._simulate_run(*partial_names, flake8=False, apply=False) def api_apply(self, *partial_names: str): """Test only the API in apply mode, no flake8 plugin.""" - return self.simulate_run(*partial_names, flake8=False, apply=True) + return self._simulate_run(*partial_names, flake8=False, apply=True) + + def api_check_then_apply( + self, *expected_violations_when_applying: Fuss, partial_names: Optional[Iterable[str]] = None + ) -> "ProjectMock": + """Assert that check mode does not change files, and that apply mode changes them. + + Perform a series of calls and assertions: + 1. Call the API in check mode, assert violations, assert files contents were not modified. + 2. Call the API in apply mode and assert violations again. + + :param expected_violations_when_applying: Expected violations when "apply mode" is on. + :param partial_names: Names of the files to enforce configs for. + :return: ``self`` for method chaining (fluent interface) + """ + partial_names = partial_names or [] + expected_filenames = set() + expected_violations_when_checking = [] + for orig in expected_violations_when_applying: + expected_filenames.add(orig.filename) + expected_violations_when_checking.append( + Fuss(False, orig.filename, orig.code, orig.message, orig.suggestion) + ) + + contents_before_check = self.read_multiple_files(expected_filenames) + self.api_check(*partial_names).assert_violations(*expected_violations_when_checking) + contents_after_check = self.read_multiple_files(expected_filenames) + compare(expected=contents_before_check, actual=contents_after_check) + + return self.api_apply(*partial_names).assert_violations(*expected_violations_when_applying) + + def read_file(self, filename: PathOrStr) -> Optional[str]: + """Read file contents. + + :param filename: Filename from project root. + :return: File contents, or ``one`` when the file doesn't exist. + """ + path = self.root_dir / filename + if not filename or not path.exists(): + return None + return path.read_text().strip() + + def read_multiple_files(self, filenames: Iterable[PathOrStr]) -> Dict[PathOrStr, Optional[str]]: + """Read multiple files and return a hash with filename (key) and contents (value).""" + return {filename: self.read_file(filename) for filename in filenames} def save_file(self, filename: PathOrStr, file_contents: str, lint: bool = None) -> "ProjectMock": """Save a file in the root dir with the desired contents and a new line at the end. @@ -151,7 +200,7 @@ def load_styles(self, *args: PathOrStr) -> "ProjectMock": This is a good way to test the style files indirectly. """ for filename in args: - style_path = Path(self.styles_dir) / self.ensure_toml_extension(filename) + style_path = Path(STYLES_DIR) / self.ensure_toml_extension(filename) self.named_style(filename, style_path.read_text()) return self @@ -209,24 +258,6 @@ def assert_errors_contain(self, raw_error: str, expected_count: int = None) -> " self._assert_error_count(expected_error, expected_count) return self - def assert_errors_contain_unordered(self, raw_error: str, expected_count: int = None) -> "ProjectMock": - """Assert the lines of the error is in the error set, in any order. - - I couldn't find a quick way to guarantee the order of the output with ``ruamel.yaml``. - """ - # TODO Once there is a way to force some sorting on the YAML output, this method can be removed, - # and ``assert_errors_contain()`` can be used again. - original_expected_error = dedent(raw_error).strip() - expected_error = original_expected_error.replace("\x1b[0m", "") - expected_error_lines = set(expected_error.split("\n")) - for actual_error in self._flake8_errors_as_string: - if set(actual_error.replace("\x1b[0m", "").split("\n")) == expected_error_lines: - self._assert_error_count(raw_error, expected_count) - return self - - self.raise_assertion_error(original_expected_error) - return self - def assert_single_error(self, raw_error: str) -> "ProjectMock": """Assert there is only one error.""" return self.assert_errors_contain(raw_error, 1) @@ -257,7 +288,10 @@ def assert_violations(self, *expected_violations: Fuss) -> "ProjectMock": fixed += 1 else: manual += 1 - stripped.add(Fuss(orig.fixed, orig.filename, orig.code, orig.message, dedent(orig.suggestion).strip())) + + # Keep non-breaking space needed by some tests (e.g. YAML) + clean_suggestion = dedent(orig.suggestion).strip().replace(NBSP, " ") + stripped.add(Fuss(orig.fixed, orig.filename, orig.code, orig.message, clean_suggestion)) dict_difference = compare( expected=[obj.__dict__ for obj in stripped], actual=[obj.__dict__ for obj in self._actual_violations], @@ -307,7 +341,6 @@ def cli_ls(self, str_or_lines: StrOrList): def assert_file_contents(self, filename: PathOrStr, file_contents: str): """Assert the file has the expected contents.""" - path = self.root_dir / filename - actual = path.read_text().strip() + actual = self.read_file(filename) expected = dedent(file_contents).strip() compare(actual=actual, expected=expected) diff --git a/tests/test_config.py b/tests/test_config.py index cb1bef28..cd3a433a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -18,26 +18,21 @@ def test_singleton(): def test_no_root_dir(tmp_path): """No root dir.""" - ProjectMock(tmp_path, pyproject_toml=False, setup_py=False).create_symlink("hello.py").simulate_run( - api=False - ).assert_single_error("NIP101 No root dir found (is this a Python project?)") + ProjectMock(tmp_path, pyproject_toml=False, setup_py=False).create_symlink("hello.py").flake8().assert_single_error( + "NIP101 No root dir found (is this a Python project?)" + ) def test_multiple_root_dirs(tmp_path): """Multiple possible "root dirs" found (e.g.: a requirements.txt file inside a docs dir).""" ProjectMock(tmp_path, setup_py=False).touch_file("docs/requirements.txt").touch_file("docs/conf.py").pyproject_toml( "" - ).style("").simulate_run().assert_no_errors().cli_run() + ).style("").api_check_then_apply().cli_run() def test_no_python_file_root_dir(tmp_path): """No Python file on the root dir.""" - project = ( - ProjectMock(tmp_path, setup_py=False) - .pyproject_toml("") - .save_file("whatever.sh", "", lint=True) - .simulate_run(api=False) - ) + project = ProjectMock(tmp_path, setup_py=False).pyproject_toml("").save_file("whatever.sh", "", lint=True).flake8() project.assert_single_error( f"NIP102 No Python file was found on the root dir and subdir of {str(project.root_dir)!r}" ) @@ -63,7 +58,7 @@ def test_at_least_one_python_file(python_file, error, tmp_path): """ ) .save_file(python_file, "", lint=True) - .simulate_run() + .flake8() ) if error: project.assert_single_error( @@ -94,4 +89,4 @@ def test_django_project_structure(tmp_path): ["setup.cfg".flake8] some = "thing" """ - ).simulate_run().assert_no_errors() + ).api_check_then_apply() diff --git a/tests/test_generic.py b/tests/test_generic.py index d4426dde..750addbd 100644 --- a/tests/test_generic.py +++ b/tests/test_generic.py @@ -4,7 +4,9 @@ from pathlib import Path from unittest import mock -from nitpick.generic import MergeDict, get_subclasses, relative_to_current_dir +from testfixtures import compare + +from nitpick.generic import MergeDict, flatten, get_subclasses, relative_to_current_dir from tests.helpers import assert_conditions @@ -27,6 +29,37 @@ class Bicycle(Vehicle): assert_conditions(get_subclasses(Vehicle) == [Car, Audi, Bicycle]) +def test_flatten(): + """Test the flatten function with variations.""" + examples = [ + ( + {"root": {"sub1": 1, "sub2": {"deep": 3}}, "sibling": False}, + {"root.sub1": 1, "root.sub2.deep": 3, "sibling": False}, + ".", + ), + ( + {"parent": {"with.dot": {"again": True}, "my.my": 1, 123: "numeric-key"}}, + {'parent."with.dot".again': True, 'parent."my.my"': 1, "parent.123": "numeric-key"}, + ".", + ), + ( + { + "parent": { + "my#my": ("inner", "tuple", "turns", "to", "list"), + "with#hash": {"inner_list": ["x", "y", "z"]}, + } + }, + { + 'parent#"my#my"': ["inner", "tuple", "turns", "to", "list"], + 'parent#"with#hash"#inner_list': ["x", "y", "z"], + }, + "#", + ), + ] + for original, expected, separator in examples: + compare(actual=flatten(original, separator=separator), expected=expected) + + def test_merge_dicts_extending_lists(): """Merge dictionaries extending lists.""" merged = MergeDict({"parent": {"brother": 1, "sister": 2}}) diff --git a/tests/test_json.py b/tests/test_json.py index 4cae4c8f..0ec26470 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -1,6 +1,8 @@ """JSON tests.""" import warnings +from nitpick.constants import READ_THE_DOCS_URL +from nitpick.violations import Fuss from tests.helpers import ProjectMock @@ -11,21 +13,26 @@ def test_suggest_initial_contents(tmp_path): [tool.nitpick] style = ["package-json"] """ - ).simulate_run().assert_errors_contain( - """ - NIP341 File package.json was not found. Create it with this content:\x1b[32m - { - "name": "", - "release": { - "plugins": "" - }, - "repository": { - "type": "", - "url": "" - }, - "version": "" - }\x1b[0m - """ + ).api_check_then_apply( + Fuss( + False, + "package.json", + 341, + " was not found. Create it with this content:", + """ + { + "name": "", + "release": { + "plugins": "" + }, + "repository": { + "type": "", + "url": "" + }, + "version": "" + } + """, + ) ) @@ -36,19 +43,39 @@ def test_json_file_contains_keys(tmp_path): [tool.nitpick] style = ["package-json"] """ - ).save_file("package.json", '{"name": "myproject", "version": "0.0.1"}').simulate_run().assert_errors_contain( - """ - NIP348 File package.json has missing keys:\x1b[32m - { - "release": { - "plugins": "" - }, - "repository": { - "type": "", - "url": "" - } - }\x1b[0m - """ + ).save_file("package.json", '{"name": "myproject", "version": "0.0.1"}').api_check_then_apply( + Fuss( + False, + "package.json", + 348, + " has missing keys:", + """ + { + "release": { + "plugins": "" + }, + "repository": { + "type": "", + "url": "" + } + } + """, + ), + Fuss( + False, + "package.json", + 348, + " has missing values:", + """ + { + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + } + } + """, + ), ) @@ -63,44 +90,51 @@ def test_missing_different_values(tmp_path): """ formatting = """ {"doesnt":"matter","here":true,"on.the": "config file"} """ ''' - ).save_file( - "my.json", '{"name":"myproject","formatting":{"on.the":"actual file"}}' - ).simulate_run().assert_errors_contain( - """ - NIP348 File my.json has missing values:\x1b[32m - { - "formatting": { - "doesnt": "matter", - "here": true - }, - "some.dotted.root.key": { - "content": [ - "should", - "be", - "here" - ], - "dotted.subkeys": [ - "should be preserved", - { - "complex.weird.sub": { - "objects": true - }, - "even.with": 1 + ).save_file("my.json", '{"name":"myproject","formatting":{"on.the":"actual file"}}').api_check_then_apply( + Fuss( + False, + "my.json", + 348, + " has missing values:", + """ + { + "formatting": { + "doesnt": "matter", + "here": true + }, + "some.dotted.root.key": { + "content": [ + "should", + "be", + "here" + ], + "dotted.subkeys": [ + "should be preserved", + { + "complex.weird.sub": { + "objects": true + }, + "even.with": 1 + } + ], + "valid": "JSON" } - ], - "valid": "JSON" - } - }\x1b[0m - """ - ).assert_errors_contain( - """ - NIP349 File my.json has different values. Use this:\x1b[32m - { - "formatting": { - "on.the": "config file" - } - }\x1b[0m - """ + } + """, + ), + Fuss( + False, + "my.json", + 349, + " has different values. Use this:", + """ + { + "formatting": { + "on.the": "config file" + } + } + """, + ), ) @@ -117,13 +151,17 @@ def test_invalid_json(tmp_path): ["another.json".with] extra = "key" ''' - ).simulate_run().assert_errors_contain( - """ - NIP001 File nitpick-style.toml has an incorrect style. Invalid config:\x1b[32m - "another.json".contains_json.some_field.value: Invalid JSON (json.decoder.JSONDecodeError: Invalid control character at: line 1 column 37 (char 36)) - "another.json".with: Unknown configuration. See https://nitpick.rtfd.io/en/latest/nitpick_section.html.\x1b[0m - """, - 1, + ).api_check_then_apply( + Fuss( + False, + "nitpick-style.toml", + 1, + " has an incorrect style. Invalid config:", + f""" + "another.json".contains_json.some_field.value: Invalid JSON (json.decoder.JSONDecodeError: Invalid control character at: line 1 column 37 (char 36)) + "another.json".with: Unknown configuration. See {READ_THE_DOCS_URL}nitpick_section.html. + """, + ) ) @@ -137,13 +175,17 @@ def test_json_configuration(tmp_path): ["their.json"] x = 1 """ - ).simulate_run().assert_errors_contain( - """ - NIP001 File nitpick-style.toml has an incorrect style. Invalid config:\x1b[32m - "their.json".x: Unknown configuration. See https://nitpick.rtfd.io/en/latest/nitpick_section.html. - "your.json".has: Unknown configuration. See https://nitpick.rtfd.io/en/latest/nitpick_section.html.\x1b[0m - """, - 1, + ).api_check_then_apply( + Fuss( + False, + "nitpick-style.toml", + 1, + " has an incorrect style. Invalid config:", + f""" + "their.json".x: Unknown configuration. See {READ_THE_DOCS_URL}nitpick_section.html. + "your.json".has: Unknown configuration. See {READ_THE_DOCS_URL}nitpick_section.html. + """, + ) ) @@ -161,7 +203,7 @@ def test_jsonfile_deprecated(tmp_path): ["my.json"] contains_keys = ["x"] """ - ).save_file("my.json", '{"x":1}').simulate_run(api=False).assert_no_errors() + ).save_file("my.json", '{"x":1}').flake8().assert_no_errors() assert len(captured) == 1 assert issubclass(captured[-1].category, DeprecationWarning) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index af006ceb..211ce1a0 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -34,11 +34,7 @@ def test_absent_files(tmp_path): xxx = "Remove this" yyy = "Remove that" """ - ).touch_file("xxx").touch_file("yyy").simulate_run().assert_errors_contain( - "NIP104 File xxx should be deleted: Remove this" - ).assert_errors_contain( - "NIP104 File yyy should be deleted: Remove that" - ).assert_violations( + ).touch_file("xxx").touch_file("yyy").api_check_then_apply( Fuss(False, "xxx", 104, " should be deleted: Remove this"), Fuss(False, "yyy", 104, " should be deleted: Remove that"), ) @@ -46,22 +42,12 @@ def test_absent_files(tmp_path): def test_missing_message(tmp_path): """Test if the breaking style change "missing_message" key points to the correct help page.""" - project = ( - ProjectMock(tmp_path) - .style( - """ + ProjectMock(tmp_path).style( + """ [nitpick.files."pyproject.toml"] missing_message = "Install poetry and run 'poetry init' to create it" """ - ) - .simulate_run() - ) - project.assert_errors_contain( - f""" - NIP001 File nitpick-style.toml has an incorrect style. Invalid config:\x1b[32m - nitpick.files."pyproject.toml": Unknown file. See {READ_THE_DOCS_URL}nitpick_section.html#nitpick-files.\x1b[0m - """ - ).assert_violations( + ).api_check_then_apply( # pylint: disable=line-too-long Fuss( False, @@ -82,7 +68,7 @@ def test_present_files(tmp_path): ".env" = "" "another-file.txt" = "" """ - ).api_apply().assert_violations( + ).api_check_then_apply( Fuss(False, ".editorconfig", 103, " should exist: Create this file"), Fuss(False, ".env", 103, " should exist"), Fuss(False, "another-file.txt", 103, " should exist"), @@ -123,14 +109,14 @@ def test_offline_flag_env_variable(tmpdir): def test_offline_doesnt_raise_connection_error(mocked_get, tmp_path): """On offline mode, no requests are made, so no connection errors should be raised.""" mocked_get.side_effect = requests.ConnectionError("A forced error") - ProjectMock(tmp_path).simulate_run(offline=True) + ProjectMock(tmp_path).flake8(offline=True) @mock.patch("requests.get") def test_offline_recommend_using_flag(mocked_get, tmp_path, capsys): """Recommend using the flag on a connection error.""" mocked_get.side_effect = requests.ConnectionError("error message from connection here") - ProjectMock(tmp_path).simulate_run(api=False) + ProjectMock(tmp_path).flake8() out, err = capsys.readouterr() assert out == "" assert err == "Your network is unreachable. Fix your connection or use --nitpick-offline / NITPICK_OFFLINE=1\n" diff --git a/tests/test_pre_commit.py b/tests/test_pre_commit.py index 164da7e2..29587665 100644 --- a/tests/test_pre_commit.py +++ b/tests/test_pre_commit.py @@ -4,8 +4,10 @@ import pytest from testfixtures import compare +from nitpick.constants import PRE_COMMIT_CONFIG_YAML from nitpick.plugins.pre_commit import PreCommitHook -from tests.helpers import ProjectMock +from nitpick.violations import Fuss +from tests.helpers import NBSP, ProjectMock def test_pre_commit_has_no_configuration(tmp_path): @@ -13,7 +15,7 @@ def test_pre_commit_has_no_configuration(tmp_path): Also the file should not be deleted unless explicitly asked. """ - ProjectMock(tmp_path).style("").pre_commit("").simulate_run().assert_no_errors() + ProjectMock(tmp_path).style("").pre_commit("").api_check_then_apply() def test_pre_commit_referenced_in_style(tmp_path): @@ -23,8 +25,8 @@ def test_pre_commit_referenced_in_style(tmp_path): [".pre-commit-config.yaml"] fail_fast = true """ - ).pre_commit("").simulate_run().assert_single_error( - "NIP331 File .pre-commit-config.yaml doesn't have the 'repos' root key" + ).pre_commit("").api_check_then_apply( + Fuss(False, PRE_COMMIT_CONFIG_YAML, 331, " doesn't have the 'repos' root key") ) @@ -35,25 +37,56 @@ def test_suggest_initial_contents(tmp_path): [tool.nitpick] style = ["isort", "black"] """ - ).simulate_run().assert_errors_contain( - """ - NIP331 File .pre-commit-config.yaml was not found. Create it with this content:\x1b[32m - repos: + ).api_check_then_apply( + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 331, + " was not found. Create it with this content:", + """ + repos: + - repo: https://github.com/PyCQA/isort + rev: 5.7.0 + hooks: + - id: isort + - repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black + args: [--safe, --quiet] + - repo: https://github.com/asottile/blacken-docs + rev: v1.9.2 + hooks: + - id: blacken-docs + additional_dependencies: [black==20.8b1] + """, + ), + partial_names=[PRE_COMMIT_CONFIG_YAML], + ) + + +def test_no_yaml_key(tmp_path): + """Test an invalid repo config.""" + ProjectMock(tmp_path).style( + ''' + [[".pre-commit-config.yaml".repos]] + missing_yaml_key = """ - repo: https://github.com/PyCQA/isort rev: 5.7.0 hooks: - id: isort - - repo: https://github.com/psf/black - rev: 20.8b1 - hooks: - - id: black - args: [--safe, --quiet] - - repo: https://github.com/asottile/blacken-docs - rev: v1.9.2 - hooks: - - id: blacken-docs - additional_dependencies: [black==20.8b1]\x1b[0m """ + ''' + ).api_check_then_apply( + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 331, + " was not found. Create it with this content:", + """ + repos: [] + """, + ) ) @@ -66,13 +99,18 @@ def test_root_values_on_missing_file(tmp_path): fail_fast = true whatever = "1" """ - ).simulate_run().assert_errors_contain_unordered( - """ - NIP331 File .pre-commit-config.yaml was not found. Create it with this content:\x1b[32m - bla_bla: oh yeah - fail_fast: true - whatever: '1'\x1b[0m - """ + ).api_check_then_apply( + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 331, + " was not found. Create it with this content:", + """ + bla_bla: oh yeah + fail_fast: true + whatever: '1' + """, + ) ) @@ -94,18 +132,27 @@ def test_root_values_on_existing_file(tmp_path): something: false another_thing: "nope" """ - ).simulate_run().assert_errors_contain_unordered( - """ - NIP338 File .pre-commit-config.yaml has missing values:\x1b[32m - blabla: what - fail_fast: true\x1b[0m - """ - ).assert_errors_contain( - """ - NIP339 File .pre-commit-config.yaml has different values. Use this:\x1b[32m - another_thing: yep - something: true\x1b[0m - """ + ).api_check_then_apply( + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 338, + " has missing values:", + """ + blabla: what + fail_fast: true + """, + ), + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 339, + " has different values. Use this:", + """ + another_thing: yep + something: true + """, + ), ) @@ -122,8 +169,8 @@ def test_missing_repos(tmp_path): - hooks: - id: whatever """ - ).simulate_run().assert_errors_contain( - "NIP331 File .pre-commit-config.yaml doesn't have the 'repos' root key" + ).api_check_then_apply( + Fuss(False, PRE_COMMIT_CONFIG_YAML, 331, " doesn't have the 'repos' root key") ) @@ -140,8 +187,8 @@ def test_missing_repo_key(tmp_path): - hooks: - id: whatever """ - ).simulate_run().assert_single_error( - "NIP332 File .pre-commit-config.yaml: style file is missing 'repo' key in repo #0" + ).api_check_then_apply( + Fuss(False, PRE_COMMIT_CONFIG_YAML, 332, ": style file is missing 'repo' key in repo #0") ) @@ -158,8 +205,8 @@ def test_repo_does_not_exist(tmp_path): - hooks: - id: whatever """ - ).simulate_run().assert_single_error( - "NIP333 File .pre-commit-config.yaml: repo 'local' does not exist under 'repos'" + ).api_check_then_apply( + Fuss(False, PRE_COMMIT_CONFIG_YAML, 333, ": repo 'local' does not exist under 'repos'") ) @@ -175,8 +222,8 @@ def test_missing_hooks_in_repo(tmp_path): repos: - repo: whatever """ - ).simulate_run().assert_single_error( - "NIP334 File .pre-commit-config.yaml: missing 'hooks' in repo 'whatever'" + ).api_check_then_apply( + Fuss(False, PRE_COMMIT_CONFIG_YAML, 334, ": missing 'hooks' in repo 'whatever'") ) @@ -194,8 +241,8 @@ def test_style_missing_hooks_in_repo(tmp_path): hooks: - id: isort """ - ).simulate_run().assert_single_error( - "NIP335 File .pre-commit-config.yaml: style file is missing 'hooks' in repo 'another'" + ).api_check_then_apply( + Fuss(False, PRE_COMMIT_CONFIG_YAML, 335, ": style file is missing 'hooks' in repo 'another'") ) @@ -217,12 +264,17 @@ def test_style_missing_id_in_hook(tmp_path): hooks: - id: isort """ - ).simulate_run().assert_single_error( - """ - NIP336 File .pre-commit-config.yaml: style file is missing 'id' in hook: - name: isort - entry: isort -sp setup.cfg - """ + ).api_check_then_apply( + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 336, + ": style file is missing 'id' in hook:", + f""" + {NBSP*4}name: isort + {NBSP*4}entry: isort -sp setup.cfg + """, + ) ) @@ -245,13 +297,18 @@ def test_missing_hook_with_id(tmp_path): hooks: - id: isort """ - ).simulate_run().assert_single_error( - """ - NIP337 File .pre-commit-config.yaml: missing hook with id 'black': - - id: black - name: black - entry: black - """ + ).api_check_then_apply( + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 337, + ": missing hook with id 'black':", + f""" + {NBSP * 2}- id: black + {NBSP * 2} name: black + {NBSP * 2} entry: black + """, + ) ) @@ -374,55 +431,77 @@ def test_missing_different_values(tmp_path): - id: my-hook args: [--different, args, --should, throw, errors] """ - ).simulate_run().assert_errors_contain( - """ - NIP332 File .pre-commit-config.yaml: hook 'mypy' not found. Use this:\x1b[32m - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.812 - hooks: - - id: mypy\x1b[0m - """ - ).assert_errors_contain( - """ - NIP332 File .pre-commit-config.yaml: hook 'python-check-mock-methods' not found. Use this:\x1b[32m - - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.7.1 - hooks: - - id: python-check-mock-methods\x1b[0m - """ - ).assert_errors_contain( - """ - NIP339 File .pre-commit-config.yaml: hook 'bashate' (rev: 0.5.0) has different values. Use this:\x1b[32m - rev: 2.0.0\x1b[0m - """ - ).assert_errors_contain( - """ - NIP339 File .pre-commit-config.yaml: hook 'python-check-blanket-noqa' (rev: v1.1.0) has different values. Use this:\x1b[32m - rev: v1.7.1\x1b[0m - """ - ).assert_errors_contain( - """ - NIP339 File .pre-commit-config.yaml: hook 'python-no-eval' (rev: v1.1.0) has different values. Use this:\x1b[32m - rev: v1.7.1\x1b[0m - """ - ).assert_errors_contain( - """ - NIP339 File .pre-commit-config.yaml: hook 'python-no-log-warn' (rev: v1.1.0) has different values. Use this:\x1b[32m - rev: v1.7.1\x1b[0m - """ - ).assert_errors_contain( - """ - NIP339 File .pre-commit-config.yaml: hook 'my-hook' (rev: 1.2.3) has different values. Use this:\x1b[32m - args: - - --expected - - arguments\x1b[0m - """ - ).assert_errors_contain( - """ - NIP339 File .pre-commit-config.yaml: hook 'rst-backticks' (rev: v1.1.0) has different values. Use this:\x1b[32m - rev: v1.7.1\x1b[0m - """, - 8, + ).api_check_then_apply( + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 332, + ": hook 'mypy' not found. Use this:", + f""" + {NBSP * 2}- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.812 + hooks: + - id: mypy + """, + ), + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 332, + ": hook 'python-check-mock-methods' not found. Use this:", + f""" + {NBSP * 2}- repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.7.1 + hooks: + - id: python-check-mock-methods + """, + ), + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 339, + ": hook 'bashate' (rev: 0.5.0) has different values. Use this:", + "rev: 2.0.0", + ), + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 339, + ": hook 'python-check-blanket-noqa' (rev: v1.1.0) has different values. Use this:", + "rev: v1.7.1", + ), + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 339, + ": hook 'python-no-eval' (rev: v1.1.0) has different values. Use this:", + "rev: v1.7.1", + ), + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 339, + ": hook 'python-no-log-warn' (rev: v1.1.0) has different values. Use this:", + "rev: v1.7.1", + ), + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 339, + ": hook 'my-hook' (rev: 1.2.3) has different values. Use this:", + """ + args: + - --expected + - arguments + """, + ), + Fuss( + False, + PRE_COMMIT_CONFIG_YAML, + 339, + ": hook 'rst-backticks' (rev: v1.1.0) has different values. Use this:", + "rev: v1.7.1", + ), ) @@ -440,9 +519,7 @@ def test_pre_commit_section_without_dot_deprecated(tmp_path): ) with pytest.deprecated_call() as warning_list: - project.simulate_run(api=False).assert_single_error( - "NIP331 File .pre-commit-config.yaml doesn't have the 'repos' root key" - ) + project.flake8().assert_single_error("NIP331 File .pre-commit-config.yaml doesn't have the 'repos' root key") assert len(warning_list) == 1 assert ( diff --git a/tests/test_pyproject_toml.py b/tests/test_pyproject_toml.py index 8166231c..eced8d6f 100644 --- a/tests/test_pyproject_toml.py +++ b/tests/test_pyproject_toml.py @@ -6,7 +6,7 @@ def test_pyproject_has_no_configuration(tmp_path): """File should not be deleted unless explicitly asked.""" - ProjectMock(tmp_path).style("").pyproject_toml("").simulate_run().assert_no_errors() + ProjectMock(tmp_path).style("").pyproject_toml("").api_check_then_apply() def test_suggest_initial_contents(tmp_path): @@ -16,7 +16,7 @@ def test_suggest_initial_contents(tmp_path): [nitpick.files.present] "pyproject.toml" = "Do something" """ - ).simulate_run().assert_errors_contain(f"NIP103 File {PYPROJECT_TOML} should exist: Do something").cli_run( + ).api_check_then_apply(Fuss(False, PYPROJECT_TOML, 103, " should exist: Do something")).cli_run( f"{PYPROJECT_TOML}:1: NIP103 should exist: Do something", violations=1 ) @@ -38,7 +38,7 @@ def test_missing_different_values(tmp_path): yada = "before" # comment for yada yada abc = "123" # comment for abc """ - ).api_apply().assert_violations( + ).api_check_then_apply( Fuss( True, PYPROJECT_TOML, diff --git a/tests/test_setup_cfg.py b/tests/test_setup_cfg.py index 523bc77f..caa62513 100644 --- a/tests/test_setup_cfg.py +++ b/tests/test_setup_cfg.py @@ -12,7 +12,7 @@ def test_setup_cfg_has_no_configuration(tmp_path): """File should not be deleted unless explicitly asked.""" - ProjectMock(tmp_path).style("").setup_cfg("").api_apply().assert_violations() + ProjectMock(tmp_path).style("").setup_cfg("").api_check_then_apply() @XFAIL_ON_WINDOWS @@ -42,7 +42,7 @@ def test_default_style_is_applied(project_with_default_style): warn_redundant_casts = True warn_unused_ignores = True """ - project_with_default_style.api_apply(SETUP_CFG).assert_violations( + project_with_default_style.api_check_then_apply( Fuss( fixed=True, filename="setup.cfg", @@ -50,7 +50,8 @@ def test_default_style_is_applied(project_with_default_style): message=" was not found. Create it with this content:", suggestion=expected_content, lineno=1, - ) + ), + partial_names=[SETUP_CFG], ).assert_file_contents(SETUP_CFG, expected_content) @@ -59,17 +60,19 @@ def test_comma_separated_keys_on_style_file(tmp_path): ProjectMock(tmp_path).style( """ [nitpick.files."setup.cfg"] - comma_separated_values = ["food.eat"] + comma_separated_values = ["food.eat", "food.drink"] ["setup.cfg".food] eat = "salt,ham,eggs" + drink = "water,bier,wine" """ ).setup_cfg( """ [food] eat = spam,eggs,cheese + drink = wine , bier , water """ - ).api_apply().assert_violations( + ).api_check_then_apply( Fuss( True, SETUP_CFG, @@ -85,6 +88,7 @@ def test_comma_separated_keys_on_style_file(tmp_path): """ [food] eat = spam,eggs,cheese,ham,salt + drink = wine , bier , water """, ) @@ -115,7 +119,7 @@ def test_suggest_initial_contents(tmp_path): ["setup.cfg".flake8] max-line-length = 120 """ - ).api_apply().assert_violations( + ).api_check_then_apply( Fuss( True, SETUP_CFG, @@ -147,7 +151,7 @@ def test_missing_sections(tmp_path): ["setup.cfg".flake8] max-line-length = 120 """ - ).api_apply().assert_violations( + ).api_check_then_apply( Fuss( True, SETUP_CFG, @@ -186,6 +190,7 @@ def test_missing_different_values(tmp_path): [isort] line_length = 30 + name = John [flake8] ; Line comment with semicolon xxx = "aaa" @@ -197,11 +202,12 @@ def test_missing_different_values(tmp_path): ["setup.cfg".isort] line_length = 110 + name = "Mary" ["setup.cfg".flake8] max-line-length = 112 """ - ).api_apply().assert_violations( + ).api_check_then_apply( Fuss( True, SETUP_CFG, @@ -222,6 +228,16 @@ def test_missing_different_values(tmp_path): max-line-length = 112 """, ), + Fuss( + True, + SETUP_CFG, + Violations.KEY_HAS_DIFFERENT_VALUE.code, + ": [isort]name is John but it should be like this:", + """ + [isort] + name = Mary + """, + ), ).assert_file_contents( SETUP_CFG, """ @@ -231,6 +247,7 @@ def test_missing_different_values(tmp_path): [isort] line_length = 110 + name = Mary [flake8] ; Line comment with semicolon xxx = "aaa" @@ -311,7 +328,7 @@ def test_invalid_sections_comma_separated_values(tmp_path): ignore = W503,E203,FI12,FI15,FI16,FI17,FI18,FI50,FI51,FI53,FI54,FI55,FI58,PT003,C408 per-file-ignores = tests/**.py:FI18,setup.py:FI18,tests/**.py:BZ01 """ - ).api_apply().assert_violations( + ).api_check_then_apply( Fuss(False, SETUP_CFG, 325, ": invalid sections on comma_separated_values:", "aaa, falek8") ) diff --git a/tests/test_style.py b/tests/test_style.py index d108e9cc..f5d534e2 100644 --- a/tests/test_style.py +++ b/tests/test_style.py @@ -10,7 +10,7 @@ from nitpick.constants import DOT_SLASH, PYPROJECT_TOML, READ_THE_DOCS_URL, TOML_EXTENSION from nitpick.violations import Fuss -from tests.helpers import XFAIL_ON_WINDOWS, ProjectMock, assert_conditions +from tests.helpers import SUGGESTION_BEGIN, SUGGESTION_END, XFAIL_ON_WINDOWS, ProjectMock, assert_conditions if TYPE_CHECKING: from pathlib import Path @@ -55,23 +55,23 @@ def test_multiple_styles_overriding_values(offline, tmp_path): [tool.black] something = 22 """ - ).simulate_run( - offline=offline, apply=False + ).flake8( + offline=offline ).assert_errors_contain( - """ - NIP318 File pyproject.toml has missing values:\x1b[32m + f""" + NIP318 File pyproject.toml has missing values:{SUGGESTION_BEGIN} [tool.black] - line-length = 100\x1b[0m + line-length = 100{SUGGESTION_END} """ ).assert_errors_contain( - """ - NIP319 File pyproject.toml has different values. Use this:\x1b[32m + f""" + NIP319 File pyproject.toml has different values. Use this:{SUGGESTION_BEGIN} [tool.black] - something = 11\x1b[0m + something = 11{SUGGESTION_END} """ ).assert_errors_contain( - """ - NIP321 File setup.cfg was not found. Create it with this content:\x1b[32m + f""" + NIP321 File setup.cfg was not found. Create it with this content:{SUGGESTION_BEGIN} [flake8] inline-quotes = double something = 123 @@ -79,7 +79,7 @@ def test_multiple_styles_overriding_values(offline, tmp_path): [isort] known_first_party = tests line_length = 120 - xxx = yyy\x1b[0m + xxx = yyy{SUGGESTION_END} """ ).cli_ls( """ @@ -133,17 +133,17 @@ def test_include_styles_overriding_values(offline, tmp_path): [tool.nitpick] style = "isort1" """ - ).simulate_run( + ).flake8( offline=offline ).assert_errors_contain( - """ - NIP318 File pyproject.toml has missing values:\x1b[32m + f""" + NIP318 File pyproject.toml has missing values:{SUGGESTION_BEGIN} [tool.black] - line-length = 100\x1b[0m + line-length = 100{SUGGESTION_END} """ ).assert_errors_contain( - """ - NIP321 File setup.cfg was not found. Create it with this content:\x1b[32m + f""" + NIP321 File setup.cfg was not found. Create it with this content:{SUGGESTION_BEGIN} [flake8] inline-quotes = double something = 123 @@ -151,7 +151,7 @@ def test_include_styles_overriding_values(offline, tmp_path): [isort] known_first_party = tests line_length = 120 - xxx = yyy\x1b[0m + xxx = yyy{SUGGESTION_END} """ ) @@ -182,7 +182,7 @@ def test_minimum_version(mocked_version, offline, tmp_path): [tool.black] line-length = 100 """ - ).simulate_run( + ).flake8( offline=offline ).assert_single_error( "NIP203 The style file you're using requires nitpick>=1.0 (you have 0.5.3). Please upgrade" @@ -241,11 +241,11 @@ def test_relative_and_other_root_dirs(offline, tmp_path): style = ["{another_dir}/main", "{another_dir}/styles/black"] {common_pyproject} """ - ).simulate_run(offline=offline).assert_single_error( - """ - NIP318 File pyproject.toml has missing values:\x1b[32m + ).flake8(offline=offline).assert_single_error( + f""" + NIP318 File pyproject.toml has missing values:{SUGGESTION_BEGIN} [tool.black] - missing = "value"\x1b[0m + missing = "value"{SUGGESTION_END} """ ) @@ -256,12 +256,17 @@ def test_relative_and_other_root_dirs(offline, tmp_path): style = ["{another_dir}/main", "styles/black.toml"] {common_pyproject} """ - ).simulate_run().assert_single_error( - """ - NIP318 File pyproject.toml has missing values:\x1b[32m - [tool.black] - missing = "value"\x1b[0m - """ + ).api_check().assert_violations( + Fuss( + False, + PYPROJECT_TOML, + 318, + " has missing values:", + """ + [tool.black] + missing = "value" + """, + ) ) # Allow relative paths @@ -271,14 +276,14 @@ def test_relative_and_other_root_dirs(offline, tmp_path): style = ["{another_dir}/styles/black", "../poetry"] {common_pyproject} """ - ).simulate_run(offline=offline).assert_single_error( - """ - NIP318 File pyproject.toml has missing values:\x1b[32m + ).flake8(offline=offline).assert_single_error( + f""" + NIP318 File pyproject.toml has missing values:{SUGGESTION_BEGIN} [tool.black] missing = "value" [tool.poetry] - version = "1.0"\x1b[0m + version = "1.0"{SUGGESTION_END} """ ) @@ -286,7 +291,7 @@ def test_relative_and_other_root_dirs(offline, tmp_path): @pytest.mark.parametrize("offline", [False, True]) def test_symlink_subdir(offline, tmp_path): """Test relative styles in subdirectories of a symlink dir.""" - target_dir: Path = tmp_path / "target_dir" # type + target_dir: Path = tmp_path / "target_dir" ProjectMock(tmp_path).named_style( f"{target_dir}/parent", """ @@ -306,13 +311,13 @@ def test_symlink_subdir(offline, tmp_path): [tool.nitpick] style = "symlinked-style" """ - ).simulate_run( + ).flake8( offline=offline ).assert_single_error( - """ - NIP318 File pyproject.toml has missing values:\x1b[32m + f""" + NIP318 File pyproject.toml has missing values:{SUGGESTION_BEGIN} [tool.black] - line-length = 86\x1b[0m + line-length = 86{SUGGESTION_END} """ ) @@ -358,12 +363,17 @@ def test_relative_style_on_urls(tmp_path): style = ["{base_url}/main", "{base_url}/styles/black.toml"] {common_pyproject} """ - ).simulate_run().assert_single_error( - """ - NIP318 File pyproject.toml has missing values:\x1b[32m - [tool.black] - missing = "value"\x1b[0m - """ + ).api_check().assert_violations( + Fuss( + False, + PYPROJECT_TOML, + 318, + " has missing values:", + """ + [tool.black] + missing = "value" + """, + ) ) # Reuse the first full path that appears @@ -373,12 +383,17 @@ def test_relative_style_on_urls(tmp_path): style = ["{base_url}/main.toml", "styles/black"] {common_pyproject} """ - ).simulate_run().assert_single_error( - """ - NIP318 File pyproject.toml has missing values:\x1b[32m - [tool.black] - missing = "value"\x1b[0m - """ + ).api_check().assert_violations( + Fuss( + False, + PYPROJECT_TOML, + 318, + " has missing values:", + """ + [tool.black] + missing = "value" + """, + ) ) # Allow relative paths @@ -388,15 +403,20 @@ def test_relative_style_on_urls(tmp_path): style = ["{base_url}/styles/black.toml", "../poetry"] {common_pyproject} """ - ).simulate_run().assert_single_error( - """ - NIP318 File pyproject.toml has missing values:\x1b[32m - [tool.black] - missing = "value" + ).api_check().assert_violations( + Fuss( + False, + PYPROJECT_TOML, + 318, + " has missing values:", + """ + [tool.black] + missing = "value" - [tool.poetry] - version = "1.0"\x1b[0m - """ + [tool.poetry] + version = "1.0" + """, + ) ) @@ -460,14 +480,14 @@ def test_fetch_private_github_urls(tmp_path): style = "{base_url}{query_string}" """ ) - project.simulate_run(offline=False).assert_single_error( - """ - NIP318 File pyproject.toml has missing values:\x1b[32m + project.flake8(offline=False).assert_single_error( + f""" + NIP318 File pyproject.toml has missing values:{SUGGESTION_BEGIN} [tool.black] - missing = "thing"\x1b[0m - """ + missing = "thing"{SUGGESTION_END} + """ ) - project.simulate_run(offline=True).assert_no_errors() + project.flake8(offline=True).assert_no_errors() @pytest.mark.parametrize("offline", [False, True]) @@ -485,7 +505,7 @@ def test_merge_styles_into_single_file(offline, tmp_path): [tool.nitpick] style = ["black", "isort", "isort_overrides"] """ - ).simulate_run( + ).flake8( offline=offline ).assert_merged_style( ''' @@ -538,7 +558,7 @@ def test_invalid_tool_nitpick_on_pyproject_toml(offline, tmp_path): for style, error_message in [ ( 'style = [""]\nextra_values = "also raise warnings"', - "extra_values: Unknown configuration. See https://nitpick.rtfd.io/en/latest/tool_nitpick_section.html." + f"extra_values: Unknown configuration. See {READ_THE_DOCS_URL}tool_nitpick_section.html." + "\nstyle.0: Shorter than minimum length 1.", ), ('style = ""', "style: Shorter than minimum length 1."), @@ -548,9 +568,9 @@ def test_invalid_tool_nitpick_on_pyproject_toml(offline, tmp_path): "style.1: Shorter than minimum length 1.\nstyle.2: Shorter than minimum length 1.", ), ]: - project.pyproject_toml(f"[tool.nitpick]\n{style}").simulate_run(offline=offline).assert_errors_contain( + project.pyproject_toml(f"[tool.nitpick]\n{style}").flake8(offline=offline).assert_errors_contain( "NIP001 File pyproject.toml has an incorrect style." - + f" Invalid data in [tool.nitpick]:\x1b[32m\n{error_message}\x1b[0m", + + f" Invalid data in [tool.nitpick]:{SUGGESTION_BEGIN}\n{error_message}{SUGGESTION_END}", 1, ) @@ -562,10 +582,14 @@ def test_invalid_toml(tmp_path): ["setup.cfg".flake8] ignore = D100,D104,D202,E203,W503 """ - ).simulate_run().assert_errors_contain( - "NIP001 File nitpick-style.toml has an incorrect style. Invalid TOML" - + " (toml.decoder.TomlDecodeError: This float doesn't have a leading digit (line 2 column 1 char 21))", - 1, + ).api_check_then_apply( + Fuss( + False, + "nitpick-style.toml", + 1, + " has an incorrect style. Invalid TOML" + " (toml.decoder.TomlDecodeError: This float doesn't have a leading digit (line 2 column 1 char 21))", + ) ) @@ -589,17 +613,17 @@ def test_invalid_nitpick_files(offline, tmp_path): [tool.nitpick] style = ["some_style", "wrong_files"] """ - ).simulate_run( + ).flake8( offline=offline ).assert_errors_contain( f""" - NIP001 File some_style.toml has an incorrect style. Invalid config:\x1b[32m - xxx: Unknown file. See {READ_THE_DOCS_URL}plugins.html.\x1b[0m + NIP001 File some_style.toml has an incorrect style. Invalid config:{SUGGESTION_BEGIN} + xxx: Unknown file. See {READ_THE_DOCS_URL}plugins.html.{SUGGESTION_END} """ ).assert_errors_contain( f""" - NIP001 File wrong_files.toml has an incorrect style. Invalid config:\x1b[32m - nitpick.files.whatever: Unknown file. See {READ_THE_DOCS_URL}nitpick_section.html#nitpick-files.\x1b[0m + NIP001 File wrong_files.toml has an incorrect style. Invalid config:{SUGGESTION_BEGIN} + nitpick.files.whatever: Unknown file. See {READ_THE_DOCS_URL}nitpick_section.html#nitpick-files.{SUGGESTION_END} """, 2, ) diff --git a/tests/test_text.py b/tests/test_text.py index 49dc0cf2..21baaee7 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -1,5 +1,7 @@ """Text file tests.""" -from tests.helpers import ProjectMock +from nitpick.constants import READ_THE_DOCS_URL +from nitpick.violations import Fuss +from tests.helpers import NBSP, SUGGESTION_BEGIN, SUGGESTION_END, ProjectMock def test_suggest_initial_contents(tmp_path): @@ -13,12 +15,17 @@ def test_suggest_initial_contents(tmp_path): [["requirements.txt".contains]] line = "some-package==1.0.0" """ - ).simulate_run().assert_errors_contain( - """ - NIP351 File requirements.txt was not found. Create it with this content:\x1b[32m - sphinx>=1.3.0 - some-package==1.0.0\x1b[0m - """ + ).api_check_then_apply( + Fuss( + False, + "requirements.txt", + 351, + " was not found. Create it with this content:", + """ + sphinx>=1.3.0 + some-package==1.0.0 + """, + ) ) @@ -37,13 +44,13 @@ def test_text_configuration(tmp_path): ["ghi.txt".whatever] wrong = "everything" """ - ).simulate_run(api=False).assert_errors_contain( - """ - NIP001 File nitpick-style.toml has an incorrect style. Invalid config:\x1b[32m - "abc.txt".contains.0.invalid: Unknown configuration. See https://nitpick.rtfd.io/en/latest/plugins.html#text-files. + ).flake8().assert_errors_contain( + f""" + NIP001 File nitpick-style.toml has an incorrect style. Invalid config:{SUGGESTION_BEGIN} + "abc.txt".contains.0.invalid: Unknown configuration. See {READ_THE_DOCS_URL}plugins.html#text-files. "abc.txt".contains.0.line: Not a valid string. "def.txt".contains: Not a valid list. - "ghi.txt".whatever: Unknown configuration. See https://nitpick.rtfd.io/en/latest/plugins.html#text-files.\x1b[0m + "ghi.txt".whatever: Unknown configuration. See {READ_THE_DOCS_URL}plugins.html#text-files.{SUGGESTION_END} """, 1, ) @@ -60,12 +67,17 @@ def test_text_file_contains_line(tmp_path): [["my.txt".contains]] line = "www" """ - ).save_file("my.txt", "def\nghi\nwww").simulate_run().assert_errors_contain( - """ - NIP352 File my.txt has missing lines:\x1b[32m - abc - qqq\x1b[0m - """ + ).save_file("my.txt", "def\nghi\nwww").api_check_then_apply( + Fuss( + False, + "my.txt", + 352, + " has missing lines:", + """ + abc + qqq + """, + ) ) @@ -76,9 +88,8 @@ def test_yaml_file_as_text(tmp_path): [[".gitlab-ci.yml".contains]] line = " - mypy -p ims --junit-xml report-mypy.xml" """ - ).save_file(".gitlab-ci.yml", "def\nghi\nwww").simulate_run().assert_errors_contain( - """ - NIP352 File .gitlab-ci.yml has missing lines:\x1b[32m - - mypy -p ims --junit-xml report-mypy.xml\x1b[0m - """ + ).save_file(".gitlab-ci.yml", "def\nghi\nwww").api_check_then_apply( + Fuss( + False, ".gitlab-ci.yml", 352, " has missing lines:", f"{NBSP * 4}- mypy -p ims --junit-xml report-mypy.xml" + ) ) diff --git a/tests/test_violations.py b/tests/test_violations.py new file mode 100644 index 00000000..bf082c1c --- /dev/null +++ b/tests/test_violations.py @@ -0,0 +1,66 @@ +"""Violations.""" +from textwrap import dedent + +import pytest +from marshmallow import ValidationError +from testfixtures import compare + +from nitpick.schemas import flatten_marshmallow_errors +from nitpick.violations import Fuss, Reporter +from tests.helpers import SUGGESTION_BEGIN, SUGGESTION_END + + +@pytest.mark.parametrize("fixed", [False, True]) +def test_fuss_pretty(fixed): + """Test Fuss' pretty formatting.""" + examples = [ + (Fuss(fixed, "abc.txt", 2, "message"), "abc.txt:1: NIP002 message"), + (Fuss(fixed, "abc.txt", 2, "message", "", 15), "abc.txt:15: NIP002 message"), + ( + Fuss(fixed, "abc.txt", 1, "message", "\tsuggestion\n\t "), + f"abc.txt:1: NIP001 message{SUGGESTION_BEGIN}\n\tsuggestion{SUGGESTION_END}", + ), + ] + for fuss, expected in examples: + compare(actual=fuss.pretty, expected=dedent(expected)) + + +def test_reporter(): + """Test error reporter.""" + reporter = Reporter() + reporter.reset() + assert reporter.manual == 0 + assert reporter.fixed == 0 + assert reporter.get_counts() == "🎖 No violations found." + + reporter.increment() + assert reporter.manual == 1 + assert reporter.fixed == 0 + assert reporter.get_counts() == "Violations: ❌ 1 to change manually." + + reporter.increment(True) + assert reporter.manual == 1 + assert reporter.fixed == 1 + assert reporter.get_counts() == "Violations: ✅ 1 fixed, ❌ 1 to change manually." + + reporter.reset() + assert reporter.manual == 0 + assert reporter.fixed == 0 + + reporter.increment(True) + assert reporter.manual == 0 + assert reporter.fixed == 1 + assert reporter.get_counts() == "Violations: ✅ 1 fixed." + + +def test_flatten_marshmallow_errors(): + """Flatten Marshmallow errors.""" + examples = [ + ({"list": ["multi", "part", "message"]}, "list: multi, part, message"), + ({"dict": {"a": "blargh", "b": "blergh"}}, "dict.a: blargh\ndict.b: blergh"), + ({"dict": {"a": ["x", "y"], "b": "blergh"}}, "dict.a: x, y\ndict.b: blergh"), + ({"tuple": ("c", "meh")}, "tuple: c, meh"), + ({"err": ValidationError("some error")}, "err: some error"), + ] + for error, expected in examples: + compare(actual=flatten_marshmallow_errors(error), expected=expected)