Summary
pyclean DIR --erase '**/*' --yes removes nothing — exit 0, Total 0 files, 0 directories removed — when DIR is nested under a directory whose name is in the default --ignore list (.git, .hg, .svn, .tox, .venv, node_modules, venv).
Since remove_freeform_targets's docstring documents dirname/**/* as the intended pattern for recursive deletion, this is a silent no-op where a full wipe is reasonably expected.
Reproduction
$ mkdir -p .tox/generated/django && touch .tox/generated/django/pyproject.toml
$ pyclean .tox/generated/django --erase '**/*' --yes -v
Ignored directories: .git .hg .svn .tox .venv node_modules venv
Cleaning directory .tox/generated/django
...
Total 0 files, 0 directories removed.
Nothing under the explicitly-named .tox/generated/django is deleted.
Real-world impact
Hit in the painless-cicd-django-app Copier template. Its tox setup generates example projects into {toxworkdir}/generated/<stack>/ (i.e. under .tox/), and a clean env is meant to wipe them — tox.ini L65-68:
[testenv:clean]
description = Remove generated project(s)
deps = pyclean
commands = pyclean {toxworkdir}/generated/{posargs} --erase **/* --yes
Because {toxworkdir} is .tox, every erase match has .tox as an ancestor, so tox -e clean silently stopped removing anything — it reports success but leaves all generated stacks in place.
Root cause
pyclean/erase.py::delete_filesystem_objects:
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)]
Runner.is_ignored → ignore.path_is_ignored, which tests the path and all of its ancestors:
return any(should_ignore(str(p), ignore_patterns) for p in [path, *path.parents])
Every match from directory.glob('**/*') has .tox among its ancestors (.tox/generated/django/pyproject.toml → ancestor .tox), so all matches are filtered out. The check considers ancestors above the directory the user named explicitly — components they didn't ask to be matched against and can't avoid.
Expected behaviour
--erase matches should be filtered by a precedence ladder — defaults < directory positional < explicit --ignore:
- The default ignore list stays valid as pyclean's baseline and keeps governing the bytecode/debris traversal as before.
- A
directory positional is an explicit instruction to traverse and act on that directory, so the default ignore patterns must not block --erase from deleting the named directory, its ancestors, or anything inside it.
- Patterns passed explicitly via
--ignore take precedence and are applied on top.
Resulting behaviour:
| Command |
Result |
pyclean DIR --erase '**/*' |
deletes everything inside DIR |
pyclean DIR --erase '**/*' --ignore FOO |
deletes everything inside DIR except FOO entries |
pyclean DIR --erase '**/*' --ignore DIR |
no-op — deletes nothing |
pyclean DIR --erase '**/*' with no explicit --ignore should traverse DIR for better or worse and delete everything therein.
Implementation considerations
The default ignore list must keep applying to the traversal in every run: bytecode cleanup (descend_and_clean, runs unconditionally) and --debris (recursive_delete_debris) both consult Runner.ignore, so dropping the defaults there would make pyclean descend into .git/.tox/etc.
So the explicit-vs-default distinction must be tracked separately, not by replacing the list:
- Give
--ignore default=None and derive explicit_ignore = args.ignore or []. (Today --ignore uses action='extend' with default=ignore_default_items, so a user value is appended to the defaults and the two are indistinguishable.)
- The traversal ignore list stays
ignore_default_items + explicit_ignore — defaults always on, exactly as today; an explicit --ignore only adds to them.
--erase filtering uses only explicit_ignore: empty ⇒ no filtering (delete everything matched inside DIR); non-empty ⇒ filter by those patterns.
A naive default=None where an explicit --ignore replaces the defaults would regress the traversal — e.g. pyclean DIR --debris --ignore FOO would start walking .git/.tox.
Environment
pyclean 3.6.0 · Python 3.14.4
Summary
pyclean DIR --erase '**/*' --yesremoves nothing — exit 0,Total 0 files, 0 directories removed— whenDIRis nested under a directory whose name is in the default--ignorelist (.git,.hg,.svn,.tox,.venv,node_modules,venv).Since
remove_freeform_targets's docstring documentsdirname/**/*as the intended pattern for recursive deletion, this is a silent no-op where a full wipe is reasonably expected.Reproduction
Nothing under the explicitly-named
.tox/generated/djangois deleted.Real-world impact
Hit in the
painless-cicd-django-appCopier template. Its tox setup generates example projects into{toxworkdir}/generated/<stack>/(i.e. under.tox/), and acleanenv is meant to wipe them —tox.iniL65-68:Because
{toxworkdir}is.tox, every erase match has.toxas an ancestor, sotox -e cleansilently stopped removing anything — it reports success but leaves all generated stacks in place.Root cause
pyclean/erase.py::delete_filesystem_objects:Runner.is_ignored→ignore.path_is_ignored, which tests the path and all of its ancestors:Every match from
directory.glob('**/*')has.toxamong its ancestors (.tox/generated/django/pyproject.toml→ ancestor.tox), so all matches are filtered out. The check considers ancestors above the directory the user named explicitly — components they didn't ask to be matched against and can't avoid.Expected behaviour
--erasematches should be filtered by a precedence ladder — defaults <directorypositional < explicit--ignore:directorypositional is an explicit instruction to traverse and act on that directory, so the default ignore patterns must not block--erasefrom deleting the named directory, its ancestors, or anything inside it.--ignoretake precedence and are applied on top.Resulting behaviour:
pyclean DIR --erase '**/*'DIRpyclean DIR --erase '**/*' --ignore FOODIRexceptFOOentriespyclean DIR --erase '**/*' --ignore DIRpyclean DIR --erase '**/*'with no explicit--ignoreshould traverseDIRfor better or worse and delete everything therein.Implementation considerations
The default ignore list must keep applying to the traversal in every run: bytecode cleanup (
descend_and_clean, runs unconditionally) and--debris(recursive_delete_debris) both consultRunner.ignore, so dropping the defaults there would make pyclean descend into.git/.tox/etc.So the explicit-vs-default distinction must be tracked separately, not by replacing the list:
--ignoredefault=Noneand deriveexplicit_ignore = args.ignore or []. (Today--ignoreusesaction='extend'withdefault=ignore_default_items, so a user value is appended to the defaults and the two are indistinguishable.)ignore_default_items + explicit_ignore— defaults always on, exactly as today; an explicit--ignoreonly adds to them.--erasefiltering uses onlyexplicit_ignore: empty ⇒ no filtering (delete everything matched insideDIR); non-empty ⇒ filter by those patterns.A naive
default=Nonewhere an explicit--ignorereplaces the defaults would regress the traversal — e.g.pyclean DIR --debris --ignore FOOwould start walking.git/.tox.Environment
pyclean 3.6.0 · Python 3.14.4