Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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
29 changes: 24 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 @@ -89,5 +101,12 @@ def remove_freeform_targets(
- A confirmation prompt for the deletion of every single file system
object is shown (unless the ``--yes`` option is used, in addition).
"""
directory = Path(directory)
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=explicit_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
79 changes: 78 additions & 1 deletion 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=None),
]


Expand Down Expand Up @@ -286,3 +286,80 @@ 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=[
'.direnv',
'.git',
'.hg',
'.svn',
'.tox',
'.venv',
'node_modules',
'venv',
],
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=[
'.direnv',
'.git',
'.hg',
'.svn',
'.tox',
'.venv',
'node_modules',
'venv',
'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'
Loading