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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""dkt cron - explain a cron expression and show its next run times."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from ..utils import DktError, c, kv
|
|
8
|
+
|
|
9
|
+
META = ("Data & Text", "cron", "", "explain cron expressions, preview next runs")
|
|
10
|
+
|
|
11
|
+
EPILOG = """\
|
|
12
|
+
examples:
|
|
13
|
+
dkt cron "*/15 * * * *"
|
|
14
|
+
dkt cron "0 9 * * mon-fri" -n 10
|
|
15
|
+
dkt cron "30 2 1 * *"
|
|
16
|
+
dkt cron @daily
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]
|
|
20
|
+
DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]
|
|
21
|
+
ALIASES = {"@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *",
|
|
22
|
+
"@weekly": "0 0 * * 0", "@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@hourly": "0 * * * *"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _val(tok: str, names, lo: int) -> int:
|
|
26
|
+
t = tok.lower()
|
|
27
|
+
if names and t in names:
|
|
28
|
+
return names.index(t) + (lo if names is MONTHS else 0)
|
|
29
|
+
if not t.isdigit():
|
|
30
|
+
raise DktError("Bad cron value '%s'" % tok)
|
|
31
|
+
return int(t)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_field(field: str, lo: int, hi: int, names=None):
|
|
35
|
+
out = set()
|
|
36
|
+
for part in field.split(","):
|
|
37
|
+
step = 1
|
|
38
|
+
if "/" in part:
|
|
39
|
+
part, st = part.split("/", 1)
|
|
40
|
+
if not st.isdigit() or int(st) < 1:
|
|
41
|
+
raise DktError("Bad step '/%s'" % st)
|
|
42
|
+
step = int(st)
|
|
43
|
+
if part in ("*", "?"):
|
|
44
|
+
a, b = lo, hi
|
|
45
|
+
elif "-" in part:
|
|
46
|
+
x, y = part.split("-", 1)
|
|
47
|
+
a, b = _val(x, names, lo), _val(y, names, lo)
|
|
48
|
+
else:
|
|
49
|
+
a = _val(part, names, lo)
|
|
50
|
+
b = hi if step > 1 else a
|
|
51
|
+
if a < lo or b > hi or a > b:
|
|
52
|
+
raise DktError("Value out of range (%d-%d): %s" % (lo, hi, part))
|
|
53
|
+
for v in range(a, b + 1, step):
|
|
54
|
+
out.add(0 if (hi == 7 and v == 7) else v)
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse(expr: str):
|
|
59
|
+
expr = ALIASES.get(expr.strip().lower(), expr.strip())
|
|
60
|
+
f = expr.split()
|
|
61
|
+
if len(f) != 5:
|
|
62
|
+
raise DktError("Expected 5 fields (min hour day month weekday), got %d." % len(f))
|
|
63
|
+
return {
|
|
64
|
+
"min": parse_field(f[0], 0, 59), "hour": parse_field(f[1], 0, 23),
|
|
65
|
+
"dom": parse_field(f[2], 1, 31), "mon": parse_field(f[3], 1, 12, MONTHS),
|
|
66
|
+
"dow": parse_field(f[4], 0, 7, DAYS), "raw": f,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def next_runs(spec, n: int, start: datetime):
|
|
71
|
+
runs = []
|
|
72
|
+
day = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
73
|
+
dom_star, dow_star = spec["raw"][2] in ("*", "?"), spec["raw"][4] in ("*", "?")
|
|
74
|
+
for _ in range(366 * 8):
|
|
75
|
+
if day.month in spec["mon"]:
|
|
76
|
+
dom_ok = day.day in spec["dom"]
|
|
77
|
+
dow_ok = ((day.weekday() + 1) % 7) in spec["dow"]
|
|
78
|
+
if dom_star and dow_star:
|
|
79
|
+
day_ok = True
|
|
80
|
+
elif dom_star:
|
|
81
|
+
day_ok = dow_ok
|
|
82
|
+
elif dow_star:
|
|
83
|
+
day_ok = dom_ok
|
|
84
|
+
else:
|
|
85
|
+
day_ok = dom_ok or dow_ok
|
|
86
|
+
if day_ok:
|
|
87
|
+
for h in sorted(spec["hour"]):
|
|
88
|
+
for m in sorted(spec["min"]):
|
|
89
|
+
t = day.replace(hour=h, minute=m)
|
|
90
|
+
if t > start:
|
|
91
|
+
runs.append(t)
|
|
92
|
+
if len(runs) >= n:
|
|
93
|
+
return runs
|
|
94
|
+
day += timedelta(days=1)
|
|
95
|
+
return runs
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _list(vals, names=None):
|
|
99
|
+
vals = sorted(vals)
|
|
100
|
+
return ", ".join(names[v] if names else str(v) for v in vals)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def explain(spec) -> str:
|
|
104
|
+
mi, ho, dm, mo, dw = spec["raw"]
|
|
105
|
+
parts = []
|
|
106
|
+
if mi == "*" and ho == "*":
|
|
107
|
+
parts.append("every minute")
|
|
108
|
+
elif mi.startswith("*/") and ho == "*":
|
|
109
|
+
parts.append("every %s minutes" % mi[2:])
|
|
110
|
+
elif ho == "*" and mi.isdigit():
|
|
111
|
+
parts.append("at minute %s of every hour" % mi)
|
|
112
|
+
elif mi.isdigit() and ho.isdigit():
|
|
113
|
+
parts.append("at %02d:%02d" % (int(ho), int(mi)))
|
|
114
|
+
else:
|
|
115
|
+
parts.append("at minute(s) %s past hour(s) %s" % (_list(spec["min"]), _list(spec["hour"])))
|
|
116
|
+
if dm not in ("*", "?"):
|
|
117
|
+
parts.append("on day-of-month %s" % _list(spec["dom"]))
|
|
118
|
+
if mo != "*":
|
|
119
|
+
parts.append("in %s" % _list(spec["mon"], [""] + [m.title() for m in MONTHS]))
|
|
120
|
+
if dw not in ("*", "?"):
|
|
121
|
+
parts.append("on %s" % _list(spec["dow"], [d.title() for d in DAYS]))
|
|
122
|
+
return ", ".join(parts)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def run(args) -> int:
|
|
126
|
+
expr = " ".join(args.expr)
|
|
127
|
+
spec = parse(expr)
|
|
128
|
+
print()
|
|
129
|
+
print(" " + c(explain(spec), "bold bcyan"))
|
|
130
|
+
print()
|
|
131
|
+
rows = []
|
|
132
|
+
now = datetime.now()
|
|
133
|
+
for t in next_runs(spec, args.n, now):
|
|
134
|
+
d = t - now
|
|
135
|
+
mins = int(d.total_seconds() // 60)
|
|
136
|
+
rel = "in %dd %dh" % (mins // 1440, (mins % 1440) // 60) if mins >= 1440 else \
|
|
137
|
+
"in %dh %dm" % (mins // 60, mins % 60) if mins >= 60 else "in %dm" % mins
|
|
138
|
+
rows.append((t.strftime("%a %Y-%m-%d %H:%M"), c(rel, "gray")))
|
|
139
|
+
if not rows:
|
|
140
|
+
raise DktError("That expression never runs (check day/month combination).")
|
|
141
|
+
kv(rows, key_color="bold")
|
|
142
|
+
print()
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def register(sub, common) -> None:
|
|
147
|
+
p = sub.add_parser("cron", parents=[common], help=META[3],
|
|
148
|
+
description="Explain a crontab expression and list upcoming runs.",
|
|
149
|
+
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
150
|
+
p.add_argument("expr", nargs="+", help='e.g. "*/5 * * * *" or @daily')
|
|
151
|
+
p.add_argument("-n", type=int, default=5, help="how many upcoming runs (default 5)")
|
|
152
|
+
p.set_defaults(func=run)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""dkt doctor (dr) - system info and a check of the dev tools installed on this machine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from ..utils import box, c, human_size, kv, sym, table, tool_version, Spinner, heading
|
|
11
|
+
|
|
12
|
+
META = ("System", "doctor", "dr", "system info + which dev tools are installed (git, node, docker...)")
|
|
13
|
+
|
|
14
|
+
EPILOG = """\
|
|
15
|
+
examples:
|
|
16
|
+
dkt dr full report
|
|
17
|
+
dkt dr --tools only the tool checklist
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
TOOLS = [
|
|
21
|
+
("Python", ["python", "--version"], "python"), ("pip", ["pip", "--version"], "pip"),
|
|
22
|
+
("Git", ["git", "--version"], "git"), ("GitHub CLI", ["gh", "--version"], "gh"),
|
|
23
|
+
("Node.js", ["node", "--version"], "node"), ("npm", ["npm", "--version"], "npm"),
|
|
24
|
+
("pnpm", ["pnpm", "--version"], "pnpm"), ("Yarn", ["yarn", "--version"], "yarn"),
|
|
25
|
+
("Bun", ["bun", "--version"], "bun"), ("Deno", ["deno", "--version"], "deno"),
|
|
26
|
+
("Docker", ["docker", "--version"], "docker"), ("Go", ["go", "version"], "go"),
|
|
27
|
+
("Rust (cargo)", ["cargo", "--version"], "cargo"), ("Java", ["java", "-version"], "java"),
|
|
28
|
+
(".NET", ["dotnet", "--version"], "dotnet"), ("PHP", ["php", "--version"], "php"),
|
|
29
|
+
("Ruby", ["ruby", "--version"], "ruby"), ("CMake", ["cmake", "--version"], "cmake"),
|
|
30
|
+
("Make", ["make", "--version"], "make"), ("curl", ["curl", "--version"], "curl"),
|
|
31
|
+
("SSH", ["ssh", "-V"], "ssh"), ("VS Code", ["code", "--version"], "code"),
|
|
32
|
+
("Ollama", ["ollama", "--version"], "ollama"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run(args) -> int:
|
|
37
|
+
from .. import __version__
|
|
38
|
+
if not args.tools:
|
|
39
|
+
try:
|
|
40
|
+
disk = shutil.disk_usage(os.getcwd())
|
|
41
|
+
disk_s = "%s free of %s" % (human_size(disk.free), human_size(disk.total))
|
|
42
|
+
except Exception:
|
|
43
|
+
disk_s = "?"
|
|
44
|
+
box("system", [
|
|
45
|
+
"%s %s %s" % (c("os ", "gray"), platform.system(), platform.release()),
|
|
46
|
+
"%s %s" % (c("machine ", "gray"), platform.machine()),
|
|
47
|
+
"%s %s cores" % (c("cpu ", "gray"), os.cpu_count()),
|
|
48
|
+
"%s %s" % (c("disk ", "gray"), disk_s),
|
|
49
|
+
"%s %s (%s)" % (c("python ", "gray"), platform.python_version(), sys.executable),
|
|
50
|
+
"%s %s" % (c("shell ", "gray"), os.environ.get("SHELL") or os.environ.get("COMSPEC") or "?"),
|
|
51
|
+
"%s %s" % (c("dkt ", "gray"), __version__),
|
|
52
|
+
])
|
|
53
|
+
print()
|
|
54
|
+
rows = []
|
|
55
|
+
found = 0
|
|
56
|
+
with Spinner("checking tools"):
|
|
57
|
+
for label, cmd, exe in TOOLS:
|
|
58
|
+
ver = tool_version(cmd) if shutil.which(exe) else None
|
|
59
|
+
if ver:
|
|
60
|
+
found += 1
|
|
61
|
+
rows.append((c(sym("ok"), "bgreen"), label, ver[:60]))
|
|
62
|
+
else:
|
|
63
|
+
rows.append((c(sym("fail"), "gray"), c(label, "gray"), c("not found", "gray")))
|
|
64
|
+
table(["", "TOOL", "VERSION"], rows)
|
|
65
|
+
print("\n %d of %d tools found\n" % (found, len(TOOLS)))
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def register(sub, common) -> None:
|
|
70
|
+
p = sub.add_parser("doctor", aliases=["dr"], parents=[common], help=META[3],
|
|
71
|
+
description="Inspect this machine's developer setup.", epilog=EPILOG,
|
|
72
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
73
|
+
p.add_argument("--tools", action="store_true", help="only the tool checklist")
|
|
74
|
+
p.set_defaults(func=run)
|
devkittools/tools/du.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""dkt du - what is eating my disk? Biggest folders and files with bars."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..utils import Spinner, bar, c, human_size, resolve_folder, sym
|
|
9
|
+
|
|
10
|
+
META = ("Project & Code", "du", "", "biggest folders & files with bars (full disk, no gitignore)")
|
|
11
|
+
|
|
12
|
+
EPILOG = """\
|
|
13
|
+
examples:
|
|
14
|
+
dkt du biggest items inside the current folder
|
|
15
|
+
dkt du ~/Downloads -n 20
|
|
16
|
+
dkt du --files largest individual files anywhere below
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(args) -> int:
|
|
21
|
+
root = resolve_folder(args.folder)
|
|
22
|
+
sizes = {}
|
|
23
|
+
big_files = []
|
|
24
|
+
with Spinner("measuring %s" % root.name):
|
|
25
|
+
try:
|
|
26
|
+
entries = list(os.scandir(str(root)))
|
|
27
|
+
except PermissionError:
|
|
28
|
+
entries = []
|
|
29
|
+
for e in entries:
|
|
30
|
+
total = 0
|
|
31
|
+
try:
|
|
32
|
+
if e.is_file(follow_symlinks=False):
|
|
33
|
+
total = e.stat(follow_symlinks=False).st_size
|
|
34
|
+
big_files.append((total, Path(e.path)))
|
|
35
|
+
elif e.is_dir(follow_symlinks=False):
|
|
36
|
+
for cur, _, files in os.walk(e.path):
|
|
37
|
+
for f in files:
|
|
38
|
+
try:
|
|
39
|
+
s = os.lstat(os.path.join(cur, f)).st_size
|
|
40
|
+
except OSError:
|
|
41
|
+
continue
|
|
42
|
+
total += s
|
|
43
|
+
if args.files:
|
|
44
|
+
big_files.append((s, Path(cur, f)))
|
|
45
|
+
except OSError:
|
|
46
|
+
continue
|
|
47
|
+
sizes[e.name + ("/" if e.is_dir(follow_symlinks=False) else "")] = total
|
|
48
|
+
if args.files:
|
|
49
|
+
rows = sorted(big_files, key=lambda x: -x[0])[:args.n]
|
|
50
|
+
mx = rows[0][0] if rows else 1
|
|
51
|
+
for s, p in rows:
|
|
52
|
+
print(" %9s %s %s" % (human_size(s), c(bar(s, mx, 16), "bcyan"), p.relative_to(root)))
|
|
53
|
+
return 0
|
|
54
|
+
items = sorted(sizes.items(), key=lambda kv: -kv[1])[:args.n]
|
|
55
|
+
total = sum(sizes.values())
|
|
56
|
+
mx = items[0][1] if items else 1
|
|
57
|
+
for name, s in items:
|
|
58
|
+
pct = (100.0 * s / total) if total else 0
|
|
59
|
+
print(" %9s %s %4.1f%% %s" % (human_size(s), c(bar(s, mx, 20), "bcyan"), pct,
|
|
60
|
+
c(name, "bold bblue") if name.endswith("/") else name))
|
|
61
|
+
print(c("\n total %s" % human_size(total), "gray"))
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def register(sub, common) -> None:
|
|
66
|
+
p = sub.add_parser("du", parents=[common], help=META[3],
|
|
67
|
+
description="Disk usage overview.", epilog=EPILOG,
|
|
68
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
69
|
+
p.add_argument("folder", nargs="?")
|
|
70
|
+
p.add_argument("-n", type=int, default=15, help="rows to show (default 15)")
|
|
71
|
+
p.add_argument("--files", action="store_true", help="list the biggest individual files")
|
|
72
|
+
p.set_defaults(func=run)
|
devkittools/tools/enc.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""dkt enc (e) and dkt jwt - encode/decode base64, url, hex, html; decode JWTs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import html
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
from ..utils import DktError, c, info, output_result, read_input, kv, warn, ok
|
|
14
|
+
|
|
15
|
+
META = ("Generate & Convert", "enc", "e", "base64 / url / hex / html encode & decode, plus JWT decoder")
|
|
16
|
+
|
|
17
|
+
EPILOG = """\
|
|
18
|
+
examples:
|
|
19
|
+
dkt enc b64 "hello" aGVsbG8=
|
|
20
|
+
dkt enc b64 aGVsbG8= -d hello
|
|
21
|
+
dkt enc url "a b&c=d" url-encode
|
|
22
|
+
dkt enc hex "hi" --copy
|
|
23
|
+
dkt jwt eyJhbGciOi... decode a JWT (signature is NOT verified)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _b64d(s: str, urlsafe: bool) -> bytes:
|
|
28
|
+
s = "".join(s.split())
|
|
29
|
+
s += "=" * (-len(s) % 4)
|
|
30
|
+
return base64.urlsafe_b64decode(s) if urlsafe else base64.b64decode(s)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def convert(kind: str, text: str, decode: bool) -> str:
|
|
34
|
+
try:
|
|
35
|
+
if kind in ("b64", "b64url"):
|
|
36
|
+
urlsafe = kind == "b64url"
|
|
37
|
+
if decode:
|
|
38
|
+
return _b64d(text, urlsafe).decode("utf-8", errors="replace")
|
|
39
|
+
raw = text.encode("utf-8")
|
|
40
|
+
return (base64.urlsafe_b64encode(raw) if urlsafe else base64.b64encode(raw)).decode()
|
|
41
|
+
if kind == "url":
|
|
42
|
+
return urllib.parse.unquote_plus(text) if decode else urllib.parse.quote(text, safe="")
|
|
43
|
+
if kind == "hex":
|
|
44
|
+
if decode:
|
|
45
|
+
return bytes.fromhex("".join(text.split())).decode("utf-8", errors="replace")
|
|
46
|
+
return text.encode("utf-8").hex()
|
|
47
|
+
if kind == "html":
|
|
48
|
+
return html.unescape(text) if decode else html.escape(text)
|
|
49
|
+
except (binascii.Error, ValueError) as e:
|
|
50
|
+
raise DktError("Could not decode as %s: %s" % (kind, e))
|
|
51
|
+
raise DktError("Unknown kind %s" % kind)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run_enc(args) -> int:
|
|
55
|
+
text = read_input(args.text, args.file, args.paste, "text")
|
|
56
|
+
if args.file is None and args.text is None:
|
|
57
|
+
text = text.rstrip("\n")
|
|
58
|
+
output_result(convert(args.kind, text, args.decode), copy=args.copy)
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ts(label: str, value) -> str:
|
|
63
|
+
try:
|
|
64
|
+
dt = datetime.fromtimestamp(int(value), tz=timezone.utc)
|
|
65
|
+
delta = int(value) - int(time.time())
|
|
66
|
+
rel = ("in %s" % _dur(delta)) if delta > 0 else ("%s ago" % _dur(-delta))
|
|
67
|
+
return "%s %s %s" % (value, dt.strftime("%Y-%m-%d %H:%M:%S UTC"), c("(%s)" % rel, "gray"))
|
|
68
|
+
except Exception:
|
|
69
|
+
return str(value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _dur(s: int) -> str:
|
|
73
|
+
for unit, n in (("d", 86400), ("h", 3600), ("m", 60)):
|
|
74
|
+
if s >= n:
|
|
75
|
+
return "%d%s" % (s // n, unit)
|
|
76
|
+
return "%ds" % s
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run_jwt(args) -> int:
|
|
80
|
+
token = read_input(args.token, None, args.paste, "token").strip()
|
|
81
|
+
if token.lower().startswith("bearer "):
|
|
82
|
+
token = token[7:].strip()
|
|
83
|
+
parts = token.split(".")
|
|
84
|
+
if len(parts) < 2:
|
|
85
|
+
raise DktError("That doesn't look like a JWT (expected header.payload.signature).")
|
|
86
|
+
try:
|
|
87
|
+
header = json.loads(_b64d(parts[0], True))
|
|
88
|
+
payload = json.loads(_b64d(parts[1], True))
|
|
89
|
+
except Exception:
|
|
90
|
+
raise DktError("Could not decode the JWT parts - is the token complete?")
|
|
91
|
+
from .jsontool import colorize
|
|
92
|
+
print(c("HEADER", "bold bcyan"))
|
|
93
|
+
print(colorize(json.dumps(header, indent=2)))
|
|
94
|
+
print(c("PAYLOAD", "bold bmagenta"))
|
|
95
|
+
print(colorize(json.dumps(payload, indent=2)))
|
|
96
|
+
rows = []
|
|
97
|
+
for k, label in (("iat", "issued"), ("nbf", "not before"), ("exp", "expires")):
|
|
98
|
+
if k in payload:
|
|
99
|
+
rows.append((label, _ts(k, payload[k])))
|
|
100
|
+
if rows:
|
|
101
|
+
print()
|
|
102
|
+
kv(rows)
|
|
103
|
+
if "exp" in payload:
|
|
104
|
+
try:
|
|
105
|
+
if int(payload["exp"]) < time.time():
|
|
106
|
+
warn("token is EXPIRED")
|
|
107
|
+
else:
|
|
108
|
+
ok("token not expired")
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
info(c("note: signature is not verified", "gray"))
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def register(sub, common) -> None:
|
|
116
|
+
p = sub.add_parser("enc", aliases=["e"], parents=[common], help=META[3],
|
|
117
|
+
description="Encode or decode text.", epilog=EPILOG,
|
|
118
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
119
|
+
p.add_argument("kind", choices=["b64", "b64url", "url", "hex", "html"])
|
|
120
|
+
p.add_argument("text", nargs="?", help="text (or pipe stdin)")
|
|
121
|
+
p.add_argument("-d", "--decode", action="store_true", help="decode instead of encode")
|
|
122
|
+
p.add_argument("-f", "--file", metavar="FILE")
|
|
123
|
+
p.add_argument("--paste", action="store_true", help="read from clipboard")
|
|
124
|
+
p.add_argument("--copy", action="store_true")
|
|
125
|
+
p.set_defaults(func=run_enc)
|
|
126
|
+
|
|
127
|
+
j = sub.add_parser("jwt", parents=[common], help="decode a JSON Web Token",
|
|
128
|
+
description="Decode a JWT and show whether it has expired.")
|
|
129
|
+
j.add_argument("token", nargs="?")
|
|
130
|
+
j.add_argument("--paste", action="store_true", help="read token from clipboard")
|
|
131
|
+
j.set_defaults(func=run_jwt)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""dkt env - inspect environment variables, PATH and compare .env files."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..utils import DktError, c, fail, ok, warn, table, sym, resolve_folder, kv, heading
|
|
10
|
+
|
|
11
|
+
META = ("Project & Code", "env", "", "env vars, PATH doctor, .env vs .env.example check")
|
|
12
|
+
|
|
13
|
+
EPILOG = """\
|
|
14
|
+
examples:
|
|
15
|
+
dkt env list variables (secrets masked)
|
|
16
|
+
dkt env HOME one variable
|
|
17
|
+
dkt env -g java variables matching 'java'
|
|
18
|
+
dkt env path PATH entries, flagging missing / duplicate ones
|
|
19
|
+
dkt env check compare .env with .env.example in this folder
|
|
20
|
+
dkt env show --reveal don't mask secret-looking values
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
SECRET_RE = re.compile(r"(key|secret|token|passw|pwd|auth|credential|private)", re.I)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def mask(name: str, value: str, reveal: bool) -> str:
|
|
27
|
+
if reveal or not SECRET_RE.search(name) or not value:
|
|
28
|
+
return value
|
|
29
|
+
return value[:2] + "*" * max(4, min(len(value) - 2, 12)) if len(value) > 4 else "****"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_env_file(path: Path):
|
|
33
|
+
data = {}
|
|
34
|
+
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
35
|
+
line = raw.strip()
|
|
36
|
+
if not line or line.startswith("#"):
|
|
37
|
+
continue
|
|
38
|
+
if line.startswith("export "):
|
|
39
|
+
line = line[7:]
|
|
40
|
+
if "=" not in line:
|
|
41
|
+
continue
|
|
42
|
+
k, v = line.split("=", 1)
|
|
43
|
+
data[k.strip()] = v.strip().strip("'\"")
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_list(args) -> int:
|
|
48
|
+
items = sorted(os.environ.items(), key=lambda kv_: kv_[0].lower())
|
|
49
|
+
if args.name:
|
|
50
|
+
want = args.name
|
|
51
|
+
hit = [(k, v) for k, v in items if k.lower() == want.lower()]
|
|
52
|
+
if not hit:
|
|
53
|
+
raise DktError("%s is not set." % want)
|
|
54
|
+
print(hit[0][1])
|
|
55
|
+
return 0
|
|
56
|
+
if args.grep:
|
|
57
|
+
g = args.grep.lower()
|
|
58
|
+
items = [(k, v) for k, v in items if g in k.lower() or g in v.lower()]
|
|
59
|
+
rows = [(c(k, "bcyan"), mask(k, v, args.reveal)[:90]) for k, v in items]
|
|
60
|
+
if not rows:
|
|
61
|
+
print(" no matches")
|
|
62
|
+
return 0
|
|
63
|
+
table(["NAME", "VALUE"], rows)
|
|
64
|
+
print("\n %d variables" % len(rows))
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_path(args) -> int:
|
|
69
|
+
sep = ";" if os.name == "nt" else ":"
|
|
70
|
+
entries = [e for e in os.environ.get("PATH", "").split(sep) if e]
|
|
71
|
+
seen = set()
|
|
72
|
+
rows = []
|
|
73
|
+
bad = 0
|
|
74
|
+
for i, e in enumerate(entries, 1):
|
|
75
|
+
norm = os.path.normcase(os.path.normpath(os.path.expandvars(e)))
|
|
76
|
+
status = c(sym("ok"), "bgreen")
|
|
77
|
+
note = ""
|
|
78
|
+
if not os.path.isdir(os.path.expandvars(e)):
|
|
79
|
+
status, note = c(sym("fail"), "bred"), c("missing", "red")
|
|
80
|
+
bad += 1
|
|
81
|
+
elif norm in seen:
|
|
82
|
+
status, note = c(sym("warn"), "byellow"), c("duplicate", "yellow")
|
|
83
|
+
bad += 1
|
|
84
|
+
seen.add(norm)
|
|
85
|
+
rows.append((i, status, e, note))
|
|
86
|
+
table(["#", "", "PATH ENTRY", ""], rows)
|
|
87
|
+
print()
|
|
88
|
+
(warn if bad else ok)("%d entries, %d problem%s" % (len(entries), bad, "" if bad == 1 else "s"))
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_check(args) -> int:
|
|
93
|
+
folder = resolve_folder(args.folder)
|
|
94
|
+
env_file = folder / args.env_file
|
|
95
|
+
example = None
|
|
96
|
+
for name in (args.example, ".env.example", ".env.sample", ".env.template", "example.env"):
|
|
97
|
+
if name and (folder / name).is_file():
|
|
98
|
+
example = folder / name
|
|
99
|
+
break
|
|
100
|
+
if not env_file.is_file():
|
|
101
|
+
raise DktError("No %s in %s" % (args.env_file, folder))
|
|
102
|
+
if not example:
|
|
103
|
+
raise DktError("No .env.example / .env.sample found to compare against.")
|
|
104
|
+
have, want = parse_env_file(env_file), parse_env_file(example)
|
|
105
|
+
missing = sorted(set(want) - set(have))
|
|
106
|
+
extra = sorted(set(have) - set(want))
|
|
107
|
+
empty = sorted(k for k, v in have.items() if k in want and v == "")
|
|
108
|
+
print(" comparing %s vs %s" % (c(env_file.name, "bold"), c(example.name, "bold")))
|
|
109
|
+
print()
|
|
110
|
+
for k in missing:
|
|
111
|
+
fail("missing in %s: %s" % (env_file.name, c(k, "bold")))
|
|
112
|
+
for k in empty:
|
|
113
|
+
warn("empty value: %s" % c(k, "bold"))
|
|
114
|
+
for k in extra:
|
|
115
|
+
print(" %s not in %s: %s" % (c(sym("dot"), "gray"), example.name, k))
|
|
116
|
+
if not (missing or empty):
|
|
117
|
+
ok("all %d expected variables are set" % len(want))
|
|
118
|
+
return 1 if missing else 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def register(sub, common) -> None:
|
|
122
|
+
p = sub.add_parser("env", parents=[common], help=META[3],
|
|
123
|
+
description="Inspect environment variables and .env files.",
|
|
124
|
+
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
125
|
+
p.add_argument("name", nargs="?", help="variable to print, or: path | check | show")
|
|
126
|
+
p.add_argument("-g", "--grep", metavar="TEXT", help="filter by name/value")
|
|
127
|
+
p.add_argument("--reveal", action="store_true", help="don't mask secret-looking values")
|
|
128
|
+
p.add_argument("-f", "--folder", metavar="PATH", help="project folder for 'check'")
|
|
129
|
+
p.add_argument("--env-file", default=".env", help="file to check (default .env)")
|
|
130
|
+
p.add_argument("--example", help="template file (default: auto-detect .env.example)")
|
|
131
|
+
p.set_defaults(func=_dispatch)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _dispatch(args) -> int:
|
|
135
|
+
if args.name == "path":
|
|
136
|
+
return cmd_path(args)
|
|
137
|
+
if args.name == "check":
|
|
138
|
+
return cmd_check(args)
|
|
139
|
+
if args.name == "show":
|
|
140
|
+
args.name = None
|
|
141
|
+
return cmd_list(args)
|