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.
- devkittools/__init__.py +3 -0
- devkittools/__main__.py +6 -0
- devkittools/cli.py +179 -0
- devkittools/tools/__init__.py +1 -0
- devkittools/tools/bench.py +73 -0
- devkittools/tools/changeloggen.py +181 -0
- devkittools/tools/clean.py +110 -0
- devkittools/tools/colortool.py +126 -0
- devkittools/tools/commitwriter.py +199 -0
- devkittools/tools/contextpacker.py +226 -0
- devkittools/tools/cron.py +152 -0
- devkittools/tools/doctor.py +74 -0
- devkittools/tools/du.py +72 -0
- devkittools/tools/enc.py +131 -0
- devkittools/tools/envtool.py +141 -0
- devkittools/tools/findtool.py +119 -0
- devkittools/tools/gen.py +182 -0
- devkittools/tools/gx.py +184 -0
- devkittools/tools/hashtool.py +93 -0
- devkittools/tools/httptool.py +131 -0
- devkittools/tools/ignore.py +213 -0
- devkittools/tools/initproj.py +166 -0
- devkittools/tools/jsontool.py +200 -0
- devkittools/tools/net.py +114 -0
- devkittools/tools/portkiller.py +246 -0
- devkittools/tools/regextool.py +84 -0
- devkittools/tools/repostats.py +197 -0
- devkittools/tools/secrets_scan.py +108 -0
- devkittools/tools/serve.py +118 -0
- devkittools/tools/snip.py +94 -0
- devkittools/tools/system.py +157 -0
- devkittools/tools/timetool.py +130 -0
- devkittools/tools/todo.py +69 -0
- devkittools/tools/tree.py +86 -0
- devkittools/tools/watch.py +82 -0
- devkittools/utils.py +561 -0
- dktcli-1.0.0.dist-info/METADATA +148 -0
- dktcli-1.0.0.dist-info/RECORD +42 -0
- dktcli-1.0.0.dist-info/WHEEL +5 -0
- dktcli-1.0.0.dist-info/entry_points.txt +3 -0
- dktcli-1.0.0.dist-info/licenses/LICENSE +21 -0
- dktcli-1.0.0.dist-info/top_level.txt +1 -0
devkittools/__init__.py
ADDED
devkittools/__main__.py
ADDED
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)
|