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,119 @@
1
+ """dkt find (fd) - fast project-wide text search (like grep/ripgrep) and file finder."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import fnmatch
6
+ import re
7
+
8
+ from ..utils import DktError, c, color_enabled, is_binary_chunk, list_project_files, resolve_folder
9
+
10
+ META = ("Project & Code", "find", "fd", "search text in a project (respects .gitignore) or find files")
11
+
12
+ EPILOG = """\
13
+ examples:
14
+ dkt fd "def main" search for text/regex in every file
15
+ dkt fd TODO -i --ext .py .js case-insensitive, only some extensions
16
+ dkt fd "class \\w+Error" -C 2 2 lines of context
17
+ dkt fd -n "*.test.js" find files by name (glob)
18
+ dkt fd foo -l only list matching file names
19
+ dkt fd "old_name" --replace new_name --yes project-wide find & replace
20
+ """
21
+
22
+
23
+ def run(args) -> int:
24
+ folder = resolve_folder(args.folder)
25
+ files = list_project_files(folder)
26
+ if args.ext:
27
+ exts = tuple(e if e.startswith(".") else "." + e for e in args.ext)
28
+ files = [f for f in files if str(f).lower().endswith(exts)]
29
+ if args.name:
30
+ pat = args.name.lower()
31
+ hits = [f for f in files if fnmatch.fnmatch(f.name.lower(), pat) or fnmatch.fnmatch(str(f).replace("\\", "/").lower(), pat)]
32
+ for f in hits:
33
+ print(str(f).replace("\\", "/"))
34
+ print(c("\n %d file%s" % (len(hits), "" if len(hits) == 1 else "s"), "gray"))
35
+ return 0 if hits else 1
36
+ if not args.pattern:
37
+ raise DktError("Give something to search for, or use -n \"*.py\" to find files by name.")
38
+ flags = re.I if args.i else 0
39
+ src = args.pattern
40
+ if args.fixed:
41
+ src = re.escape(args.pattern)
42
+ try:
43
+ rx = re.compile(src, flags)
44
+ except re.error as e:
45
+ raise DktError("Invalid pattern: %s (use -F for plain text)" % e)
46
+
47
+ total = 0
48
+ nfiles = 0
49
+ changed = 0
50
+ for rel in files:
51
+ p = folder / rel
52
+ try:
53
+ if p.stat().st_size > 2_000_000:
54
+ continue
55
+ raw = p.read_bytes()
56
+ except OSError:
57
+ continue
58
+ if is_binary_chunk(raw[:4096]):
59
+ continue
60
+ text = raw.decode("utf-8", errors="replace")
61
+ lines = text.splitlines()
62
+ hit_idx = [i for i, ln in enumerate(lines) if rx.search(ln)]
63
+ if not hit_idx:
64
+ continue
65
+ nfiles += 1
66
+ total += len(hit_idx)
67
+ name = str(rel).replace("\\", "/")
68
+ if args.replace is not None:
69
+ if args.yes:
70
+ new = rx.sub(args.replace, text)
71
+ if new != text:
72
+ p.write_bytes(new.encode("utf-8"))
73
+ changed += 1
74
+ else:
75
+ print(" %s %d match%s" % (c(name, "bcyan"), len(hit_idx), "" if len(hit_idx) == 1 else "es"))
76
+ continue
77
+ if args.l:
78
+ print(name)
79
+ continue
80
+ print("\n" + c(name, "bold bmagenta"))
81
+ shown = set()
82
+ for i in hit_idx:
83
+ for j in range(max(0, i - args.C), min(len(lines), i + args.C + 1)):
84
+ if j in shown:
85
+ continue
86
+ shown.add(j)
87
+ ln = lines[j]
88
+ if len(ln) > 220:
89
+ ln = ln[:220] + "..."
90
+ if j == i or rx.search(lines[j]):
91
+ if color_enabled():
92
+ ln = rx.sub(lambda m: c(m.group(0), "reverse byellow") if m.group(0) else "", ln)
93
+ print(" %s:%s" % (c(str(j + 1).rjust(5), "bgreen"), ln))
94
+ else:
95
+ print(" %s %s" % (c(str(j + 1).rjust(5), "gray"), c(ln, "gray")))
96
+ if args.replace is not None and not args.yes:
97
+ print(c("\n dry run - %d match%s in %d files. Add --yes to apply." % (total, "" if total == 1 else "es", nfiles), "yellow"))
98
+ elif args.replace is not None:
99
+ print(c("\n replaced in %d file%s" % (changed, "" if changed == 1 else "s"), "green"))
100
+ else:
101
+ print(c("\n %d match%s in %d file%s" % (total, "" if total == 1 else "es", nfiles, "" if nfiles == 1 else "s"), "gray"))
102
+ return 0 if total else 1
103
+
104
+
105
+ def register(sub, common) -> None:
106
+ p = sub.add_parser("find", aliases=["fd"], parents=[common], help=META[3],
107
+ description="Search file contents or names inside a project.",
108
+ epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
109
+ p.add_argument("pattern", nargs="?", help="regex (or plain text with -F)")
110
+ p.add_argument("-f", "--folder", metavar="PATH")
111
+ p.add_argument("-i", action="store_true", help="ignore case")
112
+ p.add_argument("-F", "--fixed", action="store_true", help="treat pattern as plain text")
113
+ p.add_argument("-C", type=int, default=0, metavar="N", help="lines of context")
114
+ p.add_argument("-l", action="store_true", help="only print file names")
115
+ p.add_argument("-n", "--name", metavar="GLOB", help="find files by name instead")
116
+ p.add_argument("--ext", nargs="+", metavar="EXT", help="only these extensions")
117
+ p.add_argument("--replace", metavar="TEXT", help="replace matches (dry run unless --yes)")
118
+ p.add_argument("--yes", action="store_true", help="apply --replace")
119
+ p.set_defaults(func=run)
@@ -0,0 +1,182 @@
1
+ """dkt gen (g) - generate UUIDs, ULIDs, passwords, tokens, short ids, lorem ipsum, slugs."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import math
6
+ import os
7
+ import re
8
+ import secrets
9
+ import string
10
+ import time
11
+ import unicodedata
12
+ import uuid
13
+
14
+ from ..utils import DktError, c, info, output_result
15
+
16
+ META = ("Generate & Convert", "gen", "g", "UUIDs, ULIDs, passwords, tokens, lorem ipsum, slugs")
17
+
18
+ EPILOG = """\
19
+ examples:
20
+ dkt gen uuid -n 5 five UUIDv4
21
+ dkt gen uuid --v7 time-sortable UUIDv7
22
+ dkt gen ulid
23
+ dkt gen password -l 24 --copy
24
+ dkt gen token --bytes 32 64 hex chars, good for secrets / API keys
25
+ dkt gen id -l 10 short URL-safe id
26
+ dkt gen lorem -p 2 two paragraphs
27
+ dkt gen slug "Hello, World! Ünïcode"
28
+ """
29
+
30
+ _CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
31
+ _LOREM = ("lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut "
32
+ "labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris "
33
+ "nisi aliquip ex ea commodo consequat duis aute irure in reprehenderit voluptate velit esse "
34
+ "cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt culpa qui "
35
+ "officia deserunt mollit anim id est laborum").split()
36
+
37
+
38
+ def uuid7() -> str:
39
+ ms = int(time.time() * 1000) & ((1 << 48) - 1)
40
+ rand_a = secrets.randbits(12)
41
+ rand_b = secrets.randbits(62)
42
+ value = (ms << 80) | (0x7 << 76) | (rand_a << 64) | (0b10 << 62) | rand_b
43
+ return str(uuid.UUID(int=value))
44
+
45
+
46
+ def ulid() -> str:
47
+ ms = int(time.time() * 1000)
48
+ out = ""
49
+ for _ in range(10):
50
+ out = _CROCKFORD[ms & 31] + out
51
+ ms >>= 5
52
+ for _ in range(16):
53
+ out += _CROCKFORD[secrets.randbelow(32)]
54
+ return out
55
+
56
+
57
+ def make_password(length: int, symbols: bool, digits: bool, upper: bool, ambiguous: bool) -> str:
58
+ pools = [string.ascii_lowercase]
59
+ if upper:
60
+ pools.append(string.ascii_uppercase)
61
+ if digits:
62
+ pools.append(string.digits)
63
+ if symbols:
64
+ pools.append("!@#$%^&*()-_=+[]{};:,.?")
65
+ if not ambiguous:
66
+ pools = ["".join(ch for ch in p if ch not in "0O1lI|") for p in pools]
67
+ if length < len(pools):
68
+ raise DktError("Password length must be at least %d for these options." % len(pools))
69
+ chars = [secrets.choice(p) for p in pools] # guarantee one of each class
70
+ allchars = "".join(pools)
71
+ chars += [secrets.choice(allchars) for _ in range(length - len(chars))]
72
+ secrets.SystemRandom().shuffle(chars)
73
+ return "".join(chars)
74
+
75
+
76
+ def slugify(text: str, sep: str = "-") -> str:
77
+ text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
78
+ text = re.sub(r"[^a-zA-Z0-9]+", sep, text.lower()).strip(sep)
79
+ return text
80
+
81
+
82
+ def lorem(paragraphs: int, words: int) -> str:
83
+ def sentence() -> str:
84
+ n = 6 + secrets.randbelow(10)
85
+ s = " ".join(secrets.choice(_LOREM) for _ in range(n))
86
+ return s.capitalize() + "."
87
+ if words:
88
+ out = [secrets.choice(_LOREM) for _ in range(words)]
89
+ return (" ".join(out)).capitalize() + "."
90
+ paras = []
91
+ for i in range(paragraphs):
92
+ sents = [sentence() for _ in range(3 + secrets.randbelow(4))]
93
+ if i == 0:
94
+ sents[0] = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
95
+ paras.append(" ".join(sents))
96
+ return "\n\n".join(paras)
97
+
98
+
99
+ def run(args) -> int:
100
+ kind = args.kind
101
+ if not kind:
102
+ raise DktError("Pick what to generate: uuid, ulid, password, token, id, lorem, slug. See 'dkt gen --help'.")
103
+ lines = []
104
+ if kind == "uuid":
105
+ for _ in range(args.n):
106
+ lines.append(uuid7() if args.v7 else str(uuid.uuid4()))
107
+ if args.upper:
108
+ lines = [x.upper() for x in lines]
109
+ if args.no_dashes:
110
+ lines = [x.replace("-", "") for x in lines]
111
+ elif kind == "ulid":
112
+ lines = [ulid() for _ in range(args.n)]
113
+ elif kind == "password":
114
+ for _ in range(args.n):
115
+ lines.append(make_password(args.length, not args.no_symbols, not args.no_digits,
116
+ not args.no_upper, args.ambiguous))
117
+ pool = 26 + (26 if not args.no_upper else 0) + (10 if not args.no_digits else 0) + \
118
+ (23 if not args.no_symbols else 0)
119
+ bits = args.length * math.log2(pool)
120
+ info(c("~%d bits of entropy" % bits, "gray"))
121
+ elif kind == "token":
122
+ for _ in range(args.n):
123
+ if args.format == "hex":
124
+ lines.append(secrets.token_hex(args.bytes))
125
+ elif args.format == "b64":
126
+ lines.append(secrets.token_urlsafe(args.bytes))
127
+ else:
128
+ lines.append(str(secrets.randbits(args.bytes * 8)))
129
+ elif kind == "id":
130
+ alphabet = args.alphabet or (string.ascii_letters + string.digits)
131
+ lines = ["".join(secrets.choice(alphabet) for _ in range(args.length)) for _ in range(args.n)]
132
+ elif kind == "lorem":
133
+ lines = [lorem(args.paragraphs, args.words)]
134
+ elif kind == "slug":
135
+ if not args.text:
136
+ raise DktError("Give some text to slugify.")
137
+ lines = [slugify(" ".join(args.text), args.sep)]
138
+ output_result("\n".join(lines), copy=args.copy)
139
+ return 0
140
+
141
+
142
+ def register(sub, common) -> None:
143
+ p = sub.add_parser("gen", aliases=["g"], parents=[common],
144
+ help=META[3], description="Generate common developer values.",
145
+ epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
146
+ p.set_defaults(func=run, kind=None)
147
+ g = p.add_subparsers(dest="kind_", metavar="<kind>")
148
+
149
+ def add(name, help_, **kw):
150
+ sp = g.add_parser(name, help=help_)
151
+ sp.set_defaults(func=run, kind=name)
152
+ sp.add_argument("--copy", action="store_true", help="copy result to the clipboard")
153
+ return sp
154
+
155
+ sp = add("uuid", "UUID v4 (or v7)")
156
+ sp.add_argument("-n", type=int, default=1, help="how many")
157
+ sp.add_argument("--v7", action="store_true", help="time-sortable UUIDv7")
158
+ sp.add_argument("--upper", action="store_true")
159
+ sp.add_argument("--no-dashes", action="store_true")
160
+ sp = add("ulid", "sortable ULID")
161
+ sp.add_argument("-n", type=int, default=1)
162
+ sp = add("password", "strong random password")
163
+ sp.add_argument("-n", type=int, default=1)
164
+ sp.add_argument("-l", "--length", type=int, default=20)
165
+ sp.add_argument("--no-symbols", action="store_true")
166
+ sp.add_argument("--no-digits", action="store_true")
167
+ sp.add_argument("--no-upper", action="store_true")
168
+ sp.add_argument("--ambiguous", action="store_true", help="allow look-alike chars (0 O 1 l I)")
169
+ sp = add("token", "random secret / API key")
170
+ sp.add_argument("-n", type=int, default=1)
171
+ sp.add_argument("--bytes", type=int, default=32, help="random bytes (default 32)")
172
+ sp.add_argument("--format", choices=["hex", "b64", "int"], default="hex")
173
+ sp = add("id", "short random id")
174
+ sp.add_argument("-n", type=int, default=1)
175
+ sp.add_argument("-l", "--length", type=int, default=12)
176
+ sp.add_argument("--alphabet", help="custom alphabet (default a-zA-Z0-9)")
177
+ sp = add("lorem", "placeholder text")
178
+ sp.add_argument("-p", "--paragraphs", type=int, default=1)
179
+ sp.add_argument("-w", "--words", type=int, default=0, help="exact number of words instead")
180
+ sp = add("slug", "URL slug from text")
181
+ sp.add_argument("text", nargs="*")
182
+ sp.add_argument("--sep", default="-")
@@ -0,0 +1,184 @@
1
+ """dkt gx - everyday git shortcuts: pretty log, branch cleanup, undo, wip, sync, who."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+
6
+ from ..utils import DktError, color_enabled, c, confirm, git, ok, require_git_repo, resolve_folder, run_capture, table, warn, sym, kv
7
+
8
+ META = ("Git", "gx", "", "git helpers: log, branches (+prune), undo, wip, sync, who, amend")
9
+
10
+ EPILOG = """\
11
+ examples:
12
+ dkt gx log compact colored graph
13
+ dkt gx branches all local branches: last commit, merged?, ahead/behind
14
+ dkt gx branches --prune delete branches already merged into main (asks first)
15
+ dkt gx undo undo the last commit but keep your changes
16
+ dkt gx wip stage everything + commit as "wip"
17
+ dkt gx sync fetch --prune and show where you stand
18
+ dkt gx who who wrote how much
19
+ dkt gx amend add staged changes to the last commit (no message edit)
20
+ """
21
+
22
+
23
+ def _repo(args, need_commits: bool = True):
24
+ folder = resolve_folder(args.folder)
25
+ require_git_repo(folder)
26
+ if need_commits and not git(folder, "rev-parse", "--verify", "--quiet", "HEAD", check=False).strip():
27
+ raise DktError("This repository has no commits yet.")
28
+ return folder
29
+
30
+
31
+ def cmd_log(args) -> int:
32
+ folder = _repo(args)
33
+ fmt = "%C(bold yellow)%h%C(reset) %C(green)%ar%C(reset) %C(bold blue)%an%C(reset) %C(auto)%d%C(reset) %s"
34
+ import subprocess
35
+ subprocess.call(["git", "-C", str(folder), "log", "--graph", "--color=always" if color_enabled() else "--color=never", "-n", str(args.n),
36
+ "--pretty=format:" + fmt, "--all" if args.all else "--branches=*"], stdin=subprocess.DEVNULL)
37
+ print()
38
+ return 0
39
+
40
+
41
+ def _default_branch(folder) -> str:
42
+ for cand in ("main", "master", "develop"):
43
+ if git(folder, "rev-parse", "--verify", "--quiet", cand, check=False).strip():
44
+ return cand
45
+ return git(folder, "rev-parse", "--abbrev-ref", "HEAD").strip()
46
+
47
+
48
+ def cmd_branches(args) -> int:
49
+ folder = _repo(args)
50
+ base = _default_branch(folder)
51
+ current = git(folder, "rev-parse", "--abbrev-ref", "HEAD").strip()
52
+ merged = set(b.strip().lstrip("* ") for b in git(folder, "branch", "--merged", base, "--format=%(refname:short)").splitlines())
53
+ out = git(folder, "for-each-ref", "refs/heads", "--sort=-committerdate",
54
+ "--format=%(refname:short)|%(committerdate:relative)|%(authorname)|%(upstream:track)|%(subject)")
55
+ rows, prunable = [], []
56
+ for line in out.splitlines():
57
+ name, when, author, track, subj = (line.split("|", 4) + [""] * 5)[:5]
58
+ flags = []
59
+ if name == current:
60
+ flags.append(c("current", "bgreen"))
61
+ if name in merged and name != base:
62
+ flags.append(c("merged", "gray"))
63
+ if name != current:
64
+ prunable.append(name)
65
+ if track:
66
+ flags.append(c(track.strip("[]"), "byellow"))
67
+ rows.append((c(name, "bold bcyan") if name == current else name, when, author[:16], " ".join(flags), subj[:44]))
68
+ table(["BRANCH", "LAST COMMIT", "AUTHOR", "STATE", "SUBJECT"], rows)
69
+ print(c("\n base branch: %s" % base, "gray"))
70
+ if args.prune:
71
+ prunable = [b for b in prunable if b != base]
72
+ if not prunable:
73
+ ok("no merged branches to delete")
74
+ return 0
75
+ print("\n will delete: " + ", ".join(prunable))
76
+ if confirm("Delete %d merged branches?" % len(prunable)):
77
+ for b in prunable:
78
+ git(folder, "branch", "-d", b)
79
+ ok("deleted %d branches" % len(prunable))
80
+ elif prunable:
81
+ print(c(" %d merged branch%s can be pruned: dkt gx branches --prune" % (len(prunable), "" if len(prunable) == 1 else "es"), "gray"))
82
+ return 0
83
+
84
+
85
+ def cmd_undo(args) -> int:
86
+ folder = _repo(args)
87
+ last = git(folder, "log", "-1", "--pretty=%h %s").strip()
88
+ if not last:
89
+ raise DktError("No commits to undo.")
90
+ print(" last commit: " + c(last, "bold"))
91
+ if not git(folder, "rev-parse", "--verify", "--quiet", "HEAD~1", check=False).strip():
92
+ raise DktError("That is the very first commit - nothing earlier to go back to.")
93
+ if args.yes or confirm("Undo it (changes stay staged)?", default=True):
94
+ git(folder, "reset", "--soft", "HEAD~1")
95
+ ok("commit undone - your changes are still staged")
96
+ return 0
97
+
98
+
99
+ def cmd_wip(args) -> int:
100
+ folder = _repo(args, need_commits=False)
101
+ git(folder, "add", "-A")
102
+ if not git(folder, "diff", "--cached", "--name-only").strip():
103
+ warn("nothing to commit")
104
+ return 0
105
+ git(folder, "commit", "-m", args.message, "--no-verify")
106
+ ok("committed: %s" % args.message)
107
+ return 0
108
+
109
+
110
+ def cmd_amend(args) -> int:
111
+ folder = _repo(args)
112
+ git(folder, "add", "-A") if args.all else None
113
+ git(folder, "commit", "--amend", "--no-edit")
114
+ ok("amended last commit")
115
+ return 0
116
+
117
+
118
+ def cmd_sync(args) -> int:
119
+ folder = _repo(args, need_commits=False)
120
+ code, _, err = run_capture(["git", "-C", str(folder), "fetch", "--all", "--prune"])
121
+ if code != 0:
122
+ raise DktError(err.strip() or "git fetch failed")
123
+ branch = git(folder, "rev-parse", "--abbrev-ref", "HEAD").strip()
124
+ up = git(folder, "rev-parse", "--abbrev-ref", "@{u}", check=False).strip()
125
+ dirty = git(folder, "status", "--porcelain").strip().splitlines()
126
+ rows = [("branch", c(branch, "bold bcyan")), ("upstream", up or c("none", "yellow"))]
127
+ if up:
128
+ ahead, behind = git(folder, "rev-list", "--left-right", "--count", "HEAD...@{u}").split()
129
+ rows += [("ahead", ahead), ("behind", behind)]
130
+ rows.append(("working tree", c("clean", "green") if not dirty else c("%d changed file(s)" % len(dirty), "yellow")))
131
+ kv(rows)
132
+ if up and int(behind) > 0 and not dirty and args.pull:
133
+ git(folder, "pull", "--ff-only")
134
+ ok("fast-forwarded")
135
+ elif up and int(behind) > 0:
136
+ print(c("\n tip: dkt gx sync --pull to fast-forward", "gray"))
137
+ return 0
138
+
139
+
140
+ def cmd_who(args) -> int:
141
+ folder = _repo(args)
142
+ out = git(folder, "shortlog", "-sne", "HEAD")
143
+ rows = []
144
+ mx = 1
145
+ parsed = []
146
+ for line in out.splitlines():
147
+ n, _, who = line.strip().partition("\t")
148
+ if n.isdigit():
149
+ parsed.append((int(n), who))
150
+ if parsed:
151
+ mx = parsed[0][0]
152
+ from ..utils import bar
153
+ for n, who in parsed[:args.top]:
154
+ rows.append((str(n), c(bar(n, mx, 14), "bcyan"), who))
155
+ table(["COMMITS", "", "AUTHOR"], rows)
156
+ return 0
157
+
158
+
159
+ def register(sub, common) -> None:
160
+ p = sub.add_parser("gx", parents=[common], help=META[3], description="Git quality-of-life shortcuts.",
161
+ epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
162
+ p.set_defaults(func=lambda a: (p.print_help() or 0))
163
+ g = p.add_subparsers(dest="sub", metavar="<action>")
164
+
165
+ def add(name, fn, help_):
166
+ sp = g.add_parser(name, help=help_)
167
+ sp.add_argument("-f", "--folder", metavar="PATH")
168
+ sp.set_defaults(func=fn)
169
+ return sp
170
+ sp = add("log", cmd_log, "compact colored commit graph")
171
+ sp.add_argument("-n", type=int, default=25)
172
+ sp.add_argument("--all", action="store_true", help="include remotes and tags")
173
+ sp = add("branches", cmd_branches, "branch overview, optional prune")
174
+ sp.add_argument("--prune", action="store_true", help="delete merged local branches")
175
+ sp = add("undo", cmd_undo, "undo last commit, keep changes")
176
+ sp.add_argument("-y", "--yes", action="store_true")
177
+ sp = add("wip", cmd_wip, "stage all and commit as WIP")
178
+ sp.add_argument("-m", "--message", default="wip")
179
+ sp = add("amend", cmd_amend, "amend the last commit without editing its message")
180
+ sp.add_argument("-a", "--all", action="store_true", help="stage everything first")
181
+ sp = add("sync", cmd_sync, "fetch + prune and show ahead/behind")
182
+ sp.add_argument("--pull", action="store_true", help="fast-forward if behind and clean")
183
+ sp = add("who", cmd_who, "commits per author")
184
+ sp.add_argument("--top", type=int, default=10)
@@ -0,0 +1,93 @@
1
+ """dkt hash (h) - hash text or files, and verify checksums."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import hashlib
6
+ import hmac
7
+ from pathlib import Path
8
+
9
+ from ..utils import DktError, c, human_size, info, read_input, ok, fail, sym
10
+
11
+ META = ("Generate & Convert", "hash", "h", "hash text/files, verify checksums, HMAC")
12
+
13
+ ALGOS = ["md5", "sha1", "sha256", "sha512", "sha3_256", "blake2b"]
14
+ LEN_TO_ALGO = {32: "md5", 40: "sha1", 64: "sha256", 128: "sha512"}
15
+
16
+ EPILOG = """\
17
+ examples:
18
+ dkt hash "hello world" sha256 of text
19
+ dkt hash -f setup.exe -a md5 sha256 several algorithms for one file
20
+ dkt hash -f big.iso --verify 3a7bd3e2... algorithm auto-detected from length
21
+ dkt hash "msg" --hmac secretkey HMAC-SHA256
22
+ echo -n hi | dkt hash -a all
23
+ """
24
+
25
+
26
+ def hash_file(path: Path, algos):
27
+ hs = {a: hashlib.new(a) for a in algos}
28
+ with open(str(path), "rb") as f:
29
+ for chunk in iter(lambda: f.read(1 << 20), b""):
30
+ for h in hs.values():
31
+ h.update(chunk)
32
+ return {a: h.hexdigest() for a, h in hs.items()}
33
+
34
+
35
+ def run(args) -> int:
36
+ algos = ALGOS if "all" in args.algo else args.algo
37
+ if args.verify:
38
+ want = args.verify.strip().lower()
39
+ if len(algos) != 1 or args.algo == ["sha256"] and len(want) in LEN_TO_ALGO:
40
+ if len(want) in LEN_TO_ALGO:
41
+ algos = [LEN_TO_ALGO[len(want)]]
42
+ results = [] # (label, {algo: digest})
43
+ if args.hmac:
44
+ if args.file:
45
+ raise DktError("--hmac works on text, not files.")
46
+ data = read_input(args.text, None, args.paste, "text").encode("utf-8")
47
+ a = algos[0]
48
+ results.append(("text", {"hmac-" + a: hmac.new(args.hmac.encode(), data, a).hexdigest()}))
49
+ elif args.file:
50
+ for f in args.file:
51
+ p = Path(f).expanduser()
52
+ if not p.is_file():
53
+ raise DktError("File not found: %s" % p)
54
+ label = "%s (%s)" % (p.name, human_size(p.stat().st_size))
55
+ results.append((label, hash_file(p, algos)))
56
+ else:
57
+ data = read_input(args.text, None, args.paste, "text").encode("utf-8")
58
+ d = {}
59
+ for a in algos:
60
+ h = hashlib.new(a)
61
+ h.update(data)
62
+ d[a] = h.hexdigest()
63
+ results.append(("text", d))
64
+
65
+ multi = len(results) > 1 or sum(len(d) for _, d in results) > 1
66
+ for label, d in results:
67
+ if multi:
68
+ info(c(label, "bold"))
69
+ for a, digest in d.items():
70
+ print(("%-12s %s" % (a, digest)) if multi else digest)
71
+ if args.verify:
72
+ want = args.verify.strip().lower()
73
+ hit = [a for _, d in results for a, dig in d.items() if dig == want]
74
+ if hit:
75
+ ok("checksum matches (%s)" % hit[0])
76
+ return 0
77
+ fail("checksum does NOT match")
78
+ return 1
79
+ return 0
80
+
81
+
82
+ def register(sub, common) -> None:
83
+ p = sub.add_parser("hash", aliases=["h"], parents=[common], help=META[3],
84
+ description="Hash text, stdin or files with common algorithms.",
85
+ epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
86
+ p.add_argument("text", nargs="?", help="text to hash (or pipe stdin)")
87
+ p.add_argument("-f", "--file", nargs="+", metavar="FILE", help="hash file(s) instead of text")
88
+ p.add_argument("-a", "--algo", nargs="+", default=["sha256"],
89
+ choices=ALGOS + ["all"], help="algorithm(s), default sha256")
90
+ p.add_argument("--verify", metavar="HASH", help="compare against an expected hash")
91
+ p.add_argument("--hmac", metavar="KEY", help="compute an HMAC with this key")
92
+ p.add_argument("--paste", action="store_true", help="read text from the clipboard")
93
+ p.set_defaults(func=run)