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,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)
@@ -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)
@@ -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)