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 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
@@ -0,0 +1,4 @@
1
+ from stifle._cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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())