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 @@
+
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 @@
+
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