From 3bc06c4eb9a372b101b7c05259c1f570803bba09 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Mon, 9 May 2022 16:15:25 +0200 Subject: [PATCH 01/12] Fix the clean command. --- src/_pytask/git.py | 42 ++++++++++++++++++++++ src/_pytask/live.py | 6 ++++ tests/test_clean.py | 85 ++++++++++++++++++++++++++++----------------- 3 files changed, 102 insertions(+), 31 deletions(-) create mode 100644 src/_pytask/git.py diff --git a/src/_pytask/git.py b/src/_pytask/git.py new file mode 100644 index 00000000..7ca512af --- /dev/null +++ b/src/_pytask/git.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def cmd_output(*cmd, **kwargs: Any): + r = subprocess.run(cmd, capture_output=True, **kwargs) + stdout = r.stdout.decode() if r.stdout is None else None + stderr = r.stderr.decode() if r.stderr is None else None + return r.returncode, stdout, stderr + + +def init_repo(path: Path) -> None: + subprocess.run("git", "init", cwd=path) + + +def zsplit(s: str) -> list[str]: + s = s.strip("\0") + if s: + return s.split("\0") + else: + return [] + + +def get_all_files() -> list[str]: + return zsplit(cmd_output("git", "ls-files", "-z")[1]) + + +def get_root() -> str: + # 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, but must perform + # an extra check to see if we are in the .git directory. + try: + root = os.path.abspath( + cmd_output("git", "rev-parse", "--show-cdup")[1].strip(), + ) + except CalledProcessError: + # Either git is not installed or user is not in git repo. + root = None + return root diff --git a/src/_pytask/live.py b/src/_pytask/live.py index f08a0e45..b3a7ede0 100644 --- a/src/_pytask/live.py +++ b/src/_pytask/live.py @@ -132,6 +132,12 @@ def stop(self, transient: bool | None = None) -> None: if transient is not None: self._live.transient = transient self._live.stop() + # try: + # self._live.stop() + # except OSError: + # import traceback + # traceback.print_exc() + # breakpoint() def pause(self) -> None: self._live.transient = True diff --git a/tests/test_clean.py b/tests/test_clean.py index c94bcbe1..47eabcc8 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -8,7 +8,7 @@ @pytest.fixture() -def sample_project_path(tmp_path): +def project(tmp_path): """Create a sample project to be cleaned.""" source = """ import pytask @@ -28,11 +28,33 @@ def task_write_text(produces): 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.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(project, runner): + result = runner.invoke(cli, ["clean", "--ignore", "*_1.txt", project.as_posix()]) text_without_linebreaks = result.output.replace("\n", "") assert "to_be_deleted_file_1.txt" not in text_without_linebreaks @@ -40,14 +62,14 @@ def test_clean_with_ignored_file(sample_project_path, runner): @pytest.mark.end_to_end -def test_clean_with_ingored_directory(sample_project_path, runner): +def test_clean_with_ingored_directory(project, runner): result = runner.invoke( cli, [ "clean", "--ignore", "to_be_deleted_folder_1/*", - sample_project_path.as_posix(), + project.as_posix(), ], ) @@ -63,22 +85,22 @@ def test_clean_with_nothing_to_remove(tmp_path, runner): @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()]) 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()]) text_without_linebreaks = result.output.replace("\n", "") assert "Would remove" in text_without_linebreaks @@ -88,26 +110,22 @@ 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()]) 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()]) text_without_linebreaks = result.output.replace("\n", "") assert "Remove" in text_without_linebreaks @@ -117,38 +135,38 @@ 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 "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 "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 +186,8 @@ 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): + ... From 99d1ad5b895eda29cac8957c61b2b6a992f4ae5b Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Wed, 11 May 2022 23:17:52 +0200 Subject: [PATCH 02/12] Fix test ignoring files tracked by git. --- src/_pytask/clean.py | 9 +++++++++ src/_pytask/git.py | 41 ++++++++++++++++++++++++++--------------- src/_pytask/live.py | 6 ------ tests/test_clean.py | 8 +++++++- 4 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/_pytask/clean.py b/src/_pytask/clean.py index 52bcf653..df9dac3f 100644 --- a/src/_pytask/clean.py +++ b/src/_pytask/clean.py @@ -19,6 +19,8 @@ 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 @@ -193,6 +195,13 @@ 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) + return known_paths diff --git a/src/_pytask/git.py b/src/_pytask/git.py index 7ca512af..7a704248 100644 --- a/src/_pytask/git.py +++ b/src/_pytask/git.py @@ -1,21 +1,27 @@ +"""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, **kwargs: 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 None else None - stderr = r.stderr.decode() if r.stderr is None else None + 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: - subprocess.run("git", "init", cwd=path) + """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") @@ -23,20 +29,25 @@ def zsplit(s: str) -> list[str]: return [] -def get_all_files() -> list[str]: - return zsplit(cmd_output("git", "ls-files", "-z")[1]) +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() -> str: - # 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, but must perform - # an extra check to see if we are in the .git directory. +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: - root = os.path.abspath( - cmd_output("git", "rev-parse", "--show-cdup")[1].strip(), - ) - except CalledProcessError: + _, 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/live.py b/src/_pytask/live.py index b3a7ede0..f08a0e45 100644 --- a/src/_pytask/live.py +++ b/src/_pytask/live.py @@ -132,12 +132,6 @@ def stop(self, transient: bool | None = None) -> None: if transient is not None: self._live.transient = transient self._live.stop() - # try: - # self._live.stop() - # except OSError: - # import traceback - # traceback.print_exc() - # breakpoint() def pause(self) -> None: self._live.transient = True diff --git a/tests/test_clean.py b/tests/test_clean.py index 47eabcc8..9d589f3e 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -1,8 +1,10 @@ from __future__ import annotations +import subprocess import textwrap import pytest +from _pytask.git import init_repo from pytask import cli from pytask import ExitCode @@ -190,4 +192,8 @@ def test_collection_failed(runner, tmp_path): @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 From d5d5559c6c8edc683cffc63807c30536be69d8e1 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Wed, 11 May 2022 23:37:46 +0200 Subject: [PATCH 03/12] Remove ignore. --- src/_pytask/clean.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/_pytask/clean.py b/src/_pytask/clean.py index df9dac3f..49bc17c8 100644 --- a/src/_pytask/clean.py +++ b/src/_pytask/clean.py @@ -16,7 +16,6 @@ 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 @@ -65,15 +64,6 @@ def pytask_parse_config( ) -@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", From 3b7b5e3341176712892b7eb31c2203e25b5eb018 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 12 May 2022 09:52:07 +0200 Subject: [PATCH 04/12] Fix clean test. --- src/_pytask/clean.py | 43 +++++++++++++++++++++++++++---------------- src/_pytask/config.py | 1 + tests/test_clean.py | 14 ++++++++++++-- 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/_pytask/clean.py b/src/_pytask/clean.py index 49bc17c8..01a64578 100644 --- a/src/_pytask/clean.py +++ b/src/_pytask/clean.py @@ -53,29 +53,36 @@ def pytask_parse_config( config: dict[str, Any], config_from_cli: dict[str, Any] ) -> None: """Parse the configuration.""" + config["directories"] = config_from_cli.get("directories", False) + config["exclude"] = config_from_cli.get("exclude") config["mode"] = get_first_non_none_value( config_from_cli, key="mode", default="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 - ) @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.", +) +@click.option( + "--mode", + type=click.Choice(["dry-run", "interactive", "force"]), + help=_HELP_TEXT_MODE, +) @click.option( "-q", "--quiet", @@ -111,9 +118,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"] @@ -205,7 +213,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. @@ -214,7 +225,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( @@ -250,7 +261,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. @@ -260,26 +271,26 @@ def from_path( """ 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) + 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) + 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..a9ff5ab3 100644 --- a/src/_pytask/config.py +++ b/src/_pytask/config.py @@ -42,6 +42,7 @@ "readthedocs.yml", "readthedocs.yaml", "environment.yml", + "pyproject.toml", "pytask.ini", "setup.cfg", "tox.ini", diff --git a/tests/test_clean.py b/tests/test_clean.py index 9d589f3e..dfc0e520 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -55,9 +55,12 @@ def task_write_text(produces): @pytest.mark.end_to_end -def test_clean_with_ignored_file(project, runner): - result = runner.invoke(cli, ["clean", "--ignore", "*_1.txt", project.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 @@ -83,6 +86,7 @@ def test_clean_with_ingored_directory(project, runner): def test_clean_with_nothing_to_remove(tmp_path, runner): result = runner.invoke(cli, ["clean", "--ignore", "*", tmp_path.as_posix()]) + assert result.exit_code == ExitCode.OK assert "There are no files and directories which can be deleted." in result.output @@ -90,6 +94,7 @@ def test_clean_with_nothing_to_remove(tmp_path, runner): 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 @@ -104,6 +109,7 @@ def test_clean_dry_run(project, runner): 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 @@ -115,6 +121,7 @@ def test_clean_dry_run_w_directories(project, runner): 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 @@ -129,6 +136,7 @@ def test_clean_force(project, runner): 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 @@ -145,6 +153,7 @@ def test_clean_interactive(project, runner): 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 project.joinpath("to_be_deleted_file_1.txt").exists() @@ -163,6 +172,7 @@ def test_clean_interactive_w_directories(project, runner): 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 project.joinpath("to_be_deleted_file_1.txt").exists() From 118a2e962ff5f5e106a86b45a6749ee14de8b45c Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 12 May 2022 13:06:02 +0200 Subject: [PATCH 05/12] Add profiling tip. --- docs/source/developers_guide.md | 10 ++++++++++ 1 file changed, 10 insertions(+) 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" +``` From 648fa8f306c78e509bf6d46cdd3707cb663984aa Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 12 May 2022 13:39:59 +0200 Subject: [PATCH 06/12] Fix test and remove ignore flag. --- tests/test_clean.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tests/test_clean.py b/tests/test_clean.py index dfc0e520..0426e53b 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -67,24 +67,20 @@ def test_clean_with_excluded_file(project, runner, flag, pattern): @pytest.mark.end_to_end -def test_clean_with_ingored_directory(project, 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/*", - project.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 From eea66e3a89d595e0ec5619296dc01256dd464ae9 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Thu, 12 May 2022 16:50:36 +0200 Subject: [PATCH 07/12] Implement test for using exclude from config. --- src/_pytask/clean.py | 5 ++--- tests/test_clean.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/_pytask/clean.py b/src/_pytask/clean.py index 01a64578..35925a5f 100644 --- a/src/_pytask/clean.py +++ b/src/_pytask/clean.py @@ -55,9 +55,7 @@ def pytask_parse_config( """Parse the configuration.""" config["directories"] = config_from_cli.get("directories", False) config["exclude"] = config_from_cli.get("exclude") - config["mode"] = get_first_non_none_value( - config_from_cli, key="mode", default="dry-run" - ) + config["mode"] = config_from_cli.get("mode", "dry-run") config["quiet"] = get_first_non_none_value( config_from_cli, key="quiet", default=False ) @@ -80,6 +78,7 @@ def pytask_parse_config( ) @click.option( "--mode", + default="dry-run", type=click.Choice(["dry-run", "interactive", "force"]), help=_HELP_TEXT_MODE, ) diff --git a/tests/test_clean.py b/tests/test_clean.py index 0426e53b..1b7f2acc 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -66,6 +66,23 @@ def test_clean_with_excluded_file(project, runner, flag, pattern): 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, 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 @pytest.mark.parametrize("flag", ["-e", "--exclude"]) def test_clean_with_excluded_directory(project, runner, flag): From f1d3333459233d246dcab378ee1c3d7dafbe64d4 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Fri, 13 May 2022 09:30:41 +0200 Subject: [PATCH 08/12] Excluded .git by default. --- src/_pytask/clean.py | 25 ++++++++++++++++++++----- src/_pytask/shared.py | 2 +- tests/test_clean.py | 3 ++- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/_pytask/clean.py b/src/_pytask/clean.py index 35925a5f..461af65b 100644 --- a/src/_pytask/clean.py +++ b/src/_pytask/clean.py @@ -26,7 +26,10 @@ 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 @@ -35,6 +38,9 @@ from typing import NoReturn +_DEFAULT_EXCLUDE = [".git"] + + _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 " @@ -50,11 +56,17 @@ 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["directories"] = config_from_cli.get("directories", False) - config["exclude"] = config_from_cli.get("exclude") + 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 @@ -75,6 +87,7 @@ def pytask_parse_config( multiple=True, type=str, help="A filename pattern to exclude files from the cleaning process.", + callback=falsy_to_none_callback, ) @click.option( "--mode", @@ -109,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: @@ -198,6 +211,7 @@ def _collect_all_paths_known_to_pytask(session: Session) -> set[Path]: 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 @@ -268,20 +282,21 @@ 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, exclude) for p in path.iterdir() ] if path.is_dir() - # Do not collect sub files and folders for ignored folders. + # 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. + # Excluded files are also known. or any(path.match(pattern) for pattern in exclude) ) is_unknown_directory = ( 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 1b7f2acc..f929f373 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -74,7 +74,7 @@ def test_clean_with_excluded_file_via_config(project, runner, flag, pattern): f"[tool.pytask.ini_options]\nexclude = [{pattern!r}]" ) - result = runner.invoke(cli, ["clean", flag, project.as_posix()]) + result = runner.invoke(cli, ["clean", flag, pattern, project.as_posix()]) assert result.exit_code == ExitCode.OK text_without_linebreaks = result.output.replace("\n", "") @@ -220,3 +220,4 @@ def test_dont_remove_files_tracked_by_git(runner, git_project): 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 From 85674ea6690edf4b65f50898b118778c77380425 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Fri, 13 May 2022 09:54:45 +0200 Subject: [PATCH 09/12] fix. --- src/_pytask/clean.py | 2 +- src/_pytask/config.py | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/_pytask/clean.py b/src/_pytask/clean.py index 461af65b..9861edc3 100644 --- a/src/_pytask/clean.py +++ b/src/_pytask/clean.py @@ -38,7 +38,7 @@ from typing import NoReturn -_DEFAULT_EXCLUDE = [".git"] +_DEFAULT_EXCLUDE: list[str] = [".git/*", ".hg/*", ".svn/*"] _HELP_TEXT_MODE = ( diff --git a/src/_pytask/config.py b/src/_pytask/config.py index a9ff5ab3..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] = [ From c0f3f5c0a6563d0a7a33b33f7513ef2f67879128 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Fri, 13 May 2022 14:19:52 +0200 Subject: [PATCH 10/12] Exclude repr from coverage. --- .coveragerc | 1 + 1 file changed, 1 insertion(+) 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__ From a370a24d070be6c81440906112575980d99d5361 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sat, 14 May 2022 11:19:57 +0200 Subject: [PATCH 11/12] Add preliminary svgs. --- .../images/clean-dry-run-directories.svg | 60 ++++++++++++++++++ docs/source/_static/images/clean-dry-run.svg | 61 +++++++++++++++++++ docs/source/tutorials/cleaning_projects.md | 60 +++++++++--------- scripts/svgs/task_clean.py | 34 +++++++++++ scripts/svgs/task_warning.py | 6 ++ 5 files changed, 190 insertions(+), 31 deletions(-) create mode 100644 docs/source/_static/images/clean-dry-run-directories.svg create mode 100644 docs/source/_static/images/clean-dry-run.svg create mode 100644 scripts/svgs/task_clean.py 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..1d01c282 --- /dev/null +++ b/docs/source/_static/images/clean-dry-run-directories.svg @@ -0,0 +1,60 @@ + + + + pytask + + + + + + + ──────────────────────────────────────── Start pytask session ──────────────────────────────────────── +Platform: win32 -- Python 3.10.2, pytask 0.2.2.dev13+gc0f3f5c.d20220514, pluggy 0.12.0                 +Root: C:\Users\TobiasR\git\pytask                                                                      +Plugins: parallel-0.2.0                                                                                +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..9efb15e5 --- /dev/null +++ b/docs/source/_static/images/clean-dry-run.svg @@ -0,0 +1,61 @@ + + + + pytask + + + + + + + ──────────────────────────────────────── Start pytask session ───────────────────────────────────────── +Platform: win32 -- Python 3.10.2, pytask 0.2.2.dev13+gc0f3f5c.d20220514, pluggy 0.12.0                  +Root: C:\Users\TobiasR\git\pytask                                                                       +Plugins: parallel-0.2.0                                                                                 +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/tutorials/cleaning_projects.md b/docs/source/tutorials/cleaning_projects.md index e11440af..83f7b8e0 100644 --- a/docs/source/tutorials/cleaning_projects.md +++ b/docs/source/tutorials/cleaning_projects.md @@ -1,49 +1,47 @@ # 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`. + +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/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() From 3019c9aa47a5ecdb24f917d61821ebb6b1ca54f2 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sat, 14 May 2022 12:29:23 +0200 Subject: [PATCH 12/12] Update svgs. --- .../images/clean-dry-run-directories.svg | 35 +++++++++--------- docs/source/_static/images/clean-dry-run.svg | 37 +++++++++---------- docs/source/tutorials/cleaning_projects.md | 3 +- scripts/svgs/README.md | 17 ++++++++- 4 files changed, 52 insertions(+), 40 deletions(-) diff --git a/docs/source/_static/images/clean-dry-run-directories.svg b/docs/source/_static/images/clean-dry-run-directories.svg index 1d01c282..b1284915 100644 --- a/docs/source/_static/images/clean-dry-run-directories.svg +++ b/docs/source/_static/images/clean-dry-run-directories.svg @@ -1,4 +1,4 @@ - + - pytask + pytask - ──────────────────────────────────────── Start pytask session ──────────────────────────────────────── -Platform: win32 -- Python 3.10.2, pytask 0.2.2.dev13+gc0f3f5c.d20220514, pluggy 0.12.0                 -Root: C:\Users\TobiasR\git\pytask                                                                      -Plugins: parallel-0.2.0                                                                                -Collected 25 tasks.                                                                                    -                                                                                                       -Files and directories which can be removed:                                                            -                                                                                                       -Would remove svgs\obsolete_file_1.md                                                                   -Would remove svgs\obsolete_folder                                                                      -                                                                                                       -────────────────────────────────────────────────────────────────────────────────────────────────────── + ──────────────────────────────────────── 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 index 9efb15e5..77b508ae 100644 --- a/docs/source/_static/images/clean-dry-run.svg +++ b/docs/source/_static/images/clean-dry-run.svg @@ -1,4 +1,4 @@ - + - pytask + pytask - ──────────────────────────────────────── Start pytask session ───────────────────────────────────────── -Platform: win32 -- Python 3.10.2, pytask 0.2.2.dev13+gc0f3f5c.d20220514, pluggy 0.12.0                  -Root: C:\Users\TobiasR\git\pytask                                                                       -Plugins: parallel-0.2.0                                                                                 -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                                                    -                                                                                                        -─────────────────────────────────────────────────────────────────────────────────────────────────────── + ──────────────────────────────────────── 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/tutorials/cleaning_projects.md b/docs/source/tutorials/cleaning_projects.md index 83f7b8e0..ea29fef3 100644 --- a/docs/source/tutorials/cleaning_projects.md +++ b/docs/source/tutorials/cleaning_projects.md @@ -29,7 +29,8 @@ Files which are under version control with git are excluded from the cleaning pr 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`. +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. 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