stifle 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stifle-1.0.0/.gitignore +5 -0
- stifle-1.0.0/LICENSE +21 -0
- stifle-1.0.0/PKG-INFO +254 -0
- stifle-1.0.0/README.md +235 -0
- stifle-1.0.0/pyproject.toml +89 -0
- stifle-1.0.0/src/stifle/__init__.py +38 -0
- stifle-1.0.0/src/stifle/__main__.py +4 -0
- stifle-1.0.0/src/stifle/_cli.py +661 -0
- stifle-1.0.0/src/stifle/_core.py +455 -0
- stifle-1.0.0/src/stifle/py.typed +0 -0
- stifle-1.0.0/tests/test_core.py +1070 -0
- stifle-1.0.0/tests/test_corpus.py +82 -0
- stifle-1.0.0/uv.lock +210 -0
stifle-1.0.0/.gitignore
ADDED
stifle-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 stifle contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
stifle-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: stifle
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Strip comments (and optionally docstrings) from Python code — fast, and provably incapable of touching anything else.
|
|
5
|
+
Project-URL: Repository, https://github.com/byzantime/stifle
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: cleanup,comments,docstrings,source,strip
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# stifle
|
|
21
|
+
|
|
22
|
+
Delete comments — and optionally docstrings — from a Python codebase.
|
|
23
|
+
Fast, and built so it provably cannot touch anything the program can see.
|
|
24
|
+
|
|
25
|
+
```console
|
|
26
|
+
$ stifle format path/to/project # strip comments and orphan strings, in place
|
|
27
|
+
$ stifle check src/ # CI gate: exit 1 if anything would change
|
|
28
|
+
$ stifle check --diff --delete docstrings pkg/ # preview without writing
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Zero runtime dependencies, pure stdlib, Python 3.12+.
|
|
32
|
+
|
|
33
|
+
## What gets deleted
|
|
34
|
+
|
|
35
|
+
Deletions are selected per category with `--delete CAT` (repeatable) and
|
|
36
|
+
subtracted from with `--skip CAT` (repeatable):
|
|
37
|
+
|
|
38
|
+
| Category | Meaning |
|
|
39
|
+
|---|---|
|
|
40
|
+
| own-line | comments that occupy their own line |
|
|
41
|
+
| trailing | comments after code on the same line |
|
|
42
|
+
| orphan-strings | a bare string statement after an assignment |
|
|
43
|
+
| docstrings | module/class/function docstrings |
|
|
44
|
+
|
|
45
|
+
The default is equivalent to
|
|
46
|
+
`--delete own-line --delete trailing --delete orphan-strings`; docstrings
|
|
47
|
+
are only ever deleted when explicitly selected. `--delete` replaces the
|
|
48
|
+
default selection entirely (ruff select-style); `--skip` keeps a category
|
|
49
|
+
despite the selection.
|
|
50
|
+
|
|
51
|
+
When docstrings are selected, a class or function whose body is *only* a
|
|
52
|
+
docstring gets a `pass` at the same indentation so the code still parses.
|
|
53
|
+
A docstring that shares its line with other code (`def f(): "doc"`) is
|
|
54
|
+
conservatively kept.
|
|
55
|
+
|
|
56
|
+
### Orphan strings
|
|
57
|
+
|
|
58
|
+
An orphan string is a string literal sitting on its own as a statement
|
|
59
|
+
after an assignment — a comment wearing a docstring's clothes:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
RETIRING = frozenset({DONE, CLOSED})
|
|
63
|
+
"""Which statuses retire a task. <- deleted: not a docstring
|
|
64
|
+
|
|
65
|
+
Prose that a docstring-length cap would have caught, parked one
|
|
66
|
+
statement below the only place it could have been enforced."""
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Python evaluates and discards it, so removing it cannot change the
|
|
70
|
+
program. A real docstring is `body[0]`, so no assignment can precede it
|
|
71
|
+
and none of them match. Indentation is no escape: the rule applies inside
|
|
72
|
+
`if`/`for`/`while`/`with`/`try` bodies too, and to a whole run of strings,
|
|
73
|
+
not just the first. A string sharing its line with code (`x = 1; "doc"`)
|
|
74
|
+
is left alone, since deletion works by whole lines.
|
|
75
|
+
|
|
76
|
+
**This deletes PEP 224 / Sphinx `autodoc` attribute docstrings**, which
|
|
77
|
+
use exactly the same syntax and are indistinguishable from the pattern
|
|
78
|
+
above. If your package documents module or class attributes that way, opt
|
|
79
|
+
out with `--skip orphan-strings` or `skip = ["orphan-strings"]`.
|
|
80
|
+
|
|
81
|
+
Selecting this category (or `docstrings`) means the file must *parse*, not
|
|
82
|
+
merely tokenize: a source that the running interpreter's `ast` rejects —
|
|
83
|
+
one using newer syntax, say — is skipped and reported rather than
|
|
84
|
+
comment-stripped.
|
|
85
|
+
|
|
86
|
+
## What is always preserved
|
|
87
|
+
|
|
88
|
+
- **Shebang** (`#!...` on line 1) and **PEP 263 coding declarations**
|
|
89
|
+
(lines 1–2) — deleting these can break execution or the file's encoding,
|
|
90
|
+
so they survive every selection, even `--no-default-keeps`.
|
|
91
|
+
- **Tool pragmas**: comments starting with `# noqa`, `# fmt:`,
|
|
92
|
+
`# isort:`, `# ruff:`, `# mypy:`, `# type:`, `# pyright:`, `# pragma:`
|
|
93
|
+
(case-insensitive, flexible spacing). Pass `--no-default-keeps` if you
|
|
94
|
+
really do want "literally all comments".
|
|
95
|
+
- Anything matching your own `--keep REGEX` (matched against the comment
|
|
96
|
+
text, including the `#`).
|
|
97
|
+
|
|
98
|
+
## The safety guarantee
|
|
99
|
+
|
|
100
|
+
`stifle` never regenerates code. The only edits it makes are deleting whole
|
|
101
|
+
physical lines and cutting a line at the start column of a trailing comment,
|
|
102
|
+
so every surviving line is byte-for-byte identical to the input — formatting,
|
|
103
|
+
quotes, escapes, encodings, BOMs and CRLF line endings included.
|
|
104
|
+
|
|
105
|
+
On top of that, every rewrite must pass an independent verification gate
|
|
106
|
+
before the file is touched:
|
|
107
|
+
|
|
108
|
+
- **Comment deletions** re-tokenize input and output and require the token
|
|
109
|
+
streams — minus `COMMENT`/`NL` tokens — to be identical. Equal significant
|
|
110
|
+
token streams mean the compiler sees the exact same program.
|
|
111
|
+
- **Docstring and orphan-string deletions** parse both sides and require the
|
|
112
|
+
ASTs to match after the removed string statements are normalized out of the
|
|
113
|
+
input. An AST cannot see comments, so this path additionally requires every
|
|
114
|
+
comment on the preserve-list to survive. A file from which no string
|
|
115
|
+
statement was actually removed is verified by tokens, as before.
|
|
116
|
+
|
|
117
|
+
Any mismatch, or any internal error at all, leaves the file untouched and is
|
|
118
|
+
reported (exit code 2). Files that cannot be tokenized/parsed, decoded, or
|
|
119
|
+
that use lone-CR line endings are skipped the same way. Writes are atomic
|
|
120
|
+
(temp file + `os.replace`), so a crash can never leave a half-written file.
|
|
121
|
+
|
|
122
|
+
The stdlib of the running interpreter is used as a test corpus: every target
|
|
123
|
+
set must verify and be idempotent on every file.
|
|
124
|
+
|
|
125
|
+
## CLI
|
|
126
|
+
|
|
127
|
+
stifle uses two subcommands, so the invocations you already know from
|
|
128
|
+
`ruff` and `black` do what you'd expect:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
stifle check PATH... [--fix] [--delete CAT]... [--skip CAT]... [options]
|
|
132
|
+
stifle format PATH... [--check] [--delete CAT]... [--skip CAT]... [options]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- `check` reports what would change and never writes; add `--fix`
|
|
136
|
+
(ruff-style) to rewrite in place. Exits 1 if anything would change.
|
|
137
|
+
- `format` (alias `strip`) rewrites in place (black-style); add
|
|
138
|
+
`--check` to only report instead.
|
|
139
|
+
|
|
140
|
+
Shared options:
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
--delete CAT delete this category instead of the default
|
|
144
|
+
(orphan-strings, own-line, trailing); one of
|
|
145
|
+
docstrings, orphan-strings, own-line, trailing;
|
|
146
|
+
repeatable to select several
|
|
147
|
+
--skip CAT keep this category despite the selection (repeatable)
|
|
148
|
+
--diff print unified diffs instead of writing (never writes)
|
|
149
|
+
--max-doc-lines N report docstrings longer than N content lines; exit 1
|
|
150
|
+
if any (composes with every selection)
|
|
151
|
+
--keep REGEX also keep comments matching REGEX (repeatable)
|
|
152
|
+
--default-keeps / --no-default-keeps
|
|
153
|
+
keep the built-in pragma preserve-list (default: true)
|
|
154
|
+
--exclude GLOB skip matching paths (repeatable; matches basename
|
|
155
|
+
or full path)
|
|
156
|
+
--jobs N worker processes (default: CPU count)
|
|
157
|
+
--config PATH read configuration from this pyproject.toml instead
|
|
158
|
+
of discovering one
|
|
159
|
+
--isolated ignore any pyproject.toml configuration
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Running `stifle` without a command is a usage error (exit 2); nothing is
|
|
163
|
+
written.
|
|
164
|
+
|
|
165
|
+
## Configuration
|
|
166
|
+
|
|
167
|
+
`stifle` reads settings from a `[tool.stifle]` table in a `pyproject.toml`,
|
|
168
|
+
the same way `black` and `ruff` do:
|
|
169
|
+
|
|
170
|
+
```toml
|
|
171
|
+
[tool.stifle]
|
|
172
|
+
delete = ["own-line"] # replaces the default (own-line, trailing,
|
|
173
|
+
# orphan-strings)
|
|
174
|
+
skip = ["trailing"] # subtracted from the selection
|
|
175
|
+
keep = ["^# KEEP"] # list of regexes for comments to preserve
|
|
176
|
+
default-keeps = true # built-in pragma preserve-list (# noqa etc.)
|
|
177
|
+
exclude = ["migrations/*"]
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Discovery is black-style: starting from the common ancestor of the input
|
|
181
|
+
paths, stifle walks up until it finds a `pyproject.toml` with a
|
|
182
|
+
`[tool.stifle]` table — stopping at the project root (a directory holding
|
|
183
|
+
`.git` or `.hg`). Pass `--config PATH` to read an explicit file (it may be
|
|
184
|
+
a normal pyproject or carry a bare top-level `[stifle]` table), or
|
|
185
|
+
`--isolated` to ignore configuration entirely.
|
|
186
|
+
|
|
187
|
+
Precedence follows the black convention: **configuration supplies defaults;
|
|
188
|
+
any explicit CLI flag wins outright** (`check`, `format`, `diff`, `jobs` and
|
|
189
|
+
paths are CLI-only and cannot be set in the file).
|
|
190
|
+
|
|
191
|
+
## CI gate
|
|
192
|
+
|
|
193
|
+
```console
|
|
194
|
+
$ stifle check src/
|
|
195
|
+
would strip comments from: src/pkg/mod.py
|
|
196
|
+
stifle: 1 files contain comments stifle would delete.
|
|
197
|
+
stifle: to fix, run: stifle format src/
|
|
198
|
+
stifle: (rewrites in place; run with --diff first to preview the deletions)
|
|
199
|
+
$ echo $?
|
|
200
|
+
1
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Directories are searched recursively for `*.py`; `.git`, `.venv`, `venv`,
|
|
204
|
+
`__pycache__`, `build`, `dist`, `.tox`, `.nox`, `.eggs` and common tool
|
|
205
|
+
caches are skipped. Explicitly named files are processed as-is.
|
|
206
|
+
|
|
207
|
+
Exit codes: `0` success, `1` changes needed (`stifle check` without `--fix`, `stifle format --check`, or docstring
|
|
208
|
+
violations from `--max-doc-lines`), `2` any file skipped or failed (every
|
|
209
|
+
such file is listed on stderr, and left untouched).
|
|
210
|
+
|
|
211
|
+
## Docstring length cap
|
|
212
|
+
|
|
213
|
+
```console
|
|
214
|
+
$ stifle check --max-doc-lines 20 src/ # CI gate for oversized docstrings
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`--max-doc-lines N` reports every module/class/function docstring whose
|
|
218
|
+
content spans more than *N* lines, as
|
|
219
|
+
`path:lineno: docstring of 'name' has M lines (limit N)`. It is a check,
|
|
220
|
+
never a rewrite: docstrings are never modified by this flag, and it composes
|
|
221
|
+
with every selection. The count covers the docstring's own text — interior
|
|
222
|
+
blank lines do not count, the quote-only opening/closing lines do not. Any
|
|
223
|
+
violation makes the exit code 1.
|
|
224
|
+
|
|
225
|
+
The cap only inspects real docstrings (`body[0]`), so prose relocated one
|
|
226
|
+
statement below the thing it describes is invisible to it. That is the hole
|
|
227
|
+
`orphan-strings` closes: such a string is deleted outright rather than
|
|
228
|
+
measured.
|
|
229
|
+
|
|
230
|
+
Performance: files are processed in parallel with a process pool; stripping
|
|
231
|
+
plus verifying runs at roughly 8 MB of source per second per core (the
|
|
232
|
+
whole 15 MB CPython stdlib takes about two seconds on two cores).
|
|
233
|
+
|
|
234
|
+
## Library use
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
from stifle import DOCSTRINGS, strip_source, verify
|
|
238
|
+
|
|
239
|
+
stripped = strip_source(
|
|
240
|
+
src, {"own-line", "docstrings"}, keep=re.compile(r"KEEP")
|
|
241
|
+
)
|
|
242
|
+
assert verify(src, stripped, {"own-line", "docstrings"}) # do before persisting
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`strip_source` is pure (`str -> str`) and raises `ValueError` on unknown
|
|
246
|
+
category names and `SyntaxError` / `tokenize.TokenError` on source it
|
|
247
|
+
cannot process. `verify` recomputes equivalence from scratch; treat a
|
|
248
|
+
`False` (or any exception) as "do not use the result".
|
|
249
|
+
|
|
250
|
+
## Development
|
|
251
|
+
|
|
252
|
+
```console
|
|
253
|
+
$ uv run --group dev pytest
|
|
254
|
+
```
|
stifle-1.0.0/README.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# stifle
|
|
2
|
+
|
|
3
|
+
Delete comments — and optionally docstrings — from a Python codebase.
|
|
4
|
+
Fast, and built so it provably cannot touch anything the program can see.
|
|
5
|
+
|
|
6
|
+
```console
|
|
7
|
+
$ stifle format path/to/project # strip comments and orphan strings, in place
|
|
8
|
+
$ stifle check src/ # CI gate: exit 1 if anything would change
|
|
9
|
+
$ stifle check --diff --delete docstrings pkg/ # preview without writing
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Zero runtime dependencies, pure stdlib, Python 3.12+.
|
|
13
|
+
|
|
14
|
+
## What gets deleted
|
|
15
|
+
|
|
16
|
+
Deletions are selected per category with `--delete CAT` (repeatable) and
|
|
17
|
+
subtracted from with `--skip CAT` (repeatable):
|
|
18
|
+
|
|
19
|
+
| Category | Meaning |
|
|
20
|
+
|---|---|
|
|
21
|
+
| own-line | comments that occupy their own line |
|
|
22
|
+
| trailing | comments after code on the same line |
|
|
23
|
+
| orphan-strings | a bare string statement after an assignment |
|
|
24
|
+
| docstrings | module/class/function docstrings |
|
|
25
|
+
|
|
26
|
+
The default is equivalent to
|
|
27
|
+
`--delete own-line --delete trailing --delete orphan-strings`; docstrings
|
|
28
|
+
are only ever deleted when explicitly selected. `--delete` replaces the
|
|
29
|
+
default selection entirely (ruff select-style); `--skip` keeps a category
|
|
30
|
+
despite the selection.
|
|
31
|
+
|
|
32
|
+
When docstrings are selected, a class or function whose body is *only* a
|
|
33
|
+
docstring gets a `pass` at the same indentation so the code still parses.
|
|
34
|
+
A docstring that shares its line with other code (`def f(): "doc"`) is
|
|
35
|
+
conservatively kept.
|
|
36
|
+
|
|
37
|
+
### Orphan strings
|
|
38
|
+
|
|
39
|
+
An orphan string is a string literal sitting on its own as a statement
|
|
40
|
+
after an assignment — a comment wearing a docstring's clothes:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
RETIRING = frozenset({DONE, CLOSED})
|
|
44
|
+
"""Which statuses retire a task. <- deleted: not a docstring
|
|
45
|
+
|
|
46
|
+
Prose that a docstring-length cap would have caught, parked one
|
|
47
|
+
statement below the only place it could have been enforced."""
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Python evaluates and discards it, so removing it cannot change the
|
|
51
|
+
program. A real docstring is `body[0]`, so no assignment can precede it
|
|
52
|
+
and none of them match. Indentation is no escape: the rule applies inside
|
|
53
|
+
`if`/`for`/`while`/`with`/`try` bodies too, and to a whole run of strings,
|
|
54
|
+
not just the first. A string sharing its line with code (`x = 1; "doc"`)
|
|
55
|
+
is left alone, since deletion works by whole lines.
|
|
56
|
+
|
|
57
|
+
**This deletes PEP 224 / Sphinx `autodoc` attribute docstrings**, which
|
|
58
|
+
use exactly the same syntax and are indistinguishable from the pattern
|
|
59
|
+
above. If your package documents module or class attributes that way, opt
|
|
60
|
+
out with `--skip orphan-strings` or `skip = ["orphan-strings"]`.
|
|
61
|
+
|
|
62
|
+
Selecting this category (or `docstrings`) means the file must *parse*, not
|
|
63
|
+
merely tokenize: a source that the running interpreter's `ast` rejects —
|
|
64
|
+
one using newer syntax, say — is skipped and reported rather than
|
|
65
|
+
comment-stripped.
|
|
66
|
+
|
|
67
|
+
## What is always preserved
|
|
68
|
+
|
|
69
|
+
- **Shebang** (`#!...` on line 1) and **PEP 263 coding declarations**
|
|
70
|
+
(lines 1–2) — deleting these can break execution or the file's encoding,
|
|
71
|
+
so they survive every selection, even `--no-default-keeps`.
|
|
72
|
+
- **Tool pragmas**: comments starting with `# noqa`, `# fmt:`,
|
|
73
|
+
`# isort:`, `# ruff:`, `# mypy:`, `# type:`, `# pyright:`, `# pragma:`
|
|
74
|
+
(case-insensitive, flexible spacing). Pass `--no-default-keeps` if you
|
|
75
|
+
really do want "literally all comments".
|
|
76
|
+
- Anything matching your own `--keep REGEX` (matched against the comment
|
|
77
|
+
text, including the `#`).
|
|
78
|
+
|
|
79
|
+
## The safety guarantee
|
|
80
|
+
|
|
81
|
+
`stifle` never regenerates code. The only edits it makes are deleting whole
|
|
82
|
+
physical lines and cutting a line at the start column of a trailing comment,
|
|
83
|
+
so every surviving line is byte-for-byte identical to the input — formatting,
|
|
84
|
+
quotes, escapes, encodings, BOMs and CRLF line endings included.
|
|
85
|
+
|
|
86
|
+
On top of that, every rewrite must pass an independent verification gate
|
|
87
|
+
before the file is touched:
|
|
88
|
+
|
|
89
|
+
- **Comment deletions** re-tokenize input and output and require the token
|
|
90
|
+
streams — minus `COMMENT`/`NL` tokens — to be identical. Equal significant
|
|
91
|
+
token streams mean the compiler sees the exact same program.
|
|
92
|
+
- **Docstring and orphan-string deletions** parse both sides and require the
|
|
93
|
+
ASTs to match after the removed string statements are normalized out of the
|
|
94
|
+
input. An AST cannot see comments, so this path additionally requires every
|
|
95
|
+
comment on the preserve-list to survive. A file from which no string
|
|
96
|
+
statement was actually removed is verified by tokens, as before.
|
|
97
|
+
|
|
98
|
+
Any mismatch, or any internal error at all, leaves the file untouched and is
|
|
99
|
+
reported (exit code 2). Files that cannot be tokenized/parsed, decoded, or
|
|
100
|
+
that use lone-CR line endings are skipped the same way. Writes are atomic
|
|
101
|
+
(temp file + `os.replace`), so a crash can never leave a half-written file.
|
|
102
|
+
|
|
103
|
+
The stdlib of the running interpreter is used as a test corpus: every target
|
|
104
|
+
set must verify and be idempotent on every file.
|
|
105
|
+
|
|
106
|
+
## CLI
|
|
107
|
+
|
|
108
|
+
stifle uses two subcommands, so the invocations you already know from
|
|
109
|
+
`ruff` and `black` do what you'd expect:
|
|
110
|
+
|
|
111
|
+
```text
|
|
112
|
+
stifle check PATH... [--fix] [--delete CAT]... [--skip CAT]... [options]
|
|
113
|
+
stifle format PATH... [--check] [--delete CAT]... [--skip CAT]... [options]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- `check` reports what would change and never writes; add `--fix`
|
|
117
|
+
(ruff-style) to rewrite in place. Exits 1 if anything would change.
|
|
118
|
+
- `format` (alias `strip`) rewrites in place (black-style); add
|
|
119
|
+
`--check` to only report instead.
|
|
120
|
+
|
|
121
|
+
Shared options:
|
|
122
|
+
|
|
123
|
+
```text
|
|
124
|
+
--delete CAT delete this category instead of the default
|
|
125
|
+
(orphan-strings, own-line, trailing); one of
|
|
126
|
+
docstrings, orphan-strings, own-line, trailing;
|
|
127
|
+
repeatable to select several
|
|
128
|
+
--skip CAT keep this category despite the selection (repeatable)
|
|
129
|
+
--diff print unified diffs instead of writing (never writes)
|
|
130
|
+
--max-doc-lines N report docstrings longer than N content lines; exit 1
|
|
131
|
+
if any (composes with every selection)
|
|
132
|
+
--keep REGEX also keep comments matching REGEX (repeatable)
|
|
133
|
+
--default-keeps / --no-default-keeps
|
|
134
|
+
keep the built-in pragma preserve-list (default: true)
|
|
135
|
+
--exclude GLOB skip matching paths (repeatable; matches basename
|
|
136
|
+
or full path)
|
|
137
|
+
--jobs N worker processes (default: CPU count)
|
|
138
|
+
--config PATH read configuration from this pyproject.toml instead
|
|
139
|
+
of discovering one
|
|
140
|
+
--isolated ignore any pyproject.toml configuration
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Running `stifle` without a command is a usage error (exit 2); nothing is
|
|
144
|
+
written.
|
|
145
|
+
|
|
146
|
+
## Configuration
|
|
147
|
+
|
|
148
|
+
`stifle` reads settings from a `[tool.stifle]` table in a `pyproject.toml`,
|
|
149
|
+
the same way `black` and `ruff` do:
|
|
150
|
+
|
|
151
|
+
```toml
|
|
152
|
+
[tool.stifle]
|
|
153
|
+
delete = ["own-line"] # replaces the default (own-line, trailing,
|
|
154
|
+
# orphan-strings)
|
|
155
|
+
skip = ["trailing"] # subtracted from the selection
|
|
156
|
+
keep = ["^# KEEP"] # list of regexes for comments to preserve
|
|
157
|
+
default-keeps = true # built-in pragma preserve-list (# noqa etc.)
|
|
158
|
+
exclude = ["migrations/*"]
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Discovery is black-style: starting from the common ancestor of the input
|
|
162
|
+
paths, stifle walks up until it finds a `pyproject.toml` with a
|
|
163
|
+
`[tool.stifle]` table — stopping at the project root (a directory holding
|
|
164
|
+
`.git` or `.hg`). Pass `--config PATH` to read an explicit file (it may be
|
|
165
|
+
a normal pyproject or carry a bare top-level `[stifle]` table), or
|
|
166
|
+
`--isolated` to ignore configuration entirely.
|
|
167
|
+
|
|
168
|
+
Precedence follows the black convention: **configuration supplies defaults;
|
|
169
|
+
any explicit CLI flag wins outright** (`check`, `format`, `diff`, `jobs` and
|
|
170
|
+
paths are CLI-only and cannot be set in the file).
|
|
171
|
+
|
|
172
|
+
## CI gate
|
|
173
|
+
|
|
174
|
+
```console
|
|
175
|
+
$ stifle check src/
|
|
176
|
+
would strip comments from: src/pkg/mod.py
|
|
177
|
+
stifle: 1 files contain comments stifle would delete.
|
|
178
|
+
stifle: to fix, run: stifle format src/
|
|
179
|
+
stifle: (rewrites in place; run with --diff first to preview the deletions)
|
|
180
|
+
$ echo $?
|
|
181
|
+
1
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Directories are searched recursively for `*.py`; `.git`, `.venv`, `venv`,
|
|
185
|
+
`__pycache__`, `build`, `dist`, `.tox`, `.nox`, `.eggs` and common tool
|
|
186
|
+
caches are skipped. Explicitly named files are processed as-is.
|
|
187
|
+
|
|
188
|
+
Exit codes: `0` success, `1` changes needed (`stifle check` without `--fix`, `stifle format --check`, or docstring
|
|
189
|
+
violations from `--max-doc-lines`), `2` any file skipped or failed (every
|
|
190
|
+
such file is listed on stderr, and left untouched).
|
|
191
|
+
|
|
192
|
+
## Docstring length cap
|
|
193
|
+
|
|
194
|
+
```console
|
|
195
|
+
$ stifle check --max-doc-lines 20 src/ # CI gate for oversized docstrings
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`--max-doc-lines N` reports every module/class/function docstring whose
|
|
199
|
+
content spans more than *N* lines, as
|
|
200
|
+
`path:lineno: docstring of 'name' has M lines (limit N)`. It is a check,
|
|
201
|
+
never a rewrite: docstrings are never modified by this flag, and it composes
|
|
202
|
+
with every selection. The count covers the docstring's own text — interior
|
|
203
|
+
blank lines do not count, the quote-only opening/closing lines do not. Any
|
|
204
|
+
violation makes the exit code 1.
|
|
205
|
+
|
|
206
|
+
The cap only inspects real docstrings (`body[0]`), so prose relocated one
|
|
207
|
+
statement below the thing it describes is invisible to it. That is the hole
|
|
208
|
+
`orphan-strings` closes: such a string is deleted outright rather than
|
|
209
|
+
measured.
|
|
210
|
+
|
|
211
|
+
Performance: files are processed in parallel with a process pool; stripping
|
|
212
|
+
plus verifying runs at roughly 8 MB of source per second per core (the
|
|
213
|
+
whole 15 MB CPython stdlib takes about two seconds on two cores).
|
|
214
|
+
|
|
215
|
+
## Library use
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
from stifle import DOCSTRINGS, strip_source, verify
|
|
219
|
+
|
|
220
|
+
stripped = strip_source(
|
|
221
|
+
src, {"own-line", "docstrings"}, keep=re.compile(r"KEEP")
|
|
222
|
+
)
|
|
223
|
+
assert verify(src, stripped, {"own-line", "docstrings"}) # do before persisting
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`strip_source` is pure (`str -> str`) and raises `ValueError` on unknown
|
|
227
|
+
category names and `SyntaxError` / `tokenize.TokenError` on source it
|
|
228
|
+
cannot process. `verify` recomputes equivalence from scratch; treat a
|
|
229
|
+
`False` (or any exception) as "do not use the result".
|
|
230
|
+
|
|
231
|
+
## Development
|
|
232
|
+
|
|
233
|
+
```console
|
|
234
|
+
$ uv run --group dev pytest
|
|
235
|
+
```
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "stifle"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Strip comments (and optionally docstrings) from Python code — fast, and provably incapable of touching anything else."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = ["comments", "docstrings", "strip", "cleanup", "source"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 5 - Production/Stable",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
22
|
+
]
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/byzantime/stifle"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
stifle = "stifle._cli:main"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = ["pytest>=7", "black", "ruff>=0.4"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/stifle"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
37
|
+
|
|
38
|
+
[tool.black]
|
|
39
|
+
line-length = 80
|
|
40
|
+
target-version = ["py312"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 80
|
|
44
|
+
target-version = "py312"
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
# E/F/I/C90 are the baseline. The rest are named rule-by-rule rather than by
|
|
48
|
+
# family: each one below was measured against this codebase, every finding it
|
|
49
|
+
# had was fixed, and it earns its place by catching a way agent-written code
|
|
50
|
+
# goes wrong that review reliably misses.
|
|
51
|
+
select = [
|
|
52
|
+
"E",
|
|
53
|
+
"F",
|
|
54
|
+
"I",
|
|
55
|
+
"C90",
|
|
56
|
+
# Detached asyncio task nobody holds a reference to. The garbage collector
|
|
57
|
+
# may cancel it mid-flight; this is the same bug class as the post-run write
|
|
58
|
+
# race, and it is invisible until a task silently doesn't happen.
|
|
59
|
+
"RUF006",
|
|
60
|
+
# Closure over a loop variable. Reads correct, runs with the last
|
|
61
|
+
# iteration's value.
|
|
62
|
+
"B023",
|
|
63
|
+
# try/except/pass with no logging, i.e. precisely where a failure needs to
|
|
64
|
+
# be findable.
|
|
65
|
+
"S110",
|
|
66
|
+
# Re-raise inside except without `from`, which drops the original traceback.
|
|
67
|
+
"B904",
|
|
68
|
+
# Module-level global rebound at runtime. In a suite that shares one process
|
|
69
|
+
# across the whole session, this is where cross-test leaks come from.
|
|
70
|
+
"PLW0603",
|
|
71
|
+
# Mutable class attribute with no ClassVar: shared state that reads as a
|
|
72
|
+
# per-instance default.
|
|
73
|
+
"RUF012",
|
|
74
|
+
# zip() with no strict=, so a length mismatch silently truncates.
|
|
75
|
+
"B905",
|
|
76
|
+
# noqa that no longer suppresses anything — a stale claim about the code.
|
|
77
|
+
"RUF100",
|
|
78
|
+
# try/except/pass that contextlib.suppress states more directly.
|
|
79
|
+
"SIM105",
|
|
80
|
+
]
|
|
81
|
+
ignore = ["F402"]
|
|
82
|
+
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint.mccabe]
|
|
85
|
+
max-complexity = 10
|
|
86
|
+
|
|
87
|
+
[tool.ruff.lint.isort]
|
|
88
|
+
known-first-party = ["stifle"]
|
|
89
|
+
force-single-line = true
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""stifle — delete comments (and optionally docstrings) from Python code.
|
|
2
|
+
|
|
3
|
+
Library use::
|
|
4
|
+
|
|
5
|
+
from stifle import ALL_TARGETS, strip_source, verify
|
|
6
|
+
|
|
7
|
+
stripped = strip_source(src, {"own-line"})
|
|
8
|
+
assert verify(src, stripped, {"own-line"})
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from stifle._core import ALL_TARGETS
|
|
12
|
+
from stifle._core import DEFAULT_KEEPS
|
|
13
|
+
from stifle._core import DOCSTRINGS
|
|
14
|
+
from stifle._core import ORPHAN_STRINGS
|
|
15
|
+
from stifle._core import OWN_LINE
|
|
16
|
+
from stifle._core import TARGETS
|
|
17
|
+
from stifle._core import TRAILING
|
|
18
|
+
from stifle._core import DocstringViolation
|
|
19
|
+
from stifle._core import docstring_violations
|
|
20
|
+
from stifle._core import strip_source
|
|
21
|
+
from stifle._core import verify
|
|
22
|
+
|
|
23
|
+
__version__ = "1.0.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"OWN_LINE",
|
|
27
|
+
"TRAILING",
|
|
28
|
+
"DOCSTRINGS",
|
|
29
|
+
"ORPHAN_STRINGS",
|
|
30
|
+
"ALL_TARGETS",
|
|
31
|
+
"TARGETS",
|
|
32
|
+
"strip_source",
|
|
33
|
+
"verify",
|
|
34
|
+
"DEFAULT_KEEPS",
|
|
35
|
+
"DocstringViolation",
|
|
36
|
+
"docstring_violations",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|