dktcli 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.
Files changed (42) hide show
  1. devkittools/__init__.py +3 -0
  2. devkittools/__main__.py +6 -0
  3. devkittools/cli.py +179 -0
  4. devkittools/tools/__init__.py +1 -0
  5. devkittools/tools/bench.py +73 -0
  6. devkittools/tools/changeloggen.py +181 -0
  7. devkittools/tools/clean.py +110 -0
  8. devkittools/tools/colortool.py +126 -0
  9. devkittools/tools/commitwriter.py +199 -0
  10. devkittools/tools/contextpacker.py +226 -0
  11. devkittools/tools/cron.py +152 -0
  12. devkittools/tools/doctor.py +74 -0
  13. devkittools/tools/du.py +72 -0
  14. devkittools/tools/enc.py +131 -0
  15. devkittools/tools/envtool.py +141 -0
  16. devkittools/tools/findtool.py +119 -0
  17. devkittools/tools/gen.py +182 -0
  18. devkittools/tools/gx.py +184 -0
  19. devkittools/tools/hashtool.py +93 -0
  20. devkittools/tools/httptool.py +131 -0
  21. devkittools/tools/ignore.py +213 -0
  22. devkittools/tools/initproj.py +166 -0
  23. devkittools/tools/jsontool.py +200 -0
  24. devkittools/tools/net.py +114 -0
  25. devkittools/tools/portkiller.py +246 -0
  26. devkittools/tools/regextool.py +84 -0
  27. devkittools/tools/repostats.py +197 -0
  28. devkittools/tools/secrets_scan.py +108 -0
  29. devkittools/tools/serve.py +118 -0
  30. devkittools/tools/snip.py +94 -0
  31. devkittools/tools/system.py +157 -0
  32. devkittools/tools/timetool.py +130 -0
  33. devkittools/tools/todo.py +69 -0
  34. devkittools/tools/tree.py +86 -0
  35. devkittools/tools/watch.py +82 -0
  36. devkittools/utils.py +561 -0
  37. dktcli-1.0.0.dist-info/METADATA +148 -0
  38. dktcli-1.0.0.dist-info/RECORD +42 -0
  39. dktcli-1.0.0.dist-info/WHEEL +5 -0
  40. dktcli-1.0.0.dist-info/entry_points.txt +3 -0
  41. dktcli-1.0.0.dist-info/licenses/LICENSE +21 -0
  42. dktcli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """devkittools - a suite of small developer CLI tools (command: dkt)."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
devkittools/cli.py ADDED
@@ -0,0 +1,179 @@
1
+ """Entry point for the `dkt` command."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import difflib
6
+ import re
7
+ import shlex
8
+ import sys
9
+ from typing import List, Optional
10
+
11
+ from . import __version__
12
+ from .tools import (bench, changeloggen, clean, colortool, commitwriter, contextpacker, cron, doctor, du, enc,
13
+ envtool, findtool, gen, gx, hashtool, httptool, ignore, initproj, jsontool, net, portkiller,
14
+ regextool, repostats, secrets_scan, serve, snip, system, timetool, todo, tree, watch)
15
+ from .utils import DktError, banner, c, is_interactive, ask, setup_console, sym, term_width
16
+
17
+ VERSION_STR = "devkittools %s" % __version__
18
+
19
+ # Order here = order in the help screen.
20
+ MODULES = [
21
+ portkiller, serve, httptool, net,
22
+ repostats, tree, findtool, todo, secrets_scan, clean, du, watch, bench, envtool, ignore, initproj,
23
+ gx, changeloggen,
24
+ contextpacker, commitwriter,
25
+ gen, enc, hashtool, timetool, colortool,
26
+ jsontool, regextool, cron, snip,
27
+ doctor,
28
+ ]
29
+ CATEGORY_ORDER = ["Servers & Network", "Project & Code", "Git", "AI Helpers", "Generate & Convert",
30
+ "Data & Text", "System"]
31
+ EXTRA = [ # (category, name, alias, summary) for commands without a META of their own
32
+ ("Generate & Convert", "jwt", "", "decode JSON Web Tokens"),
33
+ ("Project & Code", "license", "lic", "write a LICENSE file"),
34
+ ("dkt itself", "update", "", "upgrade dkt to the newest version"),
35
+ ("dkt itself", "config", "", "view / change settings"),
36
+ ("dkt itself", "completion", "", "shell tab-completion script"),
37
+ ("dkt itself", "menu", "", "interactive launcher"),
38
+ ("dkt itself", "about", "", "logo, version and tips"),
39
+ ]
40
+ CATEGORY_ORDER.append("dkt itself")
41
+
42
+
43
+ def all_tools():
44
+ rows = [m.META for m in MODULES] + EXTRA
45
+ return rows
46
+
47
+
48
+ def grouped_help() -> str:
49
+ lines = []
50
+ cats = {}
51
+ for cat, name, alias, summary in all_tools():
52
+ cats.setdefault(cat, []).append((name, alias, summary))
53
+ for cat in CATEGORY_ORDER:
54
+ items = cats.get(cat)
55
+ if not items:
56
+ continue
57
+ lines.append("")
58
+ lines.append(" " + c(cat, "bold bmagenta"))
59
+ for name, alias, summary in items:
60
+ if len(name) > 12 and alias:
61
+ name, alias = alias, name
62
+ short = ("(%s)" % alias) if alias else ""
63
+ lines.append(" %s %s %s" % (c(name.ljust(10), "bold bcyan"), c(short.ljust(6), "gray"), summary))
64
+ return "\n".join(lines)
65
+
66
+
67
+ def print_overview() -> None:
68
+ banner()
69
+ print(grouped_help())
70
+ print()
71
+ print(" " + c("usage:", "bold") + " dkt <tool> [options] " + c("dkt <tool> -h for examples", "gray"))
72
+ print(" " + c("tip:", "bold") + " dkt menu for an interactive launcher, dkt completion for tab-complete")
73
+ print()
74
+
75
+
76
+ class OverviewAction(argparse.Action):
77
+ def __init__(self, option_strings, dest, **kw):
78
+ super().__init__(option_strings, dest, nargs=0, **kw)
79
+
80
+ def __call__(self, parser, namespace, values, option_string=None):
81
+ print_overview()
82
+ parser.exit(0)
83
+
84
+
85
+ class DktParser(argparse.ArgumentParser):
86
+ tool_names: List[str] = []
87
+
88
+ def error(self, message):
89
+ m = re.search(r"invalid choice: '([^']*)'", message)
90
+ if m and self.prog == "dkt":
91
+ bad = m.group(1)
92
+ guess = difflib.get_close_matches(bad, self.tool_names, n=3, cutoff=0.5)
93
+ sys.stderr.write("%s unknown tool '%s'\n" % (c("error:", "red"), bad))
94
+ if guess:
95
+ sys.stderr.write(" did you mean: %s ?\n" % ", ".join(c(g, "bold bcyan") for g in guess))
96
+ sys.stderr.write(" run 'dkt' to see all tools\n")
97
+ self.exit(2)
98
+ super().error(message)
99
+
100
+
101
+ def build_parser():
102
+ parser = DktParser(prog="dkt", description="devkittools - the developer's swiss army knife.",
103
+ add_help=False, formatter_class=argparse.RawDescriptionHelpFormatter)
104
+ parser.add_argument("-h", "--help", action=OverviewAction, help="show all tools")
105
+ parser.add_argument("-v", "-V", "--version", action="version", version=VERSION_STR, help="show version")
106
+ common = argparse.ArgumentParser(add_help=False)
107
+ common.add_argument("-v", "-V", "--version", action="version", version=VERSION_STR, help="show version and exit")
108
+ sub = parser.add_subparsers(dest="command", metavar="<tool>", title="tools")
109
+ for m in MODULES:
110
+ m.register(sub, common)
111
+ system.register(sub, common)
112
+ menu = sub.add_parser("menu", aliases=["m"], parents=[common], help="interactive launcher")
113
+ menu.set_defaults(func=lambda a: run_menu())
114
+ DktParser.tool_names = sorted(sub.choices)
115
+ return parser
116
+
117
+
118
+ def run_menu() -> int:
119
+ if not is_interactive():
120
+ print_overview()
121
+ return 0
122
+ banner()
123
+ tools = []
124
+ cats = {}
125
+ for cat, name, alias, summary in all_tools():
126
+ if name in ("menu",):
127
+ continue
128
+ cats.setdefault(cat, []).append((name, alias, summary))
129
+ n = 0
130
+ for cat in CATEGORY_ORDER:
131
+ if cat not in cats:
132
+ continue
133
+ print(" " + c(cat, "bold bmagenta"))
134
+ for name, alias, summary in cats[cat]:
135
+ n += 1
136
+ tools.append(name)
137
+ print(" %s %s %s" % (c(str(n).rjust(2), "gray"), c(name.ljust(10), "bold bcyan"), summary))
138
+ print()
139
+ while True:
140
+ pick = ask("pick a tool by number or name (q to quit)")
141
+ if pick.lower() in ("", "q", "quit", "exit"):
142
+ return 0
143
+ name = tools[int(pick) - 1] if pick.isdigit() and 0 < int(pick) <= len(tools) else pick
144
+ print()
145
+ main([name, "--help"] if True else [])
146
+ line = ask("arguments (blank = run with none, 'b' = back)")
147
+ if line.lower() == "b":
148
+ continue
149
+ try:
150
+ argv = [name] + shlex.split(line)
151
+ except ValueError as e:
152
+ print(c("bad quoting: %s" % e, "red"))
153
+ continue
154
+ print()
155
+ main(argv)
156
+ print()
157
+
158
+
159
+ def main(argv: Optional[List[str]] = None) -> int:
160
+ setup_console()
161
+ parser = build_parser()
162
+ args = parser.parse_args(argv)
163
+ if not getattr(args, "func", None):
164
+ print_overview()
165
+ return 0
166
+ try:
167
+ return int(args.func(args) or 0)
168
+ except DktError as e:
169
+ print(c("error:", "red"), e, file=sys.stderr)
170
+ return 1
171
+ except KeyboardInterrupt:
172
+ print(file=sys.stderr)
173
+ return 130
174
+ except BrokenPipeError:
175
+ return 0
176
+
177
+
178
+ if __name__ == "__main__":
179
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Individual dkt tools. Each module exposes register(subparsers, common) and run(args)."""
@@ -0,0 +1,73 @@
1
+ """dkt bench (bn) - time a command over several runs."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import statistics
6
+ import subprocess
7
+ import time
8
+
9
+ from ..utils import DktError, Spinner, bar, c, kv
10
+
11
+ META = ("Project & Code", "bench", "bn", "benchmark any command: min/mean/max over N runs")
12
+
13
+ EPILOG = """\
14
+ examples:
15
+ dkt bn "python script.py"
16
+ dkt bn "node a.js" "node b.js" -n 20 compare two commands
17
+ dkt bn "make build" --warmup 2 --runs 5
18
+ """
19
+
20
+
21
+ def fmt(s: float) -> str:
22
+ return "%.0f ms" % (s * 1000) if s < 1 else "%.2f s" % s
23
+
24
+
25
+ def time_cmd(cmd: str, runs: int, warmup: int):
26
+ times = []
27
+ fails = 0
28
+ for i in range(warmup + runs):
29
+ t0 = time.perf_counter()
30
+ rc = subprocess.call(cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
31
+ dt = time.perf_counter() - t0
32
+ if i >= warmup:
33
+ times.append(dt)
34
+ fails += rc != 0
35
+ return times, fails
36
+
37
+
38
+ def run(args) -> int:
39
+ if not args.commands:
40
+ raise DktError('Give at least one command, e.g. dkt bn "python script.py"')
41
+ results = []
42
+ for cmd in args.commands:
43
+ with Spinner("benchmarking: %s" % cmd):
44
+ times, fails = time_cmd(cmd, args.runs, args.warmup)
45
+ results.append((cmd, times, fails))
46
+ best = min(statistics.mean(t) for _, t, _ in results)
47
+ for cmd, times, fails in results:
48
+ mean = statistics.mean(times)
49
+ print("\n " + c(cmd, "bold bcyan"))
50
+ rows = [("mean", c(fmt(mean), "bold") + (" " + c("± " + fmt(statistics.stdev(times)), "gray") if len(times) > 1 else "")),
51
+ ("range", "%s … %s" % (fmt(min(times)), fmt(max(times)))),
52
+ ("runs", "%d%s" % (len(times), c(" (%d failed)" % fails, "red") if fails else ""))]
53
+ if len(results) > 1:
54
+ rows.append(("vs best", "%.2fx" % (mean / best) if mean > best else c("fastest", "bgreen")))
55
+ kv(rows)
56
+ if len(results) > 1:
57
+ print()
58
+ mx = max(statistics.mean(t) for _, t, _ in results)
59
+ for cmd, times, _ in results:
60
+ m = statistics.mean(times)
61
+ print(" %s %s %s" % (c(bar(m, mx, 24), "bmagenta"), fmt(m).rjust(8), cmd))
62
+ print()
63
+ return 0
64
+
65
+
66
+ def register(sub, common) -> None:
67
+ p = sub.add_parser("bench", aliases=["bn"], parents=[common], help=META[3],
68
+ description="Benchmark shell commands.", epilog=EPILOG,
69
+ formatter_class=argparse.RawDescriptionHelpFormatter)
70
+ p.add_argument("commands", nargs="*", metavar="CMD")
71
+ p.add_argument("-n", "--runs", type=int, default=10)
72
+ p.add_argument("--warmup", type=int, default=1)
73
+ p.set_defaults(func=run)
@@ -0,0 +1,181 @@
1
+ """dkt changeloggen (clg) - turn git commits into a CHANGELOG.md."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import datetime
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional, Tuple
9
+
10
+ from ..utils import DktError, c, git, info, require_git_repo, resolve_folder
11
+
12
+ EPILOG = """\
13
+ examples:
14
+ dkt clg CHANGELOG.md for the current folder
15
+ dkt clg --folder "C:\\code\\app" for another project
16
+ dkt clg --stdout print instead of writing a file
17
+ dkt clg --since v1.2.0 only changes after a tag/commit
18
+ dkt clg -o docs/HISTORY.md --authors
19
+ """
20
+
21
+ CATEGORIES = [
22
+ ("feat", "Features", "✨"),
23
+ ("fix", "Bug Fixes", "🐛"),
24
+ ("perf", "Performance", "⚡"),
25
+ ("refactor", "Refactoring", "♻️"),
26
+ ("docs", "Documentation", "📝"),
27
+ ("style", "Style", "💄"),
28
+ ("test", "Tests", "✅"),
29
+ ("build", "Build", "📦"),
30
+ ("ci", "CI", "🤖"),
31
+ ("chore", "Chores", "🧹"),
32
+ ("revert", "Reverts", "⏪"),
33
+ ("other", "Other Changes", "🔧"),
34
+ ]
35
+ KNOWN_TYPES = {t for t, _, _ in CATEGORIES if t != "other"}
36
+ CONV_RE = re.compile(r"^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$")
37
+
38
+
39
+ def parse_commit(subject: str, body: str = "") -> dict:
40
+ m = CONV_RE.match(subject.strip())
41
+ if m and m.group(1).lower() in KNOWN_TYPES:
42
+ ctype, scope, bang, desc = m.group(1).lower(), m.group(2), m.group(3), m.group(4)
43
+ else:
44
+ ctype, scope, bang, desc = "other", None, None, subject.strip()
45
+ breaking = bool(bang) or "BREAKING CHANGE" in body
46
+ return {"type": ctype, "scope": scope, "desc": desc, "breaking": breaking}
47
+
48
+
49
+ def remote_base_url(folder: Path) -> Optional[str]:
50
+ url = git(folder, "remote", "get-url", "origin", check=False).strip()
51
+ if not url:
52
+ return None
53
+ m = re.match(r"^git@([^:]+):(.+?)(?:\.git)?/?$", url)
54
+ if m:
55
+ return "https://%s/%s" % (m.group(1), m.group(2))
56
+ m = re.match(r"^ssh://(?:[^@/]+@)?([^/]+)/(.+?)(?:\.git)?/?$", url)
57
+ if m:
58
+ return "https://%s/%s" % (m.group(1), m.group(2))
59
+ if url.startswith("http"):
60
+ url = re.sub(r"//[^@/]+@", "//", url)
61
+ return re.sub(r"\.git/?$", "", url).rstrip("/")
62
+ return None
63
+
64
+
65
+ def get_commits(folder: Path, rng: str) -> List[dict]:
66
+ fmt = "%H%x1f%h%x1f%s%x1f%an%x1f%b%x1e"
67
+ out = git(folder, "log", rng, "--no-merges", "--pretty=format:" + fmt)
68
+ commits = []
69
+ for rec in out.split("\x1e"):
70
+ rec = rec.strip("\r\n")
71
+ if not rec.strip():
72
+ continue
73
+ f = rec.split("\x1f")
74
+ if len(f) < 4:
75
+ continue
76
+ body = f[4] if len(f) > 4 else ""
77
+ item = parse_commit(f[2], body)
78
+ item.update({"hash": f[0].strip(), "short": f[1].strip(), "author": f[3].strip()})
79
+ commits.append(item)
80
+ return commits
81
+
82
+
83
+ def get_tags(folder: Path) -> List[Tuple[str, str]]:
84
+ out = git(folder, "for-each-ref", "--sort=-creatordate",
85
+ "--format=%(refname:short)\t%(creatordate:short)", "refs/tags", check=False)
86
+ tags = []
87
+ for line in out.splitlines():
88
+ if "\t" in line:
89
+ name, date = line.split("\t", 1)
90
+ tags.append((name.strip(), date.strip()))
91
+ return tags
92
+
93
+
94
+ def render_section(title: str, date: str, commits: List[dict], base: Optional[str],
95
+ authors: bool, emoji: bool) -> str:
96
+ lines = ["## %s%s" % (title, " - %s" % date if date else ""), ""]
97
+ for ctype, label, icon in CATEGORIES:
98
+ group = [x for x in commits if x["type"] == ctype]
99
+ if not group:
100
+ continue
101
+ lines.append("### %s%s" % (icon + " " if emoji else "", label))
102
+ for x in group:
103
+ text = x["desc"]
104
+ if x["scope"]:
105
+ text = "**%s:** %s" % (x["scope"], text)
106
+ if x["breaking"]:
107
+ text = "**BREAKING** " + text
108
+ ref = "`%s`" % x["short"]
109
+ if base:
110
+ ref = "[%s](%s/commit/%s)" % (ref, base, x["hash"])
111
+ suffix = " (%s)" % ref
112
+ if authors:
113
+ suffix += " - %s" % x["author"]
114
+ lines.append("- " + text + suffix)
115
+ lines.append("")
116
+ return "\n".join(lines)
117
+
118
+
119
+ def generate(folder: Path, since: Optional[str], authors: bool, emoji: bool) -> Tuple[str, int, int]:
120
+ base = remote_base_url(folder)
121
+ sections = [] # (title, date, commits)
122
+ today = datetime.date.today().isoformat()
123
+ if since:
124
+ commits = get_commits(folder, "%s..HEAD" % since)
125
+ sections.append(("Changes since %s" % since, today, commits))
126
+ else:
127
+ tags = get_tags(folder)
128
+ if not tags:
129
+ sections.append(("Unreleased", today, get_commits(folder, "HEAD")))
130
+ else:
131
+ sections.append(("Unreleased", today, get_commits(folder, "%s..HEAD" % tags[0][0])))
132
+ for i, (name, date) in enumerate(tags):
133
+ rng = name if i == len(tags) - 1 else "%s..%s" % (tags[i + 1][0], name)
134
+ sections.append((name, date, get_commits(folder, rng)))
135
+ total = sum(len(s[2]) for s in sections)
136
+ if total == 0:
137
+ raise DktError("No commits found to put in a changelog.")
138
+ parts = ["# Changelog", "",
139
+ "All notable changes to this project are listed here. "
140
+ "Generated by devkittools (`dkt clg`).", ""]
141
+ for title, date, commits in sections:
142
+ if commits:
143
+ parts.append(render_section(title, date, commits, base, authors, emoji))
144
+ return "\n".join(parts).rstrip() + "\n", total, len([s for s in sections if s[2]])
145
+
146
+
147
+ def run(args: argparse.Namespace) -> int:
148
+ folder = resolve_folder(args.folder)
149
+ require_git_repo(folder)
150
+ if not git(folder, "rev-parse", "--verify", "HEAD", check=False).strip():
151
+ raise DktError("This repository has no commits yet.")
152
+ text, total, nsec = generate(folder, args.since, args.authors, not args.no_emoji)
153
+ if args.stdout:
154
+ print(text)
155
+ return 0
156
+ out = Path(args.output) if args.output else folder / "CHANGELOG.md"
157
+ if not out.is_absolute() and args.output:
158
+ out = Path.cwd() / out
159
+ out.parent.mkdir(parents=True, exist_ok=True)
160
+ out.write_text(text, encoding="utf-8")
161
+ info("%s %s (%d commits, %d section%s)" % (c("✔ Wrote", "green"), out, total, nsec, "" if nsec == 1 else "s"))
162
+ return 0
163
+
164
+
165
+ def register(sub, common) -> None:
166
+ p = sub.add_parser(
167
+ "changeloggen", aliases=["clg"], parents=[common],
168
+ help="generate CHANGELOG.md from git commits",
169
+ description="Generate a clean, grouped CHANGELOG.md from your git history.\n"
170
+ "Understands Conventional Commits (feat:, fix:, ...) and git tags.",
171
+ epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
172
+ p.add_argument("-f", "--folder", metavar="PATH", help="project folder (default: current folder)")
173
+ p.add_argument("-o", "--output", metavar="FILE", help="output file (default: <folder>/CHANGELOG.md)")
174
+ p.add_argument("--since", metavar="REF", help="only include commits after this tag/commit")
175
+ p.add_argument("--stdout", action="store_true", help="print to the terminal instead of writing a file")
176
+ p.add_argument("--authors", action="store_true", help="show the author of each commit")
177
+ p.add_argument("--no-emoji", action="store_true", help="plain section headings")
178
+ p.set_defaults(func=run)
179
+
180
+
181
+ META = ("Git", "changeloggen", "clg", "CHANGELOG.md from conventional commits & tags")
@@ -0,0 +1,110 @@
1
+ """dkt clean (cl) - delete build caches and dependency folders to reclaim disk space."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ import shutil
7
+ import stat
8
+ from pathlib import Path
9
+
10
+ from ..utils import c, confirm, human_size, ok, resolve_folder, table, sym, Spinner, warn
11
+
12
+ META = ("Project & Code", "clean", "cl", "remove node_modules, __pycache__, caches (with sizes + confirm)")
13
+
14
+ EPILOG = """\
15
+ examples:
16
+ dkt cl preview what would be removed in this folder tree
17
+ dkt cl --yes delete without asking
18
+ dkt cl --build also dist/ build/ target/ out/
19
+ dkt cl --venv also .venv/ venv/
20
+ dkt cl ~/projects --dry-run
21
+ """
22
+
23
+ SAFE = {"node_modules", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".next",
24
+ ".nuxt", ".parcel-cache", ".turbo", ".gradle", ".angular", ".svelte-kit", ".sass-cache", ".eslintcache",
25
+ "coverage", ".nyc_output", ".ipynb_checkpoints", ".cache"}
26
+ BUILD = {"dist", "build", "out"}
27
+ VENV = {".venv", "venv", "env"}
28
+
29
+
30
+ def dir_size(path: Path) -> int:
31
+ total = 0
32
+ for root, _, files in os.walk(str(path)):
33
+ for f in files:
34
+ try:
35
+ total += os.lstat(os.path.join(root, f)).st_size
36
+ except OSError:
37
+ pass
38
+ return total
39
+
40
+
41
+ def _rm_readonly(func, path, _exc):
42
+ try:
43
+ os.chmod(path, stat.S_IWRITE)
44
+ func(path)
45
+ except OSError:
46
+ pass
47
+
48
+
49
+ def find_targets(root: Path, names):
50
+ found = []
51
+ for cur, dirs, _ in os.walk(str(root)):
52
+ keep = []
53
+ for d in dirs:
54
+ full = Path(cur, d)
55
+ if d == ".git":
56
+ continue
57
+ is_target = d in names or d.endswith(".egg-info")
58
+ if d == "target" and "target" in names and not ((Path(cur) / "Cargo.toml").exists() or (Path(cur) / "pom.xml").exists()):
59
+ is_target = False
60
+ if is_target and not full.is_symlink():
61
+ found.append(full)
62
+ elif not full.is_symlink():
63
+ keep.append(d)
64
+ dirs[:] = keep
65
+ return found
66
+
67
+
68
+ def run(args) -> int:
69
+ root = resolve_folder(args.folder)
70
+ names = set(SAFE)
71
+ if args.build:
72
+ names |= BUILD | {"target"}
73
+ if args.venv:
74
+ names |= VENV
75
+ with Spinner("scanning %s" % root.name):
76
+ targets = find_targets(root, names)
77
+ sized = sorted(((dir_size(t), t) for t in targets), key=lambda x: -x[0])
78
+ if not sized:
79
+ ok("nothing to clean - %s is already tidy" % root.name)
80
+ return 0
81
+ rows = [(human_size(s), str(t.relative_to(root))) for s, t in sized[:40]]
82
+ table(["SIZE", "FOLDER"], rows)
83
+ if len(sized) > 40:
84
+ print(c(" ... and %d more" % (len(sized) - 40), "gray"))
85
+ total = sum(s for s, _ in sized)
86
+ print("\n %s in %d folders" % (c(human_size(total), "bold bcyan"), len(sized)))
87
+ if args.dry_run:
88
+ return 0
89
+ if not args.yes and not confirm("Delete these %d folders?" % len(sized)):
90
+ print(" nothing deleted.")
91
+ return 0
92
+ removed = 0
93
+ with Spinner("deleting"):
94
+ for _, t in sized:
95
+ shutil.rmtree(str(t), onerror=_rm_readonly)
96
+ removed += 1
97
+ ok("freed %s (%d folders)" % (human_size(total), removed))
98
+ return 0
99
+
100
+
101
+ def register(sub, common) -> None:
102
+ p = sub.add_parser("clean", aliases=["cl"], parents=[common], help=META[3],
103
+ description="Find and remove regenerable folders. Never touches .git or source files.",
104
+ epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
105
+ p.add_argument("folder", nargs="?", help="root to scan (default: current folder)")
106
+ p.add_argument("--build", action="store_true", help="also dist/ build/ out/ target/")
107
+ p.add_argument("--venv", action="store_true", help="also .venv/ venv/ env/")
108
+ p.add_argument("--dry-run", action="store_true", help="only show what would go")
109
+ p.add_argument("-y", "--yes", action="store_true", help="don't ask for confirmation")
110
+ p.set_defaults(func=run)