From 6eb7f3cfb05f499e1ccea0d3332ed05b363e580f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=28M=29=C2=B2?= <131779342+Kaemiemi@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:04:28 -0300 Subject: [PATCH 1/3] Hint at optional debris topics --- pyclean/cli.py | 343 +++++++++++++++++++++++----------------------- pyclean/debris.py | 324 +++++++++++++++++++++++-------------------- pyclean/main.py | 144 +++++++++---------- 3 files changed, 420 insertions(+), 391 deletions(-) diff --git a/pyclean/cli.py b/pyclean/cli.py index 242943d..d0078ac 100644 --- a/pyclean/cli.py +++ b/pyclean/cli.py @@ -1,171 +1,172 @@ -# SPDX-FileCopyrightText: 2019 Peter Bittner -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Command line interface implementation for pyclean.""" - -import argparse -import logging -import shutil - -from . import __version__ -from . import main as main_module - -log = logging.getLogger(__name__) - - -def parse_arguments(): - """ - Parse and handle CLI arguments. - """ - debris_default_topics = ['cache', 'coverage', 'package', 'pytest', 'ruff'] - debris_optional_topics = ['complexipy', 'jupyter', 'mypy', 'pyright', 'tox'] - debris_choices = ['all', *debris_default_topics, *debris_optional_topics] - ignore_default_items = [ - '.direnv', - '.git', - '.hg', - '.svn', - '.tox', - '.venv', - 'node_modules', - 'venv', - ] - - parser = argparse.ArgumentParser( - description=( - 'Remove bytecode files, cache directories, build and test artifacts ' - 'and other debris in your Python project or elsewhere.' - ), - epilog='Made with ♥ by Painless Software, 🄯 Peter Bittner.', - ) - - parser.add_argument('--version', action='version', version=__version__) - parser.add_argument( - 'directory', - nargs='+', - help='directory tree to traverse for bytecode and debris', - ) - parser.add_argument( - '-d', - '--debris', - metavar='TOPIC', - action='extend', - nargs='*', - default=argparse.SUPPRESS, - choices=debris_choices, - help='remove leftovers from popular Python development tools' - ' (may be specified multiple times; optional: all %s; default: %s)' - % ( - ' '.join(debris_optional_topics), - ' '.join(debris_default_topics), - ), - ) - parser.add_argument( - '-e', - '--erase', - metavar='PATTERN', - action='extend', - nargs='+', - default=[], - help='delete files or folders matching a globbing pattern (may be specified' - ' multiple times); this will be interactive unless --yes is used.', - ) - parser.add_argument( - '-f', - '--folders', - action='store_true', - help='remove empty directories', - ) - parser.add_argument( - '-g', - '--git-clean', - action='store_true', - default=False, - help='run git clean to remove untracked files; this will be interactive' - ' unless --yes is used.', - ) - parser.add_argument( - '-i', - '--ignore', - metavar='DIRECTORY', - action='extend', - nargs='+', - default=None, - help='directory that should be ignored (may be specified multiple times;' - ' default: %s)' % ' '.join(ignore_default_items), - ) - parser.add_argument( - '-n', - '--dry-run', - action='store_true', - help='show what would be done', - ) - - verbosity = parser.add_mutually_exclusive_group() - verbosity.add_argument('-q', '--quiet', action='store_true', help='be quiet') - verbosity.add_argument( - '-v', - '--verbose', - action='store_true', - help='be more verbose', - ) - - parser.add_argument( - '-y', - '--yes', - action='store_true', - help='assume yes as answer for interactive questions', - ) - - args = parser.parse_args() - init_logging(args) - - if args.git_clean and not shutil.which('git'): - parser.error('Git is not available. Install Git to use --git-clean.') - - if args.yes and not args.erase and not args.git_clean: - parser.error('Specifying --yes only makes sense with --erase or --git-clean.') - - if 'debris' in args: - if 'all' in args.debris: - args.debris = debris_default_topics + debris_optional_topics - elif not args.debris: - args.debris = debris_default_topics - log.debug('Debris topics to scan for: %s', ' '.join(args.debris)) - 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 - - -def init_logging(args): - """ - Set the log level according to the -v/-q command line options. - """ - log_level = ( - logging.FATAL if args.quiet else logging.DEBUG if args.verbose else logging.INFO - ) - log_format = '%(message)s' - logging.basicConfig(level=log_level, format=log_format) - - -def main(): - """ - Entry point for CLI application. - """ - args = parse_arguments() - - try: - main_module.pyclean(args) - except Exception as err: - raise SystemExit(err) - except KeyboardInterrupt: - msg = 'Aborted by user.' - raise SystemExit(msg) +# SPDX-FileCopyrightText: 2019 Peter Bittner +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Command line interface implementation for pyclean.""" + +import argparse +import logging +import shutil + +from . import __version__ +from . import main as main_module + +log = logging.getLogger(__name__) + + +def parse_arguments(): + """ + Parse and handle CLI arguments. + """ + debris_default_topics = ['cache', 'coverage', 'package', 'pytest', 'ruff'] + debris_optional_topics = ['complexipy', 'jupyter', 'mypy', 'pyright', 'tox'] + debris_choices = ['all', *debris_default_topics, *debris_optional_topics] + ignore_default_items = [ + '.direnv', + '.git', + '.hg', + '.svn', + '.tox', + '.venv', + 'node_modules', + 'venv', + ] + + parser = argparse.ArgumentParser( + description=( + 'Remove bytecode files, cache directories, build and test artifacts ' + 'and other debris in your Python project or elsewhere.' + ), + epilog='Made with ♥ by Painless Software, 🄯 Peter Bittner.', + ) + + parser.add_argument('--version', action='version', version=__version__) + parser.add_argument( + 'directory', + nargs='+', + help='directory tree to traverse for bytecode and debris', + ) + parser.add_argument( + '-d', + '--debris', + metavar='TOPIC', + action='extend', + nargs='*', + default=argparse.SUPPRESS, + choices=debris_choices, + help='remove leftovers from popular Python development tools' + ' (may be specified multiple times; optional: all %s; default: %s)' + % ( + ' '.join(debris_optional_topics), + ' '.join(debris_default_topics), + ), + ) + parser.add_argument( + '-e', + '--erase', + metavar='PATTERN', + action='extend', + nargs='+', + default=[], + help='delete files or folders matching a globbing pattern (may be specified' + ' multiple times); this will be interactive unless --yes is used.', + ) + parser.add_argument( + '-f', + '--folders', + action='store_true', + help='remove empty directories', + ) + parser.add_argument( + '-g', + '--git-clean', + action='store_true', + default=False, + help='run git clean to remove untracked files; this will be interactive' + ' unless --yes is used.', + ) + parser.add_argument( + '-i', + '--ignore', + metavar='DIRECTORY', + action='extend', + nargs='+', + default=None, + help='directory that should be ignored (may be specified multiple times;' + ' default: %s)' % ' '.join(ignore_default_items), + ) + parser.add_argument( + '-n', + '--dry-run', + action='store_true', + help='show what would be done', + ) + + verbosity = parser.add_mutually_exclusive_group() + verbosity.add_argument('-q', '--quiet', action='store_true', help='be quiet') + verbosity.add_argument( + '-v', + '--verbose', + action='store_true', + help='be more verbose', + ) + + parser.add_argument( + '-y', + '--yes', + action='store_true', + help='assume yes as answer for interactive questions', + ) + + args = parser.parse_args() + init_logging(args) + + if args.git_clean and not shutil.which('git'): + parser.error('Git is not available. Install Git to use --git-clean.') + + if args.yes and not args.erase and not args.git_clean: + parser.error('Specifying --yes only makes sense with --erase or --git-clean.') + + args.debris_without_topics = 'debris' in args and not args.debris + if 'debris' in args: + if 'all' in args.debris: + args.debris = debris_default_topics + debris_optional_topics + elif not args.debris: + args.debris = debris_default_topics + log.debug('Debris topics to scan for: %s', ' '.join(args.debris)) + 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 + + +def init_logging(args): + """ + Set the log level according to the -v/-q command line options. + """ + log_level = ( + logging.FATAL if args.quiet else logging.DEBUG if args.verbose else logging.INFO + ) + log_format = '%(message)s' + logging.basicConfig(level=log_level, format=log_format) + + +def main(): + """ + Entry point for CLI application. + """ + args = parse_arguments() + + try: + main_module.pyclean(args) + except Exception as err: + raise SystemExit(err) + except KeyboardInterrupt: + msg = 'Aborted by user.' + raise SystemExit(msg) diff --git a/pyclean/debris.py b/pyclean/debris.py index c16cd08..96a077f 100644 --- a/pyclean/debris.py +++ b/pyclean/debris.py @@ -1,151 +1,173 @@ -# SPDX-FileCopyrightText: 2020 Peter Bittner -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Tool-specific artifact cleanup and debris detection (to suggest option usage).""" - -import logging -import os -from pathlib import Path - -from .erase import delete_filesystem_objects -from .ignore import should_ignore -from .runner import Runner - -log = logging.getLogger(__name__) - -DEBRIS_TOPICS = { - 'cache': [ - '.cache/**/*', - '.cache/', - ], - 'complexipy': [ - '.complexipy_cache/**/*', - '.complexipy_cache/', - ], - 'coverage': [ - '.coverage', - 'coverage.json', - 'coverage.lcov', - 'coverage.xml', - 'htmlcov/**/*', - 'htmlcov/', - ], - 'jupyter': [ - '.ipynb_checkpoints/**/*', - '.ipynb_checkpoints/', - ], - 'mypy': [ - '.mypy_cache/**/*', - '.mypy_cache/', - ], - 'package': [ - 'build/bdist.*/**/*', - 'build/bdist.*/', - 'build/lib/**/*', - 'build/lib/', - 'build/', - 'dist/**/*', - 'dist/', - 'sdist/**/*', - 'sdist/', - '*.egg-info/**/*', - '*.egg-info/', - ], - 'pyright': [ - '.pyright-app-cache-*/**/*', - '.pyright-app-cache-*/', - '.pyright-stubs-*/**/*', - '.pyright-stubs-*/', - '.pyright/', - ], - 'pytest': [ - '.pytest_cache/**/*', - '.pytest_cache/', - 'pytestdebug.log', - ], - 'ruff': [ - '.ruff_cache/**/*', - '.ruff_cache/', - ], - 'tox': [ - '.tox/**/*', - '.tox/', - ], -} - - -def remove_debris_for(topic, directory): - """ - Clean up debris for a specific topic. - """ - log.debug('Scanning for debris of %s ...', topic.title()) - - patterns = DEBRIS_TOPICS[topic] - recursive_delete_debris(directory, patterns) - - -def recursive_delete_debris(directory: Path, patterns: list[str]): - """ - Recursively delete debris matching any of the given patterns. - - This function walks the directory tree once and applies all patterns - at each level, avoiding redundant directory scans. - """ - for pattern in patterns: - delete_filesystem_objects(directory, pattern) - - try: - subdirs = [entry for entry in os.scandir(directory) if entry.is_dir()] - except (OSError, PermissionError) as err: - log.warning('Cannot access directory %s: %s', directory, err) - return - - for subdir in subdirs: - if should_ignore(subdir.path, Runner.ignore): - log.debug('Skipping %s', subdir.name) - else: - recursive_delete_debris(Path(subdir.path), patterns) - - -def detect_debris_in_directory(directory): - """ - Scan a directory for debris artifacts and return a list of detected topics. - """ - detected_topics = [] - - for topic, patterns in DEBRIS_TOPICS.items(): - for pattern in patterns: - if '**' in pattern: - continue - matches = list(directory.glob(pattern)) - if matches: - detected_topics.append(topic) - break - - return detected_topics - - -def suggest_debris_option(args): - """ - Suggest using the --debris option when it wasn't used. - Optionally provide targeted suggestions based on detected artifacts. - """ - all_detected = set() - for dir_name in args.directory: - dir_path = Path(dir_name) - if dir_path.exists(): - detected = detect_debris_in_directory(dir_path) - all_detected.update(detected) - - if all_detected: - topics_str = ' '.join(sorted(all_detected)) - log.info( - 'Hint: Use --debris to also clean up build artifacts. Detected: %s', - topics_str, - ) - else: - log.info( - 'Hint: Use --debris to also clean up build artifacts ' - 'from common Python development tools.', - ) +# SPDX-FileCopyrightText: 2020 Peter Bittner +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tool-specific artifact cleanup and debris detection (to suggest option usage).""" + +import logging +import os +from pathlib import Path + +from .erase import delete_filesystem_objects +from .ignore import should_ignore +from .runner import Runner + +log = logging.getLogger(__name__) + +DEBRIS_TOPICS = { + 'cache': [ + '.cache/**/*', + '.cache/', + ], + 'complexipy': [ + '.complexipy_cache/**/*', + '.complexipy_cache/', + ], + 'coverage': [ + '.coverage', + 'coverage.json', + 'coverage.lcov', + 'coverage.xml', + 'htmlcov/**/*', + 'htmlcov/', + ], + 'jupyter': [ + '.ipynb_checkpoints/**/*', + '.ipynb_checkpoints/', + ], + 'mypy': [ + '.mypy_cache/**/*', + '.mypy_cache/', + ], + 'package': [ + 'build/bdist.*/**/*', + 'build/bdist.*/', + 'build/lib/**/*', + 'build/lib/', + 'build/', + 'dist/**/*', + 'dist/', + 'sdist/**/*', + 'sdist/', + '*.egg-info/**/*', + '*.egg-info/', + ], + 'pyright': [ + '.pyright-app-cache-*/**/*', + '.pyright-app-cache-*/', + '.pyright-stubs-*/**/*', + '.pyright-stubs-*/', + '.pyright/', + ], + 'pytest': [ + '.pytest_cache/**/*', + '.pytest_cache/', + 'pytestdebug.log', + ], + 'ruff': [ + '.ruff_cache/**/*', + '.ruff_cache/', + ], + 'tox': [ + '.tox/**/*', + '.tox/', + ], +} + + +def remove_debris_for(topic, directory): + """ + Clean up debris for a specific topic. + """ + log.debug('Scanning for debris of %s ...', topic.title()) + + patterns = DEBRIS_TOPICS[topic] + recursive_delete_debris(directory, patterns) + + +def recursive_delete_debris(directory: Path, patterns: list[str]): + """ + Recursively delete debris matching any of the given patterns. + + This function walks the directory tree once and applies all patterns + at each level, avoiding redundant directory scans. + """ + for pattern in patterns: + delete_filesystem_objects(directory, pattern) + + try: + subdirs = [entry for entry in os.scandir(directory) if entry.is_dir()] + except (OSError, PermissionError) as err: + log.warning('Cannot access directory %s: %s', directory, err) + return + + for subdir in subdirs: + if should_ignore(subdir.path, Runner.ignore): + log.debug('Skipping %s', subdir.name) + else: + recursive_delete_debris(Path(subdir.path), patterns) + + +def detect_debris_in_directory(directory): + """ + Scan a directory for debris artifacts and return a list of detected topics. + """ + detected_topics = [] + + for topic, patterns in DEBRIS_TOPICS.items(): + for pattern in patterns: + if '**' in pattern: + continue + matches = list(directory.glob(pattern)) + if matches: + detected_topics.append(topic) + break + + return detected_topics + + +def suggest_debris_option(args): + """ + Suggest using the --debris option when it wasn't used. + Optionally provide targeted suggestions based on detected artifacts. + """ + all_detected = set() + for dir_name in args.directory: + dir_path = Path(dir_name) + if dir_path.exists(): + detected = detect_debris_in_directory(dir_path) + all_detected.update(detected) + + if all_detected: + topics_str = ' '.join(sorted(all_detected)) + log.info( + 'Hint: Use --debris to also clean up build artifacts. Detected: %s', + topics_str, + ) + else: + log.info( + 'Hint: Use --debris to also clean up build artifacts ' + 'from common Python development tools.', + ) + + +def suggest_optional_debris_topics(args): + """Suggest optional topics when --debris was used without topic arguments.""" + detected_topics = set() + for dir_name in args.directory: + dir_path = Path(dir_name) + if dir_path.exists(): + detected_topics.update(detect_debris_in_directory(dir_path)) + + optional_detected = sorted(detected_topics - set(args.debris)) + if optional_detected: + log.info( + 'Hint: Use --debris all or specify optional topics to clean up ' + 'more aggressively. Detected: %s', + ' '.join(optional_detected), + ) + else: + log.info( + 'Hint: Use --debris all or specify optional topics to clean up ' + 'more aggressively.', + ) diff --git a/pyclean/main.py b/pyclean/main.py index cfd3be7..9a7b9da 100644 --- a/pyclean/main.py +++ b/pyclean/main.py @@ -1,69 +1,75 @@ -# SPDX-FileCopyrightText: 2020 Peter Bittner -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Main orchestration of the pyclean cleanup process.""" - -import logging -from pathlib import Path - -from .bytecode import BYTECODE_DIRS, BYTECODE_FILES -from .debris import remove_debris_for, suggest_debris_option -from .erase import remove_freeform_targets -from .folders import remove_empty_directories -from .gitclean import execute_git_clean -from .runner import Runner -from .traversal import descend_and_clean - -log = logging.getLogger(__name__) - - -def pyclean(args): - """Cross-platform cleaning of Python bytecode.""" - Runner.configure(args) - - for dir_name in args.directory: - dir_path = Path(dir_name) - - log.info('Cleaning directory %s', dir_path) - descend_and_clean(dir_path, BYTECODE_FILES, BYTECODE_DIRS) - - for topic in args.debris: - remove_debris_for(topic, dir_path) - - 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...') - remove_empty_directories(dir_path) - - if args.git_clean: - execute_git_clean(dir_path, args) - - git_clean_note = ' (Not counting git clean)' if args.git_clean else '' - - log.info( - 'Total %d files, %d directories %s.%s', - Runner.unlink_count, - Runner.rmdir_count, - 'would be removed' if args.dry_run else 'removed', - git_clean_note, - ) - - if Runner.unlink_failed or Runner.rmdir_failed: - log.debug( - '%d files, %d directories %s not be removed.%s', - Runner.unlink_failed, - Runner.rmdir_failed, - 'would' if args.dry_run else 'could', - git_clean_note, - ) - - if not args.debris: - suggest_debris_option(args) +# SPDX-FileCopyrightText: 2020 Peter Bittner +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Main orchestration of the pyclean cleanup process.""" + +import logging +from pathlib import Path + +from .bytecode import BYTECODE_DIRS, BYTECODE_FILES +from .debris import ( + remove_debris_for, + suggest_debris_option, + suggest_optional_debris_topics, +) +from .erase import remove_freeform_targets +from .folders import remove_empty_directories +from .gitclean import execute_git_clean +from .runner import Runner +from .traversal import descend_and_clean + +log = logging.getLogger(__name__) + + +def pyclean(args): + """Cross-platform cleaning of Python bytecode.""" + Runner.configure(args) + + for dir_name in args.directory: + dir_path = Path(dir_name) + + log.info('Cleaning directory %s', dir_path) + descend_and_clean(dir_path, BYTECODE_FILES, BYTECODE_DIRS) + + for topic in args.debris: + remove_debris_for(topic, dir_path) + + 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...') + remove_empty_directories(dir_path) + + if args.git_clean: + execute_git_clean(dir_path, args) + + git_clean_note = ' (Not counting git clean)' if args.git_clean else '' + + log.info( + 'Total %d files, %d directories %s.%s', + Runner.unlink_count, + Runner.rmdir_count, + 'would be removed' if args.dry_run else 'removed', + git_clean_note, + ) + + if Runner.unlink_failed or Runner.rmdir_failed: + log.debug( + '%d files, %d directories %s not be removed.%s', + Runner.unlink_failed, + Runner.rmdir_failed, + 'would' if args.dry_run else 'could', + git_clean_note, + ) + + if not args.debris: + suggest_debris_option(args) + elif getattr(args, 'debris_without_topics', False): + suggest_optional_debris_topics(args) From 205c2792ffb4753068ab099d78bb1a10dd43c597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=28M=29=C2=B2?= <131779342+Kaemiemi@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:07:55 -0300 Subject: [PATCH 2/3] Preserve LF line endings in code files --- pyclean/cli.py | 344 ++++++++++++++++++++++----------------------- pyclean/debris.py | 346 +++++++++++++++++++++++----------------------- pyclean/main.py | 150 ++++++++++---------- 3 files changed, 420 insertions(+), 420 deletions(-) diff --git a/pyclean/cli.py b/pyclean/cli.py index d0078ac..1e78a81 100644 --- a/pyclean/cli.py +++ b/pyclean/cli.py @@ -1,172 +1,172 @@ -# SPDX-FileCopyrightText: 2019 Peter Bittner -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Command line interface implementation for pyclean.""" - -import argparse -import logging -import shutil - -from . import __version__ -from . import main as main_module - -log = logging.getLogger(__name__) - - -def parse_arguments(): - """ - Parse and handle CLI arguments. - """ - debris_default_topics = ['cache', 'coverage', 'package', 'pytest', 'ruff'] - debris_optional_topics = ['complexipy', 'jupyter', 'mypy', 'pyright', 'tox'] - debris_choices = ['all', *debris_default_topics, *debris_optional_topics] - ignore_default_items = [ - '.direnv', - '.git', - '.hg', - '.svn', - '.tox', - '.venv', - 'node_modules', - 'venv', - ] - - parser = argparse.ArgumentParser( - description=( - 'Remove bytecode files, cache directories, build and test artifacts ' - 'and other debris in your Python project or elsewhere.' - ), - epilog='Made with ♥ by Painless Software, 🄯 Peter Bittner.', - ) - - parser.add_argument('--version', action='version', version=__version__) - parser.add_argument( - 'directory', - nargs='+', - help='directory tree to traverse for bytecode and debris', - ) - parser.add_argument( - '-d', - '--debris', - metavar='TOPIC', - action='extend', - nargs='*', - default=argparse.SUPPRESS, - choices=debris_choices, - help='remove leftovers from popular Python development tools' - ' (may be specified multiple times; optional: all %s; default: %s)' - % ( - ' '.join(debris_optional_topics), - ' '.join(debris_default_topics), - ), - ) - parser.add_argument( - '-e', - '--erase', - metavar='PATTERN', - action='extend', - nargs='+', - default=[], - help='delete files or folders matching a globbing pattern (may be specified' - ' multiple times); this will be interactive unless --yes is used.', - ) - parser.add_argument( - '-f', - '--folders', - action='store_true', - help='remove empty directories', - ) - parser.add_argument( - '-g', - '--git-clean', - action='store_true', - default=False, - help='run git clean to remove untracked files; this will be interactive' - ' unless --yes is used.', - ) - parser.add_argument( - '-i', - '--ignore', - metavar='DIRECTORY', - action='extend', - nargs='+', - default=None, - help='directory that should be ignored (may be specified multiple times;' - ' default: %s)' % ' '.join(ignore_default_items), - ) - parser.add_argument( - '-n', - '--dry-run', - action='store_true', - help='show what would be done', - ) - - verbosity = parser.add_mutually_exclusive_group() - verbosity.add_argument('-q', '--quiet', action='store_true', help='be quiet') - verbosity.add_argument( - '-v', - '--verbose', - action='store_true', - help='be more verbose', - ) - - parser.add_argument( - '-y', - '--yes', - action='store_true', - help='assume yes as answer for interactive questions', - ) - - args = parser.parse_args() - init_logging(args) - - if args.git_clean and not shutil.which('git'): - parser.error('Git is not available. Install Git to use --git-clean.') - - if args.yes and not args.erase and not args.git_clean: - parser.error('Specifying --yes only makes sense with --erase or --git-clean.') - - args.debris_without_topics = 'debris' in args and not args.debris - if 'debris' in args: - if 'all' in args.debris: - args.debris = debris_default_topics + debris_optional_topics - elif not args.debris: - args.debris = debris_default_topics - log.debug('Debris topics to scan for: %s', ' '.join(args.debris)) - 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 - - -def init_logging(args): - """ - Set the log level according to the -v/-q command line options. - """ - log_level = ( - logging.FATAL if args.quiet else logging.DEBUG if args.verbose else logging.INFO - ) - log_format = '%(message)s' - logging.basicConfig(level=log_level, format=log_format) - - -def main(): - """ - Entry point for CLI application. - """ - args = parse_arguments() - - try: - main_module.pyclean(args) - except Exception as err: - raise SystemExit(err) - except KeyboardInterrupt: - msg = 'Aborted by user.' - raise SystemExit(msg) +# SPDX-FileCopyrightText: 2019 Peter Bittner +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Command line interface implementation for pyclean.""" + +import argparse +import logging +import shutil + +from . import __version__ +from . import main as main_module + +log = logging.getLogger(__name__) + + +def parse_arguments(): + """ + Parse and handle CLI arguments. + """ + debris_default_topics = ['cache', 'coverage', 'package', 'pytest', 'ruff'] + debris_optional_topics = ['complexipy', 'jupyter', 'mypy', 'pyright', 'tox'] + debris_choices = ['all', *debris_default_topics, *debris_optional_topics] + ignore_default_items = [ + '.direnv', + '.git', + '.hg', + '.svn', + '.tox', + '.venv', + 'node_modules', + 'venv', + ] + + parser = argparse.ArgumentParser( + description=( + 'Remove bytecode files, cache directories, build and test artifacts ' + 'and other debris in your Python project or elsewhere.' + ), + epilog='Made with ♥ by Painless Software, 🄯 Peter Bittner.', + ) + + parser.add_argument('--version', action='version', version=__version__) + parser.add_argument( + 'directory', + nargs='+', + help='directory tree to traverse for bytecode and debris', + ) + parser.add_argument( + '-d', + '--debris', + metavar='TOPIC', + action='extend', + nargs='*', + default=argparse.SUPPRESS, + choices=debris_choices, + help='remove leftovers from popular Python development tools' + ' (may be specified multiple times; optional: all %s; default: %s)' + % ( + ' '.join(debris_optional_topics), + ' '.join(debris_default_topics), + ), + ) + parser.add_argument( + '-e', + '--erase', + metavar='PATTERN', + action='extend', + nargs='+', + default=[], + help='delete files or folders matching a globbing pattern (may be specified' + ' multiple times); this will be interactive unless --yes is used.', + ) + parser.add_argument( + '-f', + '--folders', + action='store_true', + help='remove empty directories', + ) + parser.add_argument( + '-g', + '--git-clean', + action='store_true', + default=False, + help='run git clean to remove untracked files; this will be interactive' + ' unless --yes is used.', + ) + parser.add_argument( + '-i', + '--ignore', + metavar='DIRECTORY', + action='extend', + nargs='+', + default=None, + help='directory that should be ignored (may be specified multiple times;' + ' default: %s)' % ' '.join(ignore_default_items), + ) + parser.add_argument( + '-n', + '--dry-run', + action='store_true', + help='show what would be done', + ) + + verbosity = parser.add_mutually_exclusive_group() + verbosity.add_argument('-q', '--quiet', action='store_true', help='be quiet') + verbosity.add_argument( + '-v', + '--verbose', + action='store_true', + help='be more verbose', + ) + + parser.add_argument( + '-y', + '--yes', + action='store_true', + help='assume yes as answer for interactive questions', + ) + + args = parser.parse_args() + init_logging(args) + + if args.git_clean and not shutil.which('git'): + parser.error('Git is not available. Install Git to use --git-clean.') + + if args.yes and not args.erase and not args.git_clean: + parser.error('Specifying --yes only makes sense with --erase or --git-clean.') + + args.debris_without_topics = 'debris' in args and not args.debris + if 'debris' in args: + if 'all' in args.debris: + args.debris = debris_default_topics + debris_optional_topics + elif not args.debris: + args.debris = debris_default_topics + log.debug('Debris topics to scan for: %s', ' '.join(args.debris)) + 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 + + +def init_logging(args): + """ + Set the log level according to the -v/-q command line options. + """ + log_level = ( + logging.FATAL if args.quiet else logging.DEBUG if args.verbose else logging.INFO + ) + log_format = '%(message)s' + logging.basicConfig(level=log_level, format=log_format) + + +def main(): + """ + Entry point for CLI application. + """ + args = parse_arguments() + + try: + main_module.pyclean(args) + except Exception as err: + raise SystemExit(err) + except KeyboardInterrupt: + msg = 'Aborted by user.' + raise SystemExit(msg) diff --git a/pyclean/debris.py b/pyclean/debris.py index 96a077f..d6f4882 100644 --- a/pyclean/debris.py +++ b/pyclean/debris.py @@ -1,173 +1,173 @@ -# SPDX-FileCopyrightText: 2020 Peter Bittner -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Tool-specific artifact cleanup and debris detection (to suggest option usage).""" - -import logging -import os -from pathlib import Path - -from .erase import delete_filesystem_objects -from .ignore import should_ignore -from .runner import Runner - -log = logging.getLogger(__name__) - -DEBRIS_TOPICS = { - 'cache': [ - '.cache/**/*', - '.cache/', - ], - 'complexipy': [ - '.complexipy_cache/**/*', - '.complexipy_cache/', - ], - 'coverage': [ - '.coverage', - 'coverage.json', - 'coverage.lcov', - 'coverage.xml', - 'htmlcov/**/*', - 'htmlcov/', - ], - 'jupyter': [ - '.ipynb_checkpoints/**/*', - '.ipynb_checkpoints/', - ], - 'mypy': [ - '.mypy_cache/**/*', - '.mypy_cache/', - ], - 'package': [ - 'build/bdist.*/**/*', - 'build/bdist.*/', - 'build/lib/**/*', - 'build/lib/', - 'build/', - 'dist/**/*', - 'dist/', - 'sdist/**/*', - 'sdist/', - '*.egg-info/**/*', - '*.egg-info/', - ], - 'pyright': [ - '.pyright-app-cache-*/**/*', - '.pyright-app-cache-*/', - '.pyright-stubs-*/**/*', - '.pyright-stubs-*/', - '.pyright/', - ], - 'pytest': [ - '.pytest_cache/**/*', - '.pytest_cache/', - 'pytestdebug.log', - ], - 'ruff': [ - '.ruff_cache/**/*', - '.ruff_cache/', - ], - 'tox': [ - '.tox/**/*', - '.tox/', - ], -} - - -def remove_debris_for(topic, directory): - """ - Clean up debris for a specific topic. - """ - log.debug('Scanning for debris of %s ...', topic.title()) - - patterns = DEBRIS_TOPICS[topic] - recursive_delete_debris(directory, patterns) - - -def recursive_delete_debris(directory: Path, patterns: list[str]): - """ - Recursively delete debris matching any of the given patterns. - - This function walks the directory tree once and applies all patterns - at each level, avoiding redundant directory scans. - """ - for pattern in patterns: - delete_filesystem_objects(directory, pattern) - - try: - subdirs = [entry for entry in os.scandir(directory) if entry.is_dir()] - except (OSError, PermissionError) as err: - log.warning('Cannot access directory %s: %s', directory, err) - return - - for subdir in subdirs: - if should_ignore(subdir.path, Runner.ignore): - log.debug('Skipping %s', subdir.name) - else: - recursive_delete_debris(Path(subdir.path), patterns) - - -def detect_debris_in_directory(directory): - """ - Scan a directory for debris artifacts and return a list of detected topics. - """ - detected_topics = [] - - for topic, patterns in DEBRIS_TOPICS.items(): - for pattern in patterns: - if '**' in pattern: - continue - matches = list(directory.glob(pattern)) - if matches: - detected_topics.append(topic) - break - - return detected_topics - - -def suggest_debris_option(args): - """ - Suggest using the --debris option when it wasn't used. - Optionally provide targeted suggestions based on detected artifacts. - """ - all_detected = set() - for dir_name in args.directory: - dir_path = Path(dir_name) - if dir_path.exists(): - detected = detect_debris_in_directory(dir_path) - all_detected.update(detected) - - if all_detected: - topics_str = ' '.join(sorted(all_detected)) - log.info( - 'Hint: Use --debris to also clean up build artifacts. Detected: %s', - topics_str, - ) - else: - log.info( - 'Hint: Use --debris to also clean up build artifacts ' - 'from common Python development tools.', - ) - - -def suggest_optional_debris_topics(args): - """Suggest optional topics when --debris was used without topic arguments.""" - detected_topics = set() - for dir_name in args.directory: - dir_path = Path(dir_name) - if dir_path.exists(): - detected_topics.update(detect_debris_in_directory(dir_path)) - - optional_detected = sorted(detected_topics - set(args.debris)) - if optional_detected: - log.info( - 'Hint: Use --debris all or specify optional topics to clean up ' - 'more aggressively. Detected: %s', - ' '.join(optional_detected), - ) - else: - log.info( - 'Hint: Use --debris all or specify optional topics to clean up ' - 'more aggressively.', - ) +# SPDX-FileCopyrightText: 2020 Peter Bittner +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tool-specific artifact cleanup and debris detection (to suggest option usage).""" + +import logging +import os +from pathlib import Path + +from .erase import delete_filesystem_objects +from .ignore import should_ignore +from .runner import Runner + +log = logging.getLogger(__name__) + +DEBRIS_TOPICS = { + 'cache': [ + '.cache/**/*', + '.cache/', + ], + 'complexipy': [ + '.complexipy_cache/**/*', + '.complexipy_cache/', + ], + 'coverage': [ + '.coverage', + 'coverage.json', + 'coverage.lcov', + 'coverage.xml', + 'htmlcov/**/*', + 'htmlcov/', + ], + 'jupyter': [ + '.ipynb_checkpoints/**/*', + '.ipynb_checkpoints/', + ], + 'mypy': [ + '.mypy_cache/**/*', + '.mypy_cache/', + ], + 'package': [ + 'build/bdist.*/**/*', + 'build/bdist.*/', + 'build/lib/**/*', + 'build/lib/', + 'build/', + 'dist/**/*', + 'dist/', + 'sdist/**/*', + 'sdist/', + '*.egg-info/**/*', + '*.egg-info/', + ], + 'pyright': [ + '.pyright-app-cache-*/**/*', + '.pyright-app-cache-*/', + '.pyright-stubs-*/**/*', + '.pyright-stubs-*/', + '.pyright/', + ], + 'pytest': [ + '.pytest_cache/**/*', + '.pytest_cache/', + 'pytestdebug.log', + ], + 'ruff': [ + '.ruff_cache/**/*', + '.ruff_cache/', + ], + 'tox': [ + '.tox/**/*', + '.tox/', + ], +} + + +def remove_debris_for(topic, directory): + """ + Clean up debris for a specific topic. + """ + log.debug('Scanning for debris of %s ...', topic.title()) + + patterns = DEBRIS_TOPICS[topic] + recursive_delete_debris(directory, patterns) + + +def recursive_delete_debris(directory: Path, patterns: list[str]): + """ + Recursively delete debris matching any of the given patterns. + + This function walks the directory tree once and applies all patterns + at each level, avoiding redundant directory scans. + """ + for pattern in patterns: + delete_filesystem_objects(directory, pattern) + + try: + subdirs = [entry for entry in os.scandir(directory) if entry.is_dir()] + except (OSError, PermissionError) as err: + log.warning('Cannot access directory %s: %s', directory, err) + return + + for subdir in subdirs: + if should_ignore(subdir.path, Runner.ignore): + log.debug('Skipping %s', subdir.name) + else: + recursive_delete_debris(Path(subdir.path), patterns) + + +def detect_debris_in_directory(directory): + """ + Scan a directory for debris artifacts and return a list of detected topics. + """ + detected_topics = [] + + for topic, patterns in DEBRIS_TOPICS.items(): + for pattern in patterns: + if '**' in pattern: + continue + matches = list(directory.glob(pattern)) + if matches: + detected_topics.append(topic) + break + + return detected_topics + + +def suggest_debris_option(args): + """ + Suggest using the --debris option when it wasn't used. + Optionally provide targeted suggestions based on detected artifacts. + """ + all_detected = set() + for dir_name in args.directory: + dir_path = Path(dir_name) + if dir_path.exists(): + detected = detect_debris_in_directory(dir_path) + all_detected.update(detected) + + if all_detected: + topics_str = ' '.join(sorted(all_detected)) + log.info( + 'Hint: Use --debris to also clean up build artifacts. Detected: %s', + topics_str, + ) + else: + log.info( + 'Hint: Use --debris to also clean up build artifacts ' + 'from common Python development tools.', + ) + + +def suggest_optional_debris_topics(args): + """Suggest optional topics when --debris was used without topic arguments.""" + detected_topics = set() + for dir_name in args.directory: + dir_path = Path(dir_name) + if dir_path.exists(): + detected_topics.update(detect_debris_in_directory(dir_path)) + + optional_detected = sorted(detected_topics - set(args.debris)) + if optional_detected: + log.info( + 'Hint: Use --debris all or specify optional topics to clean up ' + 'more aggressively. Detected: %s', + ' '.join(optional_detected), + ) + else: + log.info( + 'Hint: Use --debris all or specify optional topics to clean up ' + 'more aggressively.', + ) diff --git a/pyclean/main.py b/pyclean/main.py index 9a7b9da..e094a1f 100644 --- a/pyclean/main.py +++ b/pyclean/main.py @@ -1,75 +1,75 @@ -# SPDX-FileCopyrightText: 2020 Peter Bittner -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Main orchestration of the pyclean cleanup process.""" - -import logging -from pathlib import Path - -from .bytecode import BYTECODE_DIRS, BYTECODE_FILES -from .debris import ( - remove_debris_for, - suggest_debris_option, - suggest_optional_debris_topics, -) -from .erase import remove_freeform_targets -from .folders import remove_empty_directories -from .gitclean import execute_git_clean -from .runner import Runner -from .traversal import descend_and_clean - -log = logging.getLogger(__name__) - - -def pyclean(args): - """Cross-platform cleaning of Python bytecode.""" - Runner.configure(args) - - for dir_name in args.directory: - dir_path = Path(dir_name) - - log.info('Cleaning directory %s', dir_path) - descend_and_clean(dir_path, BYTECODE_FILES, BYTECODE_DIRS) - - for topic in args.debris: - remove_debris_for(topic, dir_path) - - 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...') - remove_empty_directories(dir_path) - - if args.git_clean: - execute_git_clean(dir_path, args) - - git_clean_note = ' (Not counting git clean)' if args.git_clean else '' - - log.info( - 'Total %d files, %d directories %s.%s', - Runner.unlink_count, - Runner.rmdir_count, - 'would be removed' if args.dry_run else 'removed', - git_clean_note, - ) - - if Runner.unlink_failed or Runner.rmdir_failed: - log.debug( - '%d files, %d directories %s not be removed.%s', - Runner.unlink_failed, - Runner.rmdir_failed, - 'would' if args.dry_run else 'could', - git_clean_note, - ) - - if not args.debris: - suggest_debris_option(args) - elif getattr(args, 'debris_without_topics', False): - suggest_optional_debris_topics(args) +# SPDX-FileCopyrightText: 2020 Peter Bittner +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Main orchestration of the pyclean cleanup process.""" + +import logging +from pathlib import Path + +from .bytecode import BYTECODE_DIRS, BYTECODE_FILES +from .debris import ( + remove_debris_for, + suggest_debris_option, + suggest_optional_debris_topics, +) +from .erase import remove_freeform_targets +from .folders import remove_empty_directories +from .gitclean import execute_git_clean +from .runner import Runner +from .traversal import descend_and_clean + +log = logging.getLogger(__name__) + + +def pyclean(args): + """Cross-platform cleaning of Python bytecode.""" + Runner.configure(args) + + for dir_name in args.directory: + dir_path = Path(dir_name) + + log.info('Cleaning directory %s', dir_path) + descend_and_clean(dir_path, BYTECODE_FILES, BYTECODE_DIRS) + + for topic in args.debris: + remove_debris_for(topic, dir_path) + + 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...') + remove_empty_directories(dir_path) + + if args.git_clean: + execute_git_clean(dir_path, args) + + git_clean_note = ' (Not counting git clean)' if args.git_clean else '' + + log.info( + 'Total %d files, %d directories %s.%s', + Runner.unlink_count, + Runner.rmdir_count, + 'would be removed' if args.dry_run else 'removed', + git_clean_note, + ) + + if Runner.unlink_failed or Runner.rmdir_failed: + log.debug( + '%d files, %d directories %s not be removed.%s', + Runner.unlink_failed, + Runner.rmdir_failed, + 'would' if args.dry_run else 'could', + git_clean_note, + ) + + if not args.debris: + suggest_debris_option(args) + elif getattr(args, 'debris_without_topics', False): + suggest_optional_debris_topics(args) From 728a0b9e4e194979003920b142d2aaa359c0ca62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=28M=29=C2=B2?= <131779342+Kaemiemi@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:09:14 -0300 Subject: [PATCH 3/3] Test optional debris hints --- tests/test_cli.py | 2 ++ tests/test_debris.py | 51 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8333663..73ca526 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -145,6 +145,7 @@ def test_debris_default_args(): args = pyclean.cli.parse_arguments() assert args.debris == ['cache', 'coverage', 'package', 'pytest', 'ruff'] + assert args.debris_without_topics def test_debris_optional_args(): @@ -192,6 +193,7 @@ def test_debris_explicit_args(): args = pyclean.cli.parse_arguments() assert args.debris == ['package', 'pytest'] + assert not args.debris_without_topics def test_debris_invalid_args(): diff --git a/tests/test_debris.py b/tests/test_debris.py index 5baa565..af2e8a0 100644 --- a/tests/test_debris.py +++ b/tests/test_debris.py @@ -184,14 +184,59 @@ def test_suggest_debris_with_artifacts(mock_descend, mock_log): @patch('pyclean.main.descend_and_clean') def test_no_suggest_debris_when_used(mock_descend, mock_log): """ - Does pyclean NOT suggest --debris when it's already used? + Does pyclean avoid suggesting the base --debris option when it's already used? """ with TemporaryDirectory() as tmp, ArgvContext('pyclean', tmp, '--debris'): pyclean.cli.main() - # Check that the suggestion was NOT logged + # The base hint is replaced by the optional-topics hint. log_info_calls = [str(call) for call in mock_log.info.call_args_list] - assert not any('Hint: Use --debris' in msg for msg in log_info_calls) + assert not any( + 'Hint: Use --debris to also clean up' in msg for msg in log_info_calls + ) + assert any('Hint: Use --debris all' in msg for msg in log_info_calls) + + +@pytest.mark.parametrize('option', ['-d', '--debris']) +@patch('pyclean.debris.log') +def test_suggest_optional_debris_topics(mock_log, option): + """Does bare --debris identify optional topics left behind?""" + with TemporaryDirectory() as tmp: + directory = Path(tmp) + (directory / '.ipynb_checkpoints').mkdir() + (directory / '.mypy_cache').mkdir() + (directory / '.pytest_cache').mkdir() + + with ArgvContext('pyclean', tmp, option): + pyclean.cli.main() + + mock_log.info.assert_called_once_with( + 'Hint: Use --debris all or specify optional topics to clean up ' + 'more aggressively. Detected: %s', + 'jupyter mypy', + ) + + +@patch('pyclean.debris.log') +def test_suggest_optional_debris_topics_without_artifacts(mock_log): + """Does bare --debris still explain how to include optional topics?""" + with TemporaryDirectory() as tmp, ArgvContext('pyclean', tmp, '--debris'): + pyclean.cli.main() + + mock_log.info.assert_called_once_with( + 'Hint: Use --debris all or specify optional topics to clean up ' + 'more aggressively.', + ) + + +@pytest.mark.parametrize('topic', ['mypy', 'all']) +@patch('pyclean.debris.log') +def test_no_optional_hint_with_explicit_debris_topics(mock_log, topic): + """Does an explicit choice suppress the optional topic hint?""" + with TemporaryDirectory() as tmp, ArgvContext('pyclean', tmp, '-d', topic): + pyclean.cli.main() + + mock_log.info.assert_not_called() def test_ignore_with_debris_cleanup():