stifle 1.0.0__py3-none-any.whl
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/__init__.py +38 -0
- stifle/__main__.py +4 -0
- stifle/_cli.py +661 -0
- stifle/_core.py +455 -0
- stifle/py.typed +0 -0
- stifle-1.0.0.dist-info/METADATA +254 -0
- stifle-1.0.0.dist-info/RECORD +10 -0
- stifle-1.0.0.dist-info/WHEEL +4 -0
- stifle-1.0.0.dist-info/entry_points.txt +2 -0
- stifle-1.0.0.dist-info/licenses/LICENSE +21 -0
stifle/__init__.py
ADDED
|
@@ -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
|
+
]
|
stifle/__main__.py
ADDED
stifle/_cli.py
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
"""Command-line interface: discovery, parallelism, atomic in-place writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import difflib
|
|
8
|
+
import functools
|
|
9
|
+
import io
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import tokenize
|
|
16
|
+
import tomllib
|
|
17
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
18
|
+
from fnmatch import fnmatch
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Iterable
|
|
21
|
+
from typing import Iterator
|
|
22
|
+
from typing import List
|
|
23
|
+
from typing import NamedTuple
|
|
24
|
+
from typing import Optional
|
|
25
|
+
from typing import Pattern
|
|
26
|
+
from typing import Sequence
|
|
27
|
+
from typing import Tuple
|
|
28
|
+
|
|
29
|
+
from stifle._core import ALL_TARGETS
|
|
30
|
+
from stifle._core import TARGETS
|
|
31
|
+
from stifle._core import docstring_violations
|
|
32
|
+
from stifle._core import strip_source
|
|
33
|
+
from stifle._core import verify
|
|
34
|
+
|
|
35
|
+
SKIP_DIRS = frozenset(
|
|
36
|
+
{
|
|
37
|
+
".git",
|
|
38
|
+
".venv",
|
|
39
|
+
"venv",
|
|
40
|
+
"__pycache__",
|
|
41
|
+
"build",
|
|
42
|
+
"dist",
|
|
43
|
+
".tox",
|
|
44
|
+
".nox",
|
|
45
|
+
".eggs",
|
|
46
|
+
".mypy_cache",
|
|
47
|
+
".pytest_cache",
|
|
48
|
+
".ruff_cache",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
SERIAL_THRESHOLD = 20
|
|
53
|
+
|
|
54
|
+
UNCHANGED = "unchanged"
|
|
55
|
+
CHANGED = "changed"
|
|
56
|
+
SKIPPED = "skipped"
|
|
57
|
+
FAILED = "failed"
|
|
58
|
+
|
|
59
|
+
PROJECT_ROOT_MARKERS = frozenset({".git", ".hg"})
|
|
60
|
+
|
|
61
|
+
CONFIG_KEYS = {
|
|
62
|
+
"delete": list,
|
|
63
|
+
"skip": list,
|
|
64
|
+
"keep": list,
|
|
65
|
+
"default-keeps": bool,
|
|
66
|
+
"exclude": list,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_toml(path: Path, parser: argparse.ArgumentParser) -> dict:
|
|
71
|
+
try:
|
|
72
|
+
return tomllib.loads(path.read_text(encoding="utf-8"))
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
parser.error("cannot read %s: %s" % (path, exc))
|
|
75
|
+
except tomllib.TOMLDecodeError as exc:
|
|
76
|
+
parser.error("invalid TOML in %s: %s" % (path, exc))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _explicit_config(
|
|
80
|
+
config_path: Path, parser: argparse.ArgumentParser
|
|
81
|
+
) -> dict:
|
|
82
|
+
"""Read ``[tool.stifle]`` (or a bare top-level ``[stifle]``)."""
|
|
83
|
+
data = _read_toml(config_path, parser)
|
|
84
|
+
table = data.get("stifle", data.get("tool", {}).get("stifle"))
|
|
85
|
+
if table is None:
|
|
86
|
+
return {}
|
|
87
|
+
if not isinstance(table, dict):
|
|
88
|
+
parser.error(
|
|
89
|
+
"%s: [tool.stifle] must be a table, got %r" % (config_path, table)
|
|
90
|
+
)
|
|
91
|
+
return _validate_config(table, str(config_path), parser)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load_config(
|
|
95
|
+
paths: "Sequence[Path]",
|
|
96
|
+
config_path: "Optional[Path]",
|
|
97
|
+
isolated: bool,
|
|
98
|
+
parser: argparse.ArgumentParser,
|
|
99
|
+
) -> dict:
|
|
100
|
+
"""Load ``[tool.stifle]`` settings, black-style.
|
|
101
|
+
|
|
102
|
+
With no explicit *config_path*, walk up from the common ancestor of
|
|
103
|
+
*paths*; the first directory whose ``pyproject.toml`` contains a
|
|
104
|
+
``[tool.stifle]`` table wins. Discovery stops at a project root (a
|
|
105
|
+
directory holding ``.git``/``.hg``) or the filesystem root.
|
|
106
|
+
"""
|
|
107
|
+
if isolated:
|
|
108
|
+
return {}
|
|
109
|
+
if config_path is not None:
|
|
110
|
+
return _explicit_config(config_path, parser)
|
|
111
|
+
try:
|
|
112
|
+
anchor = Path(os.path.commonpath([str(p) for p in paths]))
|
|
113
|
+
except ValueError:
|
|
114
|
+
return {}
|
|
115
|
+
for directory in [anchor, *anchor.parents]:
|
|
116
|
+
candidate = directory / "pyproject.toml"
|
|
117
|
+
if candidate.is_file():
|
|
118
|
+
table = _read_toml(candidate, parser).get("tool", {}).get("stifle")
|
|
119
|
+
if table is not None:
|
|
120
|
+
return _validate_config(table, str(candidate), parser)
|
|
121
|
+
if any((directory / m).exists() for m in PROJECT_ROOT_MARKERS):
|
|
122
|
+
break
|
|
123
|
+
return {}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _validate_list_key(
|
|
127
|
+
key: str, entries: list, source: str, parser: argparse.ArgumentParser
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Every entry of a list-valued key must be a string; ``keep`` entries
|
|
130
|
+
must additionally compile as regexes."""
|
|
131
|
+
for entry in entries:
|
|
132
|
+
if not isinstance(entry, str):
|
|
133
|
+
parser.error(
|
|
134
|
+
"%s: [tool.stifle] %s entries must be strings, got %r"
|
|
135
|
+
% (source, key, entry)
|
|
136
|
+
)
|
|
137
|
+
if key == "keep":
|
|
138
|
+
try:
|
|
139
|
+
re.compile(entry)
|
|
140
|
+
except re.error as exc:
|
|
141
|
+
parser.error(
|
|
142
|
+
"%s: [tool.stifle] keep entry %r is not a valid "
|
|
143
|
+
"regex: %s" % (source, entry, exc)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _validate_config(
|
|
148
|
+
table: dict, source: str, parser: argparse.ArgumentParser
|
|
149
|
+
) -> dict:
|
|
150
|
+
unknown = sorted(set(table) - set(CONFIG_KEYS))
|
|
151
|
+
if unknown:
|
|
152
|
+
parser.error(
|
|
153
|
+
"unknown key%s in [tool.stifle] (%s): %s; valid keys are: %s"
|
|
154
|
+
% (
|
|
155
|
+
"s" if len(unknown) != 1 else "",
|
|
156
|
+
source,
|
|
157
|
+
", ".join(unknown),
|
|
158
|
+
", ".join(sorted(CONFIG_KEYS)),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
for key, value in table.items():
|
|
162
|
+
expected = CONFIG_KEYS[key]
|
|
163
|
+
if not isinstance(value, expected):
|
|
164
|
+
parser.error(
|
|
165
|
+
"%s: [tool.stifle] %s must be a %s, got %r"
|
|
166
|
+
% (
|
|
167
|
+
source,
|
|
168
|
+
key,
|
|
169
|
+
{str: "string", list: "list", bool: "boolean"}[expected],
|
|
170
|
+
value,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
if key in ("keep", "exclude", "delete", "skip"):
|
|
174
|
+
_validate_list_key(key, value, source, parser)
|
|
175
|
+
if key in ("delete", "skip"):
|
|
176
|
+
bad = sorted(set(value) - TARGETS)
|
|
177
|
+
if bad:
|
|
178
|
+
parser.error(
|
|
179
|
+
"%s: [tool.stifle] %s entries must be one of: %s (got %s)"
|
|
180
|
+
% (
|
|
181
|
+
source,
|
|
182
|
+
key,
|
|
183
|
+
", ".join(sorted(TARGETS)),
|
|
184
|
+
", ".join(bad),
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
return table
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Result(NamedTuple):
|
|
191
|
+
path: str
|
|
192
|
+
status: str
|
|
193
|
+
message: "Optional[str]" = None
|
|
194
|
+
diff: "Optional[str]" = None
|
|
195
|
+
violations: "Tuple[str, ...]" = ()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _excluded(path: str, name: str, excludes: "Sequence[str]") -> bool:
|
|
199
|
+
return any(fnmatch(path, g) or fnmatch(name, g) for g in excludes)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _discover(
|
|
203
|
+
paths: "Iterable[Path]", excludes: "Sequence[str]"
|
|
204
|
+
) -> "Tuple[List[str], List[str]]":
|
|
205
|
+
"""Return (python files, missing arguments), sorted and deduplicated."""
|
|
206
|
+
files = set()
|
|
207
|
+
missing = []
|
|
208
|
+
for root in paths:
|
|
209
|
+
if root.is_dir():
|
|
210
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
211
|
+
dirnames[:] = sorted(
|
|
212
|
+
d
|
|
213
|
+
for d in dirnames
|
|
214
|
+
if d not in SKIP_DIRS
|
|
215
|
+
and not _excluded(os.path.join(dirpath, d), d, excludes)
|
|
216
|
+
)
|
|
217
|
+
for name in filenames:
|
|
218
|
+
full = os.path.join(dirpath, name)
|
|
219
|
+
if name.endswith(".py") and not _excluded(
|
|
220
|
+
full, name, excludes
|
|
221
|
+
):
|
|
222
|
+
files.add(full)
|
|
223
|
+
elif root.is_file():
|
|
224
|
+
files.add(str(root))
|
|
225
|
+
else:
|
|
226
|
+
missing.append(str(root))
|
|
227
|
+
return sorted(files), missing
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _atomic_write(path: str, data: bytes) -> None:
|
|
231
|
+
parent, name = os.path.split(os.path.abspath(path))
|
|
232
|
+
fd, tmp = tempfile.mkstemp(
|
|
233
|
+
dir=parent, prefix=name + ".", suffix=".stifle-tmp"
|
|
234
|
+
)
|
|
235
|
+
try:
|
|
236
|
+
with os.fdopen(fd, "wb") as fh:
|
|
237
|
+
fh.write(data)
|
|
238
|
+
with contextlib.suppress(OSError):
|
|
239
|
+
os.chmod(tmp, os.stat(path).st_mode)
|
|
240
|
+
os.replace(tmp, path)
|
|
241
|
+
except BaseException:
|
|
242
|
+
with contextlib.suppress(OSError):
|
|
243
|
+
os.unlink(tmp)
|
|
244
|
+
raise
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class _Config(NamedTuple):
|
|
248
|
+
targets: "Tuple[str, ...]"
|
|
249
|
+
keep: "Optional[str]"
|
|
250
|
+
default_keeps: bool
|
|
251
|
+
write: bool
|
|
252
|
+
want_diff: bool
|
|
253
|
+
max_doc_lines: "Optional[int]" = None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _read_source(path: str) -> "Tuple[str, str]":
|
|
257
|
+
"""Read *path* and decode it per its PEP 263 coding declaration.
|
|
258
|
+
|
|
259
|
+
Raises OSError/SyntaxError/UnicodeDecodeError/LookupError on failure.
|
|
260
|
+
"""
|
|
261
|
+
raw = Path(path).read_bytes()
|
|
262
|
+
encoding, _ = tokenize.detect_encoding(io.BytesIO(raw).readline)
|
|
263
|
+
return raw.decode(encoding), encoding
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _verified(
|
|
267
|
+
src: str, stripped: str, cfg: _Config, keep: "Optional[Pattern[str]]"
|
|
268
|
+
) -> bool:
|
|
269
|
+
"""Whether *stripped* passes the gate. *keep* is the compiled cfg.keep.
|
|
270
|
+
|
|
271
|
+
It must be the very pattern the strip used, or the two sides disagree
|
|
272
|
+
about which comments were deletable.
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
return bool(
|
|
276
|
+
verify(
|
|
277
|
+
src,
|
|
278
|
+
stripped,
|
|
279
|
+
cfg.targets,
|
|
280
|
+
keep=keep,
|
|
281
|
+
default_keeps=cfg.default_keeps,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
except Exception:
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _diff(path: str, src: str, stripped: str) -> str:
|
|
289
|
+
return "".join(
|
|
290
|
+
difflib.unified_diff(
|
|
291
|
+
src.splitlines(keepends=True),
|
|
292
|
+
stripped.splitlines(keepends=True),
|
|
293
|
+
fromfile=path,
|
|
294
|
+
tofile=path,
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _doc_violation_lines(
|
|
300
|
+
cfg: _Config, path: str, src: str
|
|
301
|
+
) -> "Tuple[str, ...]":
|
|
302
|
+
"""Formatted --max-doc-lines violations for *src* (empty when unset)."""
|
|
303
|
+
if cfg.max_doc_lines is None:
|
|
304
|
+
return ()
|
|
305
|
+
vs = docstring_violations(src, cfg.max_doc_lines)
|
|
306
|
+
return tuple(
|
|
307
|
+
"%s:%d: docstring of '%s' has %d lines (limit %d)"
|
|
308
|
+
% (path, v.lineno, v.name, v.lines, cfg.max_doc_lines)
|
|
309
|
+
for v in vs
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _finish(
|
|
314
|
+
cfg: _Config,
|
|
315
|
+
path: str,
|
|
316
|
+
src: str,
|
|
317
|
+
stripped: str,
|
|
318
|
+
encoding: str,
|
|
319
|
+
violations: "Tuple[str, ...]",
|
|
320
|
+
) -> Result:
|
|
321
|
+
"""Diff and/or write *stripped* after verification already passed."""
|
|
322
|
+
diff = None
|
|
323
|
+
if cfg.want_diff:
|
|
324
|
+
diff = _diff(path, src, stripped)
|
|
325
|
+
if cfg.write:
|
|
326
|
+
try:
|
|
327
|
+
_atomic_write(path, stripped.encode(encoding))
|
|
328
|
+
except OSError as exc:
|
|
329
|
+
return Result(
|
|
330
|
+
path, FAILED, "cannot write: %s" % exc, None, violations
|
|
331
|
+
)
|
|
332
|
+
return Result(path, CHANGED, None, diff, violations)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _process_one(cfg: _Config, path: str) -> Result:
|
|
336
|
+
path = os.path.realpath(path)
|
|
337
|
+
try:
|
|
338
|
+
src, encoding = _read_source(path)
|
|
339
|
+
except (OSError, SyntaxError, UnicodeDecodeError, LookupError) as exc:
|
|
340
|
+
return Result(path, SKIPPED, "cannot read: %s" % exc)
|
|
341
|
+
if src.count("\r") != src.count("\r\n"):
|
|
342
|
+
return Result(path, SKIPPED, "lone-CR line endings are not supported")
|
|
343
|
+
keep = re.compile(cfg.keep) if cfg.keep is not None else None
|
|
344
|
+
try:
|
|
345
|
+
violations = _doc_violation_lines(cfg, path, src)
|
|
346
|
+
except SyntaxError as exc:
|
|
347
|
+
return Result(path, SKIPPED, "cannot parse: %s" % exc)
|
|
348
|
+
try:
|
|
349
|
+
stripped = strip_source(
|
|
350
|
+
src, cfg.targets, keep, default_keeps=cfg.default_keeps
|
|
351
|
+
)
|
|
352
|
+
except (SyntaxError, tokenize.TokenError, ValueError) as exc:
|
|
353
|
+
return Result(path, SKIPPED, "cannot parse: %s" % exc, None, violations)
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
return Result(
|
|
356
|
+
path, FAILED, "internal error: %r" % exc, None, violations
|
|
357
|
+
)
|
|
358
|
+
if stripped == src:
|
|
359
|
+
return Result(path, UNCHANGED, None, None, violations)
|
|
360
|
+
if not _verified(src, stripped, cfg, keep):
|
|
361
|
+
return Result(
|
|
362
|
+
path,
|
|
363
|
+
FAILED,
|
|
364
|
+
"verification failed; file left untouched",
|
|
365
|
+
None,
|
|
366
|
+
violations,
|
|
367
|
+
)
|
|
368
|
+
return _finish(cfg, path, src, stripped, encoding, violations)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _run(cfg: _Config, files: "List[str]", jobs: int) -> "Iterator[Result]":
|
|
372
|
+
worker = functools.partial(_process_one, cfg)
|
|
373
|
+
if jobs <= 1 or len(files) < SERIAL_THRESHOLD:
|
|
374
|
+
return map(worker, files)
|
|
375
|
+
|
|
376
|
+
def parallel() -> "Iterator[Result]":
|
|
377
|
+
with ProcessPoolExecutor(max_workers=jobs) as pool:
|
|
378
|
+
chunksize = max(1, len(files) // (jobs * 4))
|
|
379
|
+
for result in pool.map(worker, files, chunksize=chunksize):
|
|
380
|
+
yield result
|
|
381
|
+
|
|
382
|
+
return parallel()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _build_shared_options(parser: argparse.ArgumentParser) -> None:
|
|
386
|
+
parser.add_argument(
|
|
387
|
+
"paths",
|
|
388
|
+
nargs="+",
|
|
389
|
+
type=Path,
|
|
390
|
+
metavar="PATH",
|
|
391
|
+
help="files or directories to strip",
|
|
392
|
+
)
|
|
393
|
+
parser.add_argument(
|
|
394
|
+
"--delete",
|
|
395
|
+
action="append",
|
|
396
|
+
choices=sorted(TARGETS),
|
|
397
|
+
metavar="CAT",
|
|
398
|
+
help="delete this category instead of the default (%s); one of %s, "
|
|
399
|
+
"repeatable to select several"
|
|
400
|
+
% (", ".join(sorted(ALL_TARGETS)), ", ".join(sorted(TARGETS))),
|
|
401
|
+
)
|
|
402
|
+
parser.add_argument(
|
|
403
|
+
"--skip",
|
|
404
|
+
action="append",
|
|
405
|
+
choices=sorted(TARGETS),
|
|
406
|
+
metavar="CAT",
|
|
407
|
+
help="keep this category despite the selection (repeatable)",
|
|
408
|
+
)
|
|
409
|
+
parser.add_argument(
|
|
410
|
+
"--diff",
|
|
411
|
+
action="store_true",
|
|
412
|
+
help="print unified diffs instead of writing",
|
|
413
|
+
)
|
|
414
|
+
parser.add_argument(
|
|
415
|
+
"--max-doc-lines",
|
|
416
|
+
type=int,
|
|
417
|
+
metavar="N",
|
|
418
|
+
help="report docstrings longer than N content lines; exit 1 if any "
|
|
419
|
+
"(composes with every selection)",
|
|
420
|
+
)
|
|
421
|
+
parser.add_argument(
|
|
422
|
+
"--keep",
|
|
423
|
+
metavar="REGEX",
|
|
424
|
+
action="append",
|
|
425
|
+
help="also keep comments matching REGEX (repeatable)",
|
|
426
|
+
)
|
|
427
|
+
parser.add_argument(
|
|
428
|
+
"--default-keeps",
|
|
429
|
+
action=argparse.BooleanOptionalAction,
|
|
430
|
+
help="keep the built-in pragma preserve-list (# noqa / # type: / "
|
|
431
|
+
"# pragma: and friends); shebang and coding lines always survive "
|
|
432
|
+
"(default: true)",
|
|
433
|
+
)
|
|
434
|
+
parser.add_argument(
|
|
435
|
+
"--exclude",
|
|
436
|
+
action="append",
|
|
437
|
+
metavar="GLOB",
|
|
438
|
+
help="skip paths matching this glob (repeatable)",
|
|
439
|
+
)
|
|
440
|
+
parser.add_argument(
|
|
441
|
+
"--config",
|
|
442
|
+
type=Path,
|
|
443
|
+
metavar="PATH",
|
|
444
|
+
help="read configuration from this pyproject.toml instead of "
|
|
445
|
+
"discovering one",
|
|
446
|
+
)
|
|
447
|
+
parser.add_argument(
|
|
448
|
+
"--isolated",
|
|
449
|
+
action="store_true",
|
|
450
|
+
help="ignore any pyproject.toml configuration",
|
|
451
|
+
)
|
|
452
|
+
parser.add_argument(
|
|
453
|
+
"--jobs",
|
|
454
|
+
type=int,
|
|
455
|
+
metavar="N",
|
|
456
|
+
help="worker processes (default: number of CPUs)",
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
461
|
+
parser = argparse.ArgumentParser(
|
|
462
|
+
prog="stifle",
|
|
463
|
+
description="Delete comments (and optionally docstrings) from Python "
|
|
464
|
+
"code. Categories: own-line (a comment alone on its line), trailing "
|
|
465
|
+
"(after code on the same line), orphan-strings (a bare string "
|
|
466
|
+
"statement after an assignment — a docstring that is not one), "
|
|
467
|
+
"docstrings. Default selection: --delete own-line trailing "
|
|
468
|
+
"orphan-strings. Files are rewritten in place, atomically, and only "
|
|
469
|
+
"when the result provably preserves the program; anything that "
|
|
470
|
+
"cannot be proven safe is left untouched and reported.",
|
|
471
|
+
)
|
|
472
|
+
from stifle import __version__
|
|
473
|
+
|
|
474
|
+
parser.add_argument(
|
|
475
|
+
"--version", action="version", version="%(prog)s " + __version__
|
|
476
|
+
)
|
|
477
|
+
parent = argparse.ArgumentParser(add_help=False)
|
|
478
|
+
_build_shared_options(parent)
|
|
479
|
+
subparsers = parser.add_subparsers(
|
|
480
|
+
dest="command", required=True, metavar="COMMAND"
|
|
481
|
+
)
|
|
482
|
+
check = subparsers.add_parser(
|
|
483
|
+
"check",
|
|
484
|
+
parents=[parent],
|
|
485
|
+
help="report what would change, without writing (like ruff check)",
|
|
486
|
+
)
|
|
487
|
+
check.add_argument(
|
|
488
|
+
"--fix",
|
|
489
|
+
action="store_true",
|
|
490
|
+
help="rewrite the offending files in place",
|
|
491
|
+
)
|
|
492
|
+
fmt = subparsers.add_parser(
|
|
493
|
+
"format",
|
|
494
|
+
parents=[parent],
|
|
495
|
+
aliases=["strip"],
|
|
496
|
+
help="strip in place (like black); add --check to only report",
|
|
497
|
+
)
|
|
498
|
+
fmt.add_argument(
|
|
499
|
+
"--check",
|
|
500
|
+
action="store_true",
|
|
501
|
+
help="write nothing; exit 1 if any file would change",
|
|
502
|
+
)
|
|
503
|
+
return parser
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
_VALUE_FLAGS = (
|
|
507
|
+
"--keep",
|
|
508
|
+
"--exclude",
|
|
509
|
+
"--config",
|
|
510
|
+
"--max-doc-lines",
|
|
511
|
+
"--jobs",
|
|
512
|
+
"--delete",
|
|
513
|
+
"--skip",
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _normalise_argv(argv: List[str], parser: argparse.ArgumentParser) -> None:
|
|
518
|
+
"""Hoist the command token so flags before it still parse."""
|
|
519
|
+
if any(a in ("-h", "--help", "--version") for a in argv):
|
|
520
|
+
return
|
|
521
|
+
i = 0
|
|
522
|
+
while i < len(argv):
|
|
523
|
+
arg = argv[i]
|
|
524
|
+
if arg in ("check", "format", "strip"):
|
|
525
|
+
if i != 0:
|
|
526
|
+
argv.insert(0, argv.pop(i))
|
|
527
|
+
return
|
|
528
|
+
i += 2 if arg in _VALUE_FLAGS else 1
|
|
529
|
+
parser.error(
|
|
530
|
+
"no command given; use `stifle check PATH` to report or "
|
|
531
|
+
"`stifle format PATH` to rewrite in place"
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _report(
|
|
536
|
+
result: Result,
|
|
537
|
+
ns: argparse.Namespace,
|
|
538
|
+
counts: "dict",
|
|
539
|
+
checking: bool,
|
|
540
|
+
) -> None:
|
|
541
|
+
counts[result.status] += 1
|
|
542
|
+
if result.violations:
|
|
543
|
+
counts["violations"] += len(result.violations)
|
|
544
|
+
for line in result.violations:
|
|
545
|
+
print(line)
|
|
546
|
+
if result.diff:
|
|
547
|
+
sys.stdout.write(result.diff)
|
|
548
|
+
if result.status == CHANGED and checking and not ns.diff:
|
|
549
|
+
print("would strip comments from: %s" % result.path)
|
|
550
|
+
if result.message:
|
|
551
|
+
print("stifle: %s: %s" % (result.path, result.message), file=sys.stderr)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _merge(
|
|
555
|
+
ns: argparse.Namespace, config: dict, parser: argparse.ArgumentParser
|
|
556
|
+
) -> "Tuple[Tuple[str, ...], Optional[str], bool, List[str]]":
|
|
557
|
+
"""Resolve (targets, keep-regex, default_keeps, exclude): config
|
|
558
|
+
supplies defaults, any explicit CLI flag wins outright."""
|
|
559
|
+
if ns.delete is not None:
|
|
560
|
+
selected = set(ns.delete)
|
|
561
|
+
elif "delete" in config:
|
|
562
|
+
selected = set(config["delete"])
|
|
563
|
+
else:
|
|
564
|
+
selected = set(ALL_TARGETS)
|
|
565
|
+
skips = ns.skip if ns.skip is not None else set(config.get("skip") or [])
|
|
566
|
+
selected -= set(skips)
|
|
567
|
+
if not selected:
|
|
568
|
+
parser.error("nothing to delete")
|
|
569
|
+
patterns = (
|
|
570
|
+
list(ns.keep) if ns.keep is not None else list(config.get("keep") or [])
|
|
571
|
+
)
|
|
572
|
+
if not patterns:
|
|
573
|
+
keep = None
|
|
574
|
+
else:
|
|
575
|
+
for pattern in patterns:
|
|
576
|
+
try:
|
|
577
|
+
re.compile(pattern)
|
|
578
|
+
except re.error as exc:
|
|
579
|
+
parser.error("invalid --keep regex %r: %s" % (pattern, exc))
|
|
580
|
+
keep = "|".join("(?:%s)" % p for p in patterns)
|
|
581
|
+
if ns.default_keeps is not None:
|
|
582
|
+
default_keeps = ns.default_keeps
|
|
583
|
+
elif "default-keeps" in config:
|
|
584
|
+
default_keeps = config["default-keeps"]
|
|
585
|
+
else:
|
|
586
|
+
default_keeps = True
|
|
587
|
+
exclude = (
|
|
588
|
+
list(ns.exclude)
|
|
589
|
+
if ns.exclude is not None
|
|
590
|
+
else list(config.get("exclude") or [])
|
|
591
|
+
)
|
|
592
|
+
return tuple(sorted(selected)), keep, default_keeps, exclude
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def main(argv: "Optional[Sequence[str]]" = None) -> int:
|
|
596
|
+
parser = _build_parser()
|
|
597
|
+
argv = list(sys.argv[1:]) if argv is None else list(argv)
|
|
598
|
+
_normalise_argv(argv, parser)
|
|
599
|
+
ns = parser.parse_args(argv)
|
|
600
|
+
command = "format" if ns.command == "strip" else ns.command
|
|
601
|
+
if ns.max_doc_lines is not None and ns.max_doc_lines < 1:
|
|
602
|
+
parser.error("--max-doc-lines must be at least 1")
|
|
603
|
+
config = _load_config(ns.paths, ns.config, ns.isolated, parser)
|
|
604
|
+
targets, keep, default_keeps, exclude = _merge(ns, config, parser)
|
|
605
|
+
files, missing = _discover(ns.paths, exclude)
|
|
606
|
+
for path in missing:
|
|
607
|
+
print("stifle: no such file or directory: %s" % path, file=sys.stderr)
|
|
608
|
+
if not files:
|
|
609
|
+
print("stifle: no Python files found", file=sys.stderr)
|
|
610
|
+
return 2 if missing else 0
|
|
611
|
+
|
|
612
|
+
checking = not ns.fix if command == "check" else bool(ns.check)
|
|
613
|
+
write = not checking and not ns.diff
|
|
614
|
+
cfg = _Config(
|
|
615
|
+
targets, keep, default_keeps, write, ns.diff, ns.max_doc_lines
|
|
616
|
+
)
|
|
617
|
+
jobs = ns.jobs if ns.jobs is not None else os.cpu_count() or 1
|
|
618
|
+
|
|
619
|
+
counts = {UNCHANGED: 0, CHANGED: 0, SKIPPED: 0, FAILED: 0, "violations": 0}
|
|
620
|
+
for result in _run(cfg, files, jobs):
|
|
621
|
+
_report(result, ns, counts, checking)
|
|
622
|
+
|
|
623
|
+
verb = "would change" if checking or ns.diff else "changed"
|
|
624
|
+
summary = (
|
|
625
|
+
"stifle: %d file%s: %d %s, %d unchanged, %d skipped, %d failed"
|
|
626
|
+
% (
|
|
627
|
+
len(files),
|
|
628
|
+
"s" if len(files) != 1 else "",
|
|
629
|
+
counts[CHANGED],
|
|
630
|
+
verb,
|
|
631
|
+
counts[UNCHANGED],
|
|
632
|
+
counts[SKIPPED],
|
|
633
|
+
counts[FAILED],
|
|
634
|
+
)
|
|
635
|
+
)
|
|
636
|
+
if ns.max_doc_lines is not None:
|
|
637
|
+
summary += ", %d docstring violations" % counts["violations"]
|
|
638
|
+
print(summary, file=sys.stderr)
|
|
639
|
+
if counts[FAILED] or counts[SKIPPED] or missing:
|
|
640
|
+
return 2
|
|
641
|
+
if checking and counts[CHANGED]:
|
|
642
|
+
rest = [a for a in argv[1:] if a not in ("--check", "--diff", "--fix")]
|
|
643
|
+
rerun = shlex.join(["format", *rest])
|
|
644
|
+
print(
|
|
645
|
+
"stifle: %d files contain comments stifle would delete."
|
|
646
|
+
% counts[CHANGED],
|
|
647
|
+
file=sys.stderr,
|
|
648
|
+
)
|
|
649
|
+
print("stifle: to fix, run: stifle %s" % rerun, file=sys.stderr)
|
|
650
|
+
print(
|
|
651
|
+
"stifle: (rewrites in place; run with --diff first to preview "
|
|
652
|
+
"the deletions)",
|
|
653
|
+
file=sys.stderr,
|
|
654
|
+
)
|
|
655
|
+
if (checking and counts[CHANGED]) or counts["violations"]:
|
|
656
|
+
return 1
|
|
657
|
+
return 0
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
if __name__ == "__main__":
|
|
661
|
+
raise SystemExit(main())
|