diff --git a/README.rst b/README.rst index 2702e0f..7d24bac 100644 --- a/README.rst +++ b/README.rst @@ -242,6 +242,11 @@ inside the current folder. If you omit the final ``tmp/`` you'll leave the empty ``tmp`` directory in place. (**WARNING!** Don't put the ``.`` *after* the ``--erase`` option! Obviously, your project files will all be deleted.) +``--erase`` matches are filtered only by patterns you pass explicitly to +``--ignore``; the built-in default ignore list (``.git``, ``.tox``, ``.venv``, +…) does **not** restrict ``--erase``. This lets you wipe contents inside a +directory like ``.tox/generated/`` when you name it explicitly. + Empty directories 📂 ---------------------- diff --git a/pyclean/cli.py b/pyclean/cli.py index 5c50384..242943d 100644 --- a/pyclean/cli.py +++ b/pyclean/cli.py @@ -91,7 +91,7 @@ def parse_arguments(): metavar='DIRECTORY', action='extend', nargs='+', - default=ignore_default_items, + default=None, help='directory that should be ignored (may be specified multiple times;' ' default: %s)' % ' '.join(ignore_default_items), ) @@ -136,6 +136,10 @@ def parse_arguments(): else: args.debris = [] + args.explicit_ignore = args.ignore if args.ignore is not None else [] + # Keep defaults first while deduplicating explicit repeats. + args.ignore = list(dict.fromkeys([*ignore_default_items, *args.explicit_ignore])) + log.debug('Ignored directories: %s', ' '.join(args.ignore)) return args diff --git a/pyclean/erase.py b/pyclean/erase.py index 940e850..596665a 100644 --- a/pyclean/erase.py +++ b/pyclean/erase.py @@ -4,9 +4,12 @@ """Freeform target deletion with interactive prompt.""" +from __future__ import annotations + import logging from pathlib import Path +from .ignore import path_is_ignored from .runner import Runner log = logging.getLogger(__name__) @@ -23,10 +26,11 @@ def confirm(message): def delete_filesystem_objects( - directory: Path, + directory: Path | str, path_glob: str, prompt=False, dry_run=False, + ignore_patterns: list[str] | None = None, ): """ Identifies all pathnames matching a specific glob pattern, and attempts @@ -36,10 +40,17 @@ def delete_filesystem_objects( and first delete *all files* before removing directories. This way we make sure that the directories that are deepest down in the hierarchy are empty (for both files & directories) when we attempt to remove them. + + If ``ignore_patterns`` is not provided, the current ``Runner.ignore`` + patterns are used for compatibility with existing internal call sites. """ + directory = Path(directory) all_names = sorted(directory.glob(path_glob), reverse=True) - if Runner.ignore: - all_names = [n for n in all_names if not Runner.is_ignored(n)] + # Keep existing behavior for call sites like debris cleanup that invoke + # delete_filesystem_objects() directly and rely on Runner.ignore filtering. + ignore_patterns = Runner.ignore if ignore_patterns is None else ignore_patterns + if ignore_patterns: + all_names = [n for n in all_names if not path_is_ignored(n, ignore_patterns)] if not all_names: return log.debug('Erase file system objects matching: %s', path_glob) @@ -69,10 +80,11 @@ def delete_filesystem_objects( def remove_freeform_targets( - directory: Path, + directory: Path | str, glob_patterns: list[str], yes, dry_run=False, + explicit_ignore_patterns: list[str] | None = None, ): """ Remove free-form targets using globbing. @@ -88,6 +100,19 @@ def remove_freeform_targets( is empty when it is attempted to be deleted. - A confirmation prompt for the deletion of every single file system object is shown (unless the ``--yes`` option is used, in addition). + + Only ``explicit_ignore_patterns`` (typically the user's ``--ignore`` + values) constrain ``--erase`` matches. The built-in default ignore + list does not restrict ``--erase`` — when no explicit ignore patterns + are given, every match is deleted. """ + directory = Path(directory) + ignore_patterns = explicit_ignore_patterns or [] for path_glob in glob_patterns: - delete_filesystem_objects(directory, path_glob, prompt=not yes, dry_run=dry_run) + delete_filesystem_objects( + directory, + path_glob, + prompt=not yes, + dry_run=dry_run, + ignore_patterns=ignore_patterns, + ) diff --git a/pyclean/main.py b/pyclean/main.py index 2b61d48..cfd3be7 100644 --- a/pyclean/main.py +++ b/pyclean/main.py @@ -31,7 +31,13 @@ def pyclean(args): for topic in args.debris: remove_debris_for(topic, dir_path) - remove_freeform_targets(dir_path, args.erase, args.yes, args.dry_run) + remove_freeform_targets( + dir_path, + args.erase, + args.yes, + args.dry_run, + explicit_ignore_patterns=getattr(args, 'explicit_ignore', []), + ) if args.folders: log.debug('Removing empty directories...') diff --git a/tests/test_cli.py b/tests/test_cli.py index f73972b..8333663 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -112,6 +112,29 @@ def test_ignore_option(): args = pyclean.cli.parse_arguments() assert args.ignore == expected_ignore_list + assert args.explicit_ignore == ['foo', 'bar'] + + +def test_ignore_defaults_without_option(): + """ + Does --ignore still default to the built-in ignore list? + """ + default = [ + '.direnv', + '.git', + '.hg', + '.svn', + '.tox', + '.venv', + 'node_modules', + 'venv', + ] + + with ArgvContext('pyclean', '.'): + args = pyclean.cli.parse_arguments() + + assert args.ignore == default + assert args.explicit_ignore == [] def test_debris_default_args(): diff --git a/tests/test_erase.py b/tests/test_erase.py index 64ff0ca..2b53609 100644 --- a/tests/test_erase.py +++ b/tests/test_erase.py @@ -50,7 +50,7 @@ def test_erase_loop(mock_delete_fs_obj): remove_freeform_targets(directory, patterns, yes=False, dry_run=False) assert mock_delete_fs_obj.mock_calls == [ - call(directory, 'foo.txt', prompt=True, dry_run=False), + call(directory, 'foo.txt', prompt=True, dry_run=False, ignore_patterns=[]), ] @@ -238,7 +238,13 @@ def test_erase_ignored_dir_produces_no_output(tmp_path, caplog): pyclean.main.Runner.configure(args) with caplog.at_level(logging.DEBUG): - remove_freeform_targets(tmp_path, ['foo/**/*', 'foo'], yes=True, dry_run=True) + remove_freeform_targets( + tmp_path, + ['foo/**/*', 'foo'], + yes=True, + dry_run=True, + explicit_ignore_patterns=['foo'], + ) assert pyclean.main.Runner.unlink_count == 0 assert pyclean.main.Runner.rmdir_count == 0 @@ -286,3 +292,94 @@ def test_delete_filesystem_objects_erases_non_ignored(tmp_path): assert ignored_file.exists(), 'File in ignored directory should not be deleted' assert not non_ignored_file1.exists(), 'Non-ignored file should be deleted' assert not non_ignored_file2.exists(), 'Non-ignored file should be deleted' + + +def test_erase_in_explicit_target_under_default_ignored_ancestor(tmp_path): + """ + Does --erase still delete matches in an explicitly named directory + under a default ignored ancestor? + """ + target_dir = tmp_path / '.tox' / 'generated' / 'django' + target_dir.mkdir(parents=True) + target_file = target_dir / 'pyproject.toml' + target_file.write_text('[build-system]') + + args = Namespace( + directory=[str(target_dir)], + debris=[], + erase=['**/*'], + yes=True, + dry_run=False, + folders=False, + git_clean=False, + ignore=['.tox'], + explicit_ignore=[], + ) + + pyclean.main.pyclean(args) + + assert not target_file.exists(), ( + 'File should be deleted by --erase in target directory' + ) + + +def test_erase_applies_explicit_ignore_on_top_of_defaults(tmp_path): + """ + Does --erase apply only explicit ignore patterns for filtering matches? + """ + target_dir = tmp_path / '.tox' / 'generated' / 'django' + target_dir.mkdir(parents=True) + kept_file = target_dir / 'keep.txt' + removed_file = target_dir / 'erase.txt' + kept_file.write_text('keep') + removed_file.write_text('remove') + + args = Namespace( + directory=[str(target_dir)], + debris=[], + erase=['**/*'], + yes=True, + dry_run=False, + folders=False, + git_clean=False, + ignore=['.tox', 'keep.txt'], + explicit_ignore=['keep.txt'], + ) + + pyclean.main.pyclean(args) + + assert kept_file.exists(), 'Explicitly ignored file should not be deleted' + assert not removed_file.exists(), 'Non-ignored file should be deleted' + + +def test_erase_is_no_op_when_explicit_ignore_matches_target(tmp_path): + """ + Does --erase do nothing when --ignore matches the named target directory? + + Per the precedence table in issue #122, + ``pyclean DIR --erase '**/*' --ignore DIR`` is a no-op: explicit + ``--ignore`` takes precedence and the user can still protect the + named directory from erasure. + """ + target_dir = tmp_path / 'protected' + target_dir.mkdir() + safe_file = target_dir / 'safe.txt' + safe_file.write_text('safe') + + args = Namespace( + directory=[str(target_dir)], + debris=[], + erase=['**/*'], + yes=True, + dry_run=False, + folders=False, + git_clean=False, + ignore=['protected'], + explicit_ignore=['protected'], + ) + + pyclean.main.pyclean(args) + + assert safe_file.exists(), ( + 'File should not be deleted when --ignore matches the target directory' + )