Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 📂
----------------------

Expand Down
6 changes: 5 additions & 1 deletion pyclean/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down Expand Up @@ -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
Expand Down
35 changes: 30 additions & 5 deletions pyclean/erase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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,
)
8 changes: 7 additions & 1 deletion pyclean/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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...')
Expand Down
23 changes: 23 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
101 changes: 99 additions & 2 deletions tests/test_erase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[]),
]


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
)
Loading