diff --git a/.coveragerc b/.coveragerc index fd5df4bf..8f4a99e0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,3 +3,4 @@ exclude_lines = pragma: no cover if TYPE_CHECKING.*: \.\.\. + def __repr__ diff --git a/docs/source/_static/images/clean-dry-run-directories.svg b/docs/source/_static/images/clean-dry-run-directories.svg new file mode 100644 index 00000000..b1284915 --- /dev/null +++ b/docs/source/_static/images/clean-dry-run-directories.svg @@ -0,0 +1,59 @@ + + + + pytask + + + + + + + ──────────────────────────────────────── Start pytask session ──────────────────────────────────────── +Platform: win32 -- Python 3.10.2, pytask 0.2.2, pluggy 0.12.0                                          +Root: C:\Users\TobiasR\git\pytask                                                                      +Collected 25 tasks.                                                                                    +                                                                                                       +Files and directories which can be removed:                                                            +                                                                                                       +Would remove svgs\obsolete_file_1.md                                                                   +Would remove svgs\obsolete_folder                                                                      +                                                                                                       +────────────────────────────────────────────────────────────────────────────────────────────────────── + + + diff --git a/docs/source/_static/images/clean-dry-run.svg b/docs/source/_static/images/clean-dry-run.svg new file mode 100644 index 00000000..77b508ae --- /dev/null +++ b/docs/source/_static/images/clean-dry-run.svg @@ -0,0 +1,60 @@ + + + + pytask + + + + + + + ──────────────────────────────────────── Start pytask session ──────────────────────────────────────── +Platform: win32 -- Python 3.10.2, pytask 0.2.2, pluggy 0.12.0                                          +Root: C:\Users\TobiasR\git\pytask                                                                      +Collected 25 tasks.                                                                                    +                                                                                                       +Files which can be removed:                                                                            +                                                                                                       +Would remove svgs\obsolete_file_1.md                                                                   +Would remove svgs\obsolete_folder\obsolete_file_2.md                                                   +Would remove svgs\obsolete_folder\obsolete_file_3.md                                                   +                                                                                                       +────────────────────────────────────────────────────────────────────────────────────────────────────── + + + diff --git a/docs/source/developers_guide.md b/docs/source/developers_guide.md index 675a1a5f..e3654663 100644 --- a/docs/source/developers_guide.md +++ b/docs/source/developers_guide.md @@ -36,6 +36,8 @@ The following list covers all steps of a release cycle. ## Profiling the application +### `cProfile` + To profile pytask, you can follow this [video](https://www.youtube.com/watch?v=qiZyDLEJHh0) (it also features explanations for `git bisect`, caching, and profiling tools). We use {mod}`cProfile` with @@ -50,3 +52,11 @@ The profile can be visualized with $ pip install yelp-gprof2dot $ gprof2dot log.pstats | dot -T svg -o out.svg ``` + +### `importtime` + +To measure how long it takes to import pytask, use + +```console +$ python -X importtime -c "import pytask" +``` diff --git a/docs/source/tutorials/cleaning_projects.md b/docs/source/tutorials/cleaning_projects.md index e11440af..ea29fef3 100644 --- a/docs/source/tutorials/cleaning_projects.md +++ b/docs/source/tutorials/cleaning_projects.md @@ -1,49 +1,48 @@ # Cleaning projects -At some point, projects are cluttered with obsolete files. For example, a product of a -task was renamed and the old version still exists. +Projects usually become cluttered with obsolete files after some time. -To clean directories from files which are not recognized by pytask, enter the directory -and type +To clean the project from files which are not recognized by pytask and type -```console -$ pytask clean -========================= Start pytask session ========================= -Platform: win32 -- Python x.y.z, pytask x.y.z, pluggy x.y.z -Root: . -Collected 3 task(s). - -Files which can be removed: - -Would remove C:\Users\project\obsolete_file_1.txt. -Would remove C:\Users\project\obsolete_folder\obsolete_file_2.txt. -Would remove C:\Users\project\obsolete_folder\obsolete_file_3.txt. +```{image} /_static/images/clean-dry-run.svg ``` -By default, pytask takes the current directory and performs a dry-run which shows only -files which could be removed. Pass other paths to the command if you want to inspect -specific directories. +pytask performs a dry-run by default and shows all the files which can be removed. -If you want to remove the files, there exist two other modes for -{option}`pytask clean -m`. +If you want to remove the files, use {option}`pytask clean --mode` with one of the +following modes. - `force` removes all files suggested in the `dry-run` without any confirmation. - `interactive` allows you to decide for every file whether to keep it or not. -If you want to delete whole folders instead of only the files in them, use -{option}`pytask clean -d`. +If you want to delete complete folders instead of single files, use +{option}`pytask clean --directories`. If all content in a directory can be removed, only +the directory is shown. + +```{image} /_static/images/clean-dry-run-directories.svg +``` + +## Excluding files + +Files which are under version control with git are excluded from the cleaning process. + +If other files or directories should be excluded as well, you can use the +{option}`pytask clean --exclude` option or the `exclude` key in the configuration file. + +The value can be a Unix filename pattern which is documented in {mod}`fnmatch` and +supports the wildcard character `*` for any characters and other symbols. + +Here is an example where the `obsolete_folder` is excluded from the cleaning process. ```console -$ pytask clean -d -========================= Start pytask session ========================= -Platform: win32 -- Python x.y.z, pytask x.y.z, pluggy x.y.z -Root: . -Collected 3 task(s). +$ pytask clean --exclude obsolete_folder +``` -Files and directories which can be removed: +or -Would remove C:\Users\project\obsolete_file_1.txt. -Would remove C:\Users\project\obsolete_folder. +```toml +[tool.pytask.ini_options] +exclude = ["obsolete_folder"] ``` ## Further reading diff --git a/scripts/svgs/README.md b/scripts/svgs/README.md index 933eb0f5..e1bcf302 100644 --- a/scripts/svgs/README.md +++ b/scripts/svgs/README.md @@ -3,14 +3,27 @@ This folder contains scripts to create svgs for the documentation and other publicly available material. +## Setup + +Before creating the SVGs, you need to adjust the version of pytask from a dirty version +to the desired one. + +Set `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYTASK` to the desired version. + +```console +# Command for Powershell +$ $env:SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYTASK = "v0.2.0" + +# Command for Bash +$ export SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYTASK = "v0.2.0" +``` + ## Post-processing of SVGs - [ ] Remove flickering left-overs. - [ ] Set Python version to 3.10.0. -- [ ] Set pytask version to 0.2.0. - [ ] Set pluggy version to 1.0.0. - [ ] Set root path to C:\\Users\\tobias\\git\\pytask-examples. -- [ ] Set a suitable width and height. ## Store diff --git a/scripts/svgs/task_clean.py b/scripts/svgs/task_clean.py new file mode 100644 index 00000000..b253a1b5 --- /dev/null +++ b/scripts/svgs/task_clean.py @@ -0,0 +1,34 @@ +# Content of task_module.py +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytask +from click.testing import CliRunner + + +def task_example(): + pass + + +if __name__ == "__main__": + runner = CliRunner() + path = Path(__file__).parent + + path.joinpath("obsolete_file_1.md").touch() + + path.joinpath("obsolete_folder").mkdir() + path.joinpath("obsolete_folder", "obsolete_file_2.md").touch() + path.joinpath("obsolete_folder", "obsolete_file_3.md").touch() + + pytask.console.record = True + rs = runner.invoke(pytask.cli, ["clean", "-e", "__pycache__", path.as_posix()]) + pytask.console.save_svg("clean-dry-run.svg", title="pytask") + + pytask.console.record = True + runner.invoke(pytask.cli, ["clean", "-e", "__pycache__", "-d", path.as_posix()]) + pytask.console.save_svg("clean-dry-run-directories.svg", title="pytask") + + path.joinpath("obsolete_file_1.md").unlink() + shutil.rmtree(path / "obsolete_folder") diff --git a/scripts/svgs/task_warning.py b/scripts/svgs/task_warning.py index 3b38286b..eb9b7590 100644 --- a/scripts/svgs/task_warning.py +++ b/scripts/svgs/task_warning.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + import pandas as pd import pytask from click.testing import CliRunner @@ -20,6 +22,10 @@ def task_warning(produces): if __name__ == "__main__": runner = CliRunner() + path = Path(__file__).parent + pytask.console.record = True runner.invoke(pytask.cli, [__file__]) pytask.console.save_svg("warning.svg", title="pytask") + + path.joinpath("df.pkl").unlink() diff --git a/src/_pytask/clean.py b/src/_pytask/clean.py index 52bcf653..9861edc3 100644 --- a/src/_pytask/clean.py +++ b/src/_pytask/clean.py @@ -16,16 +16,20 @@ import click from _pytask.click import ColoredCommand from _pytask.config import hookimpl -from _pytask.config import IGNORED_TEMPORARY_FILES_AND_FOLDERS from _pytask.console import console from _pytask.exceptions import CollectionError +from _pytask.git import get_all_files +from _pytask.git import get_root from _pytask.nodes import Task from _pytask.outcomes import ExitCode from _pytask.path import find_common_ancestor from _pytask.path import relative_to from _pytask.pluginmanager import get_plugin_manager from _pytask.session import Session +from _pytask.shared import falsy_to_none_callback from _pytask.shared import get_first_non_none_value +from _pytask.shared import parse_value_or_multiline_option +from _pytask.shared import to_list from _pytask.traceback import render_exc_info from pybaum.tree_util import tree_just_yield @@ -34,6 +38,9 @@ from typing import NoReturn +_DEFAULT_EXCLUDE: list[str] = [".git/*", ".hg/*", ".svn/*"] + + _HELP_TEXT_MODE = ( "Choose 'dry-run' to print the paths of files/directories which would be removed, " "'interactive' for a confirmation prompt for every path, and 'force' to remove all " @@ -49,41 +56,45 @@ def pytask_extend_command_line_interface(cli: click.Group) -> None: @hookimpl def pytask_parse_config( - config: dict[str, Any], config_from_cli: dict[str, Any] + config: dict[str, Any], + config_from_cli: dict[str, Any], + config_from_file: dict[str, Any], ) -> None: """Parse the configuration.""" - config["mode"] = get_first_non_none_value( - config_from_cli, key="mode", default="dry-run" + config["directories"] = config_from_cli.get("directories", False) + cli_excludes = parse_value_or_multiline_option(config_from_cli.get("exclude")) + file_excludes = parse_value_or_multiline_option(config_from_file.get("exclude")) + config["exclude"] = ( + to_list(cli_excludes or []) + to_list(file_excludes or []) + _DEFAULT_EXCLUDE ) + config["mode"] = config_from_cli.get("mode", "dry-run") config["quiet"] = get_first_non_none_value( config_from_cli, key="quiet", default=False ) - config["directories"] = get_first_non_none_value( - config_from_cli, key="directories", default=False - ) - - -@hookimpl -def pytask_post_parse(config: dict[str, Any]) -> None: - """Correct ignore patterns such that caches, etc. will not be ignored.""" - if config["command"] == "clean": - config["ignore"] = [ - i for i in config["ignore"] if i not in IGNORED_TEMPORARY_FILES_AND_FOLDERS - ] @click.command(cls=ColoredCommand) -@click.option( - "--mode", - type=click.Choice(["dry-run", "interactive", "force"]), - help=_HELP_TEXT_MODE, -) @click.option( "-d", "--directories", is_flag=True, help="Remove whole directories. [dim]\\[default: False][/]", ) +@click.option( + "-e", + "--exclude", + metavar="PATTERN", + multiple=True, + type=str, + help="A filename pattern to exclude files from the cleaning process.", + callback=falsy_to_none_callback, +) +@click.option( + "--mode", + default="dry-run", + type=click.Choice(["dry-run", "interactive", "force"]), + help=_HELP_TEXT_MODE, +) @click.option( "-q", "--quiet", @@ -111,7 +122,7 @@ def clean(**config_from_cli: Any) -> NoReturn: exc_info: tuple[ type[BaseException], BaseException, TracebackType | None ] = sys.exc_info() - console.print(render_exc_info(*exc_info, config["show_locals"])) + console.print(render_exc_info(*exc_info)) else: try: @@ -119,9 +130,10 @@ def clean(**config_from_cli: Any) -> NoReturn: session.hook.pytask_collect(session=session) known_paths = _collect_all_paths_known_to_pytask(session) + exclude = session.config["exclude"] include_directories = session.config["directories"] unknown_paths = _find_all_unknown_paths( - session, known_paths, include_directories + session, known_paths, exclude, include_directories ) common_ancestor = find_common_ancestor( *unknown_paths, *session.config["paths"] @@ -193,6 +205,14 @@ def _collect_all_paths_known_to_pytask(session: Session) -> set[Path]: known_paths.add(session.config["root"]) known_paths.add(session.config["database_filename"]) + # Add files tracked by git. + git_root = get_root(session.config["root"]) + if git_root is not None: + paths_known_by_git = get_all_files(session.config["root"]) + absolute_paths_known_by_git = [git_root.joinpath(p) for p in paths_known_by_git] + known_paths.update(absolute_paths_known_by_git) + known_paths.add(git_root / ".git") + return known_paths @@ -206,7 +226,10 @@ def _yield_paths_from_task(task: Task) -> Generator[Path, None, None]: def _find_all_unknown_paths( - session: Session, known_paths: set[Path], include_directories: bool + session: Session, + known_paths: set[Path], + exclude: tuple[str, ...], + include_directories: bool, ) -> list[Path]: """Find all unknown paths. @@ -215,7 +238,7 @@ def _find_all_unknown_paths( """ recursive_nodes = [ - _RecursivePathNode.from_path(path, known_paths, session) + _RecursivePathNode.from_path(path, known_paths, exclude) for path in session.config["paths"] ] unknown_paths = list( @@ -251,7 +274,7 @@ class _RecursivePathNode: @classmethod def from_path( - cls, path: Path, known_paths: Iterable[Path], session: Session + cls, path: Path, known_paths: Iterable[Path], exclude: tuple[str, ...] ) -> _RecursivePathNode: """Create a node from a path. @@ -259,28 +282,29 @@ def from_path( inside a directory. """ + # Spawn subnodes for a directory, but only if the directory is not excluded. sub_nodes = ( [ - _RecursivePathNode.from_path(p, known_paths, session) + _RecursivePathNode.from_path(p, known_paths, exclude) for p in path.iterdir() ] if path.is_dir() - # Do not collect sub files and folders for ignored folders. - and not session.hook.pytask_ignore_collect(path=path, config=session.config) + # Do not collect sub files and folders for excluded folders. + and not any(path.match(pattern) for pattern in exclude) else [] ) is_unknown_file = path.is_file() and not ( path in known_paths - # Ignored files are also known. - or session.hook.pytask_ignore_collect(path=path, config=session.config) + # Excluded files are also known. + or any(path.match(pattern) for pattern in exclude) ) is_unknown_directory = ( path.is_dir() # True for folders and ignored folders without any sub nodes. and all(node.is_unknown for node in sub_nodes) # True for not ignored paths. - and not session.hook.pytask_ignore_collect(path=path, config=session.config) + and not any(path.match(pattern) for pattern in exclude) ) is_unknown = is_unknown_file or is_unknown_directory diff --git a/src/_pytask/config.py b/src/_pytask/config.py index 102efda4..ebae0b4d 100644 --- a/src/_pytask/config.py +++ b/src/_pytask/config.py @@ -25,12 +25,7 @@ hookimpl = pluggy.HookimplMarker("pytask") -_IGNORED_FOLDERS: list[str] = [ - ".git/*", - ".hg/*", - ".svn/*", - ".venv/*", -] +_IGNORED_FOLDERS: list[str] = [".git/*", ".hg/*", ".svn/*", ".venv/*"] _IGNORED_FILES: list[str] = [ @@ -42,6 +37,7 @@ "readthedocs.yml", "readthedocs.yaml", "environment.yml", + "pyproject.toml", "pytask.ini", "setup.cfg", "tox.ini", diff --git a/src/_pytask/git.py b/src/_pytask/git.py new file mode 100644 index 00000000..7a704248 --- /dev/null +++ b/src/_pytask/git.py @@ -0,0 +1,53 @@ +"""This module contains all functions related to git.""" +from __future__ import annotations + +import subprocess +from os import PathLike +from pathlib import Path +from typing import Any + + +def cmd_output(*cmd: str, **kwargs: Any) -> tuple[int, str, str]: + """Execute a command and capture the output.""" + r = subprocess.run(cmd, capture_output=True, **kwargs) + stdout = r.stdout.decode() if r.stdout is not None else None + stderr = r.stderr.decode() if r.stderr is not None else None + return r.returncode, stdout, stderr + + +def init_repo(path: Path) -> None: + """Initialize a git repository.""" + subprocess.run(("git", "init"), cwd=path) + + +def zsplit(s: str) -> list[str]: + """Split string which uses the NUL character as a separator.""" + s = s.strip("\0") + if s: + return s.split("\0") + else: + return [] + + +def get_all_files(cwd: PathLike[str] | None = None) -> list[Path]: + """Get all files tracked by git - even new, staged files.""" + str_paths = zsplit(cmd_output("git", "ls-files", "-z", cwd=cwd)[1]) + paths = [Path(x) for x in str_paths] + return paths + + +def get_root(cwd: PathLike[str] | None) -> Path | None: + """Get the root path of a git repository. + + Git 2.25 introduced a change to ``rev-parse --show-toplevel`` that exposed + underlying volumes for Windows drives mapped with ``SUBST``. We use ``rev-parse + --show-cdup`` to get the appropriate path. + + """ + try: + _, stdout, _ = cmd_output("git", "rev-parse", "--show-cdup", cwd=cwd) + root = Path(cwd) / stdout.strip() + except subprocess.CalledProcessError: + # Either git is not installed or user is not in git repo. + root = None + return root diff --git a/src/_pytask/shared.py b/src/_pytask/shared.py index aa43f259..4ee97113 100644 --- a/src/_pytask/shared.py +++ b/src/_pytask/shared.py @@ -112,7 +112,7 @@ def parse_value_or_multiline_option(value: str | None) -> None | str | list[str] """Parse option which can hold a single value or values separated by new lines.""" if value in ["none", "None", None, ""]: return None - elif isinstance(value, list): + elif isinstance(value, (list, tuple)): return list(map(str, value)) elif isinstance(value, str) and "\n" in value: return [v.strip() for v in value.split("\n") if v.strip()] diff --git a/tests/test_clean.py b/tests/test_clean.py index c94bcbe1..f929f373 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -1,14 +1,16 @@ from __future__ import annotations +import subprocess import textwrap import pytest +from _pytask.git import init_repo from pytask import cli from pytask import ExitCode @pytest.fixture() -def sample_project_path(tmp_path): +def project(tmp_path): """Create a sample project to be cleaned.""" source = """ import pytask @@ -28,58 +30,99 @@ def task_write_text(produces): return tmp_path +@pytest.fixture() +def git_project(tmp_path): + """Create a sample project to be cleaned.""" + source = """ + import pytask + + @pytask.mark.depends_on("in_tracked.txt") + @pytask.mark.produces("out.txt") + def task_write_text(produces): + produces.write_text("a") + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + tmp_path.joinpath("in_tracked.txt").touch() + tmp_path.joinpath("tracked.txt").touch() + + init_repo(tmp_path) + subprocess.run( + ("git", "add", "task_module.py", "in_tracked.txt", "tracked.txt"), cwd=tmp_path + ) + subprocess.run(("git", "commit", "-m", "'COMMIT'"), cwd=tmp_path) + + return tmp_path + + @pytest.mark.end_to_end -def test_clean_with_ignored_file(sample_project_path, runner): - result = runner.invoke( - cli, ["clean", "--ignore", "*_1.txt", sample_project_path.as_posix()] +@pytest.mark.parametrize("flag", ["-e", "--exclude"]) +@pytest.mark.parametrize("pattern", ["*_1.txt", "to_be_deleted_file_[1]*"]) +def test_clean_with_excluded_file(project, runner, flag, pattern): + result = runner.invoke(cli, ["clean", flag, pattern, project.as_posix()]) + + assert result.exit_code == ExitCode.OK + text_without_linebreaks = result.output.replace("\n", "") + assert "to_be_deleted_file_1.txt" not in text_without_linebreaks + assert "to_be_deleted_file_2.txt" in text_without_linebreaks + + +@pytest.mark.end_to_end +@pytest.mark.parametrize("flag", ["-e", "--exclude"]) +@pytest.mark.parametrize("pattern", ["*_1.txt", "to_be_deleted_file_[1]*"]) +def test_clean_with_excluded_file_via_config(project, runner, flag, pattern): + project.joinpath("pyproject.toml").write_text( + f"[tool.pytask.ini_options]\nexclude = [{pattern!r}]" ) + result = runner.invoke(cli, ["clean", flag, pattern, project.as_posix()]) + + assert result.exit_code == ExitCode.OK text_without_linebreaks = result.output.replace("\n", "") assert "to_be_deleted_file_1.txt" not in text_without_linebreaks assert "to_be_deleted_file_2.txt" in text_without_linebreaks + assert "pyproject.toml" in text_without_linebreaks @pytest.mark.end_to_end -def test_clean_with_ingored_directory(sample_project_path, runner): +@pytest.mark.parametrize("flag", ["-e", "--exclude"]) +def test_clean_with_excluded_directory(project, runner, flag): result = runner.invoke( - cli, - [ - "clean", - "--ignore", - "to_be_deleted_folder_1/*", - sample_project_path.as_posix(), - ], + cli, ["clean", flag, "to_be_deleted_folder_1/*", project.as_posix()] ) - assert "to_be_deleted_folder_1/" not in result.output - assert "to_be_deleted_file_1.txt" in result.output.replace("\n", "") + assert result.exit_code == ExitCode.OK + assert "deleted_folder_1/" not in result.output + assert "deleted_file_1.txt" in result.output.replace("\n", "") @pytest.mark.end_to_end def test_clean_with_nothing_to_remove(tmp_path, runner): - result = runner.invoke(cli, ["clean", "--ignore", "*", tmp_path.as_posix()]) + result = runner.invoke(cli, ["clean", "--exclude", "*", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK assert "There are no files and directories which can be deleted." in result.output @pytest.mark.end_to_end -def test_clean_dry_run(sample_project_path, runner): - result = runner.invoke(cli, ["clean", sample_project_path.as_posix()]) +def test_clean_dry_run(project, runner): + result = runner.invoke(cli, ["clean", project.as_posix()]) + assert result.exit_code == ExitCode.OK text_without_linebreaks = result.output.replace("\n", "") assert "Would remove" in text_without_linebreaks assert "to_be_deleted_file_1.txt" in text_without_linebreaks - assert sample_project_path.joinpath("to_be_deleted_file_1.txt").exists() + assert project.joinpath("to_be_deleted_file_1.txt").exists() assert "to_be_deleted_file_2.txt" in text_without_linebreaks - assert sample_project_path.joinpath( + assert project.joinpath( "to_be_deleted_folder_1", "to_be_deleted_file_2.txt" ).exists() @pytest.mark.end_to_end -def test_clean_dry_run_w_directories(sample_project_path, runner): - result = runner.invoke(cli, ["clean", "-d", sample_project_path.as_posix()]) +def test_clean_dry_run_w_directories(project, runner): + result = runner.invoke(cli, ["clean", "-d", project.as_posix()]) + assert result.exit_code == ExitCode.OK text_without_linebreaks = result.output.replace("\n", "") assert "Would remove" in text_without_linebreaks assert "to_be_deleted_file_1.txt" in text_without_linebreaks @@ -88,27 +131,25 @@ def test_clean_dry_run_w_directories(sample_project_path, runner): @pytest.mark.end_to_end -def test_clean_force(sample_project_path, runner): - result = runner.invoke( - cli, ["clean", "--mode", "force", sample_project_path.as_posix()] - ) +def test_clean_force(project, runner): + result = runner.invoke(cli, ["clean", "--mode", "force", project.as_posix()]) + assert result.exit_code == ExitCode.OK text_without_linebreaks = result.output.replace("\n", "") assert "Remove" in result.output assert "to_be_deleted_file_1.txt" in text_without_linebreaks - assert not sample_project_path.joinpath("to_be_deleted_file_1.txt").exists() + assert not project.joinpath("to_be_deleted_file_1.txt").exists() assert "to_be_deleted_file_2.txt" in text_without_linebreaks - assert not sample_project_path.joinpath( + assert not project.joinpath( "to_be_deleted_folder_1", "to_be_deleted_file_2.txt" ).exists() @pytest.mark.end_to_end -def test_clean_force_w_directories(sample_project_path, runner): - result = runner.invoke( - cli, ["clean", "-d", "--mode", "force", sample_project_path.as_posix()] - ) +def test_clean_force_w_directories(project, runner): + result = runner.invoke(cli, ["clean", "-d", "--mode", "force", project.as_posix()]) + assert result.exit_code == ExitCode.OK text_without_linebreaks = result.output.replace("\n", "") assert "Remove" in text_without_linebreaks assert "to_be_deleted_file_1.txt" in text_without_linebreaks @@ -117,38 +158,40 @@ def test_clean_force_w_directories(sample_project_path, runner): @pytest.mark.end_to_end -def test_clean_interactive(sample_project_path, runner): +def test_clean_interactive(project, runner): result = runner.invoke( cli, - ["clean", "--mode", "interactive", sample_project_path.as_posix()], + ["clean", "--mode", "interactive", project.as_posix()], # Three instead of two because the compiled .pyc file is also present. input="y\ny\ny", ) + assert result.exit_code == ExitCode.OK assert "Remove" in result.output assert "to_be_deleted_file_1.txt" in result.output - assert not sample_project_path.joinpath("to_be_deleted_file_1.txt").exists() + assert not project.joinpath("to_be_deleted_file_1.txt").exists() assert "to_be_deleted_file_2.txt" in result.output - assert not sample_project_path.joinpath( + assert not project.joinpath( "to_be_deleted_folder_1", "to_be_deleted_file_2.txt" ).exists() @pytest.mark.end_to_end -def test_clean_interactive_w_directories(sample_project_path, runner): +def test_clean_interactive_w_directories(project, runner): result = runner.invoke( cli, - ["clean", "-d", "--mode", "interactive", sample_project_path.as_posix()], + ["clean", "-d", "--mode", "interactive", project.as_posix()], # Three instead of two because the compiled .pyc file is also present. input="y\ny\ny", ) + assert result.exit_code == ExitCode.OK assert "Remove" in result.output assert "to_be_deleted_file_1.txt" in result.output - assert not sample_project_path.joinpath("to_be_deleted_file_1.txt").exists() + assert not project.joinpath("to_be_deleted_file_1.txt").exists() assert "to_be_deleted_file_2.txt" not in result.output assert "to_be_deleted_folder_1" in result.output - assert not sample_project_path.joinpath("to_be_deleted_folder_1").exists() + assert not project.joinpath("to_be_deleted_folder_1").exists() @pytest.mark.end_to_end @@ -168,3 +211,13 @@ def test_collection_failed(runner, tmp_path): result = runner.invoke(cli, ["clean", tmp_path.as_posix()]) assert result.exit_code == ExitCode.COLLECTION_FAILED + + +@pytest.mark.end_to_end +def test_dont_remove_files_tracked_by_git(runner, git_project): + result = runner.invoke(cli, ["clean", git_project.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "tracked.txt" not in result.output + assert "in_tracked.txt" not in result.output + assert ".git" not in result.output