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/utils.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""Shared helpers for devkittools."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Optional, Sequence, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DktError(Exception):
|
|
18
|
+
"""A user-facing error. Printed without a traceback."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ----------------------------------------------------------------- console
|
|
22
|
+
|
|
23
|
+
_COLOR = False
|
|
24
|
+
_CODES = {
|
|
25
|
+
"black": "30", "red": "31", "green": "32", "yellow": "33", "blue": "34",
|
|
26
|
+
"magenta": "35", "cyan": "36", "white": "37", "gray": "90",
|
|
27
|
+
"bred": "91", "bgreen": "92", "byellow": "93", "bblue": "94",
|
|
28
|
+
"bmagenta": "95", "bcyan": "96", "bwhite": "97",
|
|
29
|
+
"bold": "1", "dim": "2", "italic": "3", "underline": "4", "reverse": "7",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def setup_console() -> None:
|
|
34
|
+
"""Make output UTF-8 safe and enable ANSI colors when possible."""
|
|
35
|
+
global _COLOR
|
|
36
|
+
for stream in (sys.stdout, sys.stderr):
|
|
37
|
+
try:
|
|
38
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
if os.environ.get("NO_COLOR"):
|
|
42
|
+
_COLOR = False
|
|
43
|
+
return
|
|
44
|
+
if sys.stdout.isatty():
|
|
45
|
+
if os.name == "nt":
|
|
46
|
+
os.system("") # enables ANSI escape processing on Windows 10+
|
|
47
|
+
_COLOR = True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def c(text, color: str) -> str:
|
|
51
|
+
"""Colorize text. `color` may combine styles: c("hi", "bold cyan")."""
|
|
52
|
+
if not _COLOR:
|
|
53
|
+
return str(text)
|
|
54
|
+
codes = ";".join(_CODES[x] for x in color.split())
|
|
55
|
+
return "\033[%sm%s\033[0m" % (codes, text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def info(msg: str = "") -> None:
|
|
59
|
+
"""Status output goes to stderr so stdout stays clean for piping."""
|
|
60
|
+
print(msg, file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def print_table(headers: Sequence[str], rows: Sequence[Sequence[object]], indent: str = " ") -> None:
|
|
64
|
+
widths = [len(h) for h in headers]
|
|
65
|
+
srows = [[str(x) for x in r] for r in rows]
|
|
66
|
+
for r in srows:
|
|
67
|
+
for i, cell in enumerate(r):
|
|
68
|
+
widths[i] = max(widths[i], len(cell))
|
|
69
|
+
head = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers))
|
|
70
|
+
print(indent + c(head, "bold"))
|
|
71
|
+
print(indent + c(" ".join("-" * w for w in widths), "dim"))
|
|
72
|
+
for r in srows:
|
|
73
|
+
print(indent + " ".join(cell.ljust(widths[i]) for i, cell in enumerate(r)))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def bar(value: float, maximum: float, width: int = 20) -> str:
|
|
77
|
+
if maximum <= 0:
|
|
78
|
+
filled = 0
|
|
79
|
+
else:
|
|
80
|
+
filled = int(round(width * value / maximum))
|
|
81
|
+
return "█" * filled + "░" * (width - filled)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def human_size(n: float) -> str:
|
|
85
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
86
|
+
if n < 1024 or unit == "GB":
|
|
87
|
+
return ("%d %s" % (n, unit)) if unit == "B" else ("%.1f %s" % (n, unit))
|
|
88
|
+
n /= 1024.0
|
|
89
|
+
return "%d B" % n
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def human_num(n: int) -> str:
|
|
93
|
+
return "{:,}".format(n)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def str2bool(value) -> bool:
|
|
97
|
+
if isinstance(value, bool):
|
|
98
|
+
return value
|
|
99
|
+
v = str(value).strip().lower()
|
|
100
|
+
if v in ("1", "true", "t", "yes", "y", "on"):
|
|
101
|
+
return True
|
|
102
|
+
if v in ("0", "false", "f", "no", "n", "off", ""):
|
|
103
|
+
return False
|
|
104
|
+
raise DktError("Expected true/false but got '%s'" % value)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ----------------------------------------------------------------- processes
|
|
108
|
+
|
|
109
|
+
def decode_output(data: bytes) -> str:
|
|
110
|
+
if os.name == "nt":
|
|
111
|
+
try:
|
|
112
|
+
return data.decode("oem", errors="replace")
|
|
113
|
+
except LookupError:
|
|
114
|
+
pass
|
|
115
|
+
return data.decode("utf-8", errors="replace")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run_capture(cmd: Sequence[str]) -> Tuple[int, str, str]:
|
|
119
|
+
"""Run a command, return (returncode, stdout, stderr). Never raises on non-zero."""
|
|
120
|
+
try:
|
|
121
|
+
r = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
122
|
+
stdin=subprocess.DEVNULL)
|
|
123
|
+
except FileNotFoundError:
|
|
124
|
+
raise DktError("Command not found: %s" % cmd[0])
|
|
125
|
+
return r.returncode, decode_output(r.stdout), decode_output(r.stderr)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ----------------------------------------------------------------- folders / git
|
|
129
|
+
|
|
130
|
+
def resolve_folder(value: Optional[str]) -> Path:
|
|
131
|
+
p = Path(value).expanduser() if value else Path.cwd()
|
|
132
|
+
p = p.resolve()
|
|
133
|
+
if not p.is_dir():
|
|
134
|
+
raise DktError("Folder not found: %s" % p)
|
|
135
|
+
return p
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def git(folder: Path, *args: str, check: bool = True) -> str:
|
|
139
|
+
"""Run git inside `folder` and return stdout (UTF-8)."""
|
|
140
|
+
cmd = ["git", "-C", str(folder), "-c", "core.quotepath=false"] + list(args)
|
|
141
|
+
try:
|
|
142
|
+
r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
143
|
+
stdin=subprocess.DEVNULL)
|
|
144
|
+
except FileNotFoundError:
|
|
145
|
+
raise DktError("git is not installed or not on PATH. Get it at https://git-scm.com")
|
|
146
|
+
if r.returncode != 0:
|
|
147
|
+
if check:
|
|
148
|
+
msg = r.stderr.decode("utf-8", errors="replace").strip()
|
|
149
|
+
raise DktError(msg or "git %s failed" % " ".join(args))
|
|
150
|
+
return ""
|
|
151
|
+
return r.stdout.decode("utf-8", errors="replace")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def is_git_repo(folder: Path) -> bool:
|
|
155
|
+
try:
|
|
156
|
+
return git(folder, "rev-parse", "--is-inside-work-tree", check=False).strip() == "true"
|
|
157
|
+
except DktError:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def require_git_repo(folder: Path) -> None:
|
|
162
|
+
if not is_git_repo(folder):
|
|
163
|
+
raise DktError("%s is not inside a git repository." % folder)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
IGNORE_DIRS = {
|
|
167
|
+
".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv", "env",
|
|
168
|
+
".idea", ".vscode", "dist", "build", ".mypy_cache", ".pytest_cache", ".tox",
|
|
169
|
+
"target", ".next", ".gradle", ".cache", "coverage",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def list_project_files(folder: Path) -> List[Path]:
|
|
174
|
+
"""Relative paths of project files. Respects .gitignore inside git repos."""
|
|
175
|
+
if is_git_repo(folder):
|
|
176
|
+
try:
|
|
177
|
+
r = subprocess.run(
|
|
178
|
+
["git", "-C", str(folder), "ls-files", "-co", "--exclude-standard", "-z"],
|
|
179
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, check=True)
|
|
180
|
+
names = [n for n in r.stdout.decode("utf-8", errors="replace").split("\0") if n]
|
|
181
|
+
files = [Path(n) for n in names if (folder / n).is_file()]
|
|
182
|
+
return sorted(set(files))
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
files = []
|
|
186
|
+
for root, dirs, fnames in os.walk(str(folder)):
|
|
187
|
+
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.endswith(".egg-info")]
|
|
188
|
+
for f in fnames:
|
|
189
|
+
files.append(Path(root, f).relative_to(folder))
|
|
190
|
+
return sorted(files)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def is_binary_chunk(chunk: bytes) -> bool:
|
|
194
|
+
return b"\0" in chunk
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ----------------------------------------------------------------- clipboard
|
|
198
|
+
|
|
199
|
+
def copy_to_clipboard(text: str) -> bool:
|
|
200
|
+
try:
|
|
201
|
+
if os.name == "nt":
|
|
202
|
+
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8") as f:
|
|
203
|
+
f.write(text)
|
|
204
|
+
tmp = f.name
|
|
205
|
+
try:
|
|
206
|
+
cmd = "Get-Content -Raw -Encoding UTF8 -LiteralPath '%s' | Set-Clipboard" % tmp
|
|
207
|
+
r = subprocess.run(["powershell", "-NoProfile", "-Command", cmd],
|
|
208
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
209
|
+
return r.returncode == 0
|
|
210
|
+
finally:
|
|
211
|
+
try:
|
|
212
|
+
os.unlink(tmp)
|
|
213
|
+
except OSError:
|
|
214
|
+
pass
|
|
215
|
+
if sys.platform == "darwin" and shutil.which("pbcopy"):
|
|
216
|
+
subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True)
|
|
217
|
+
return True
|
|
218
|
+
for tool in (["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]):
|
|
219
|
+
if shutil.which(tool[0]):
|
|
220
|
+
subprocess.run(tool, input=text.encode("utf-8"), check=True)
|
|
221
|
+
return True
|
|
222
|
+
except Exception:
|
|
223
|
+
return False
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ================================================================= UI toolkit
|
|
228
|
+
# Everything below is pure stdlib: boxes, banners, spinners, prompts, config.
|
|
229
|
+
|
|
230
|
+
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def color_enabled() -> bool:
|
|
234
|
+
return _COLOR
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def vlen(text: str) -> int:
|
|
238
|
+
"""Visible length of a string (ANSI escapes removed)."""
|
|
239
|
+
return len(_ANSI_RE.sub("", text))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def strip_ansi(text: str) -> str:
|
|
243
|
+
return _ANSI_RE.sub("", text)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def term_width(default: int = 80) -> int:
|
|
247
|
+
try:
|
|
248
|
+
return max(40, min(shutil.get_terminal_size((default, 24)).columns, 140))
|
|
249
|
+
except Exception:
|
|
250
|
+
return default
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
_UNICODE_SYMS = {
|
|
254
|
+
"ok": "✔", "fail": "✘", "warn": "▲", "arrow": "➜", "dot": "•", "info": "ℹ",
|
|
255
|
+
"tl": "╭", "tr": "╮", "bl": "╰", "br": "╯", "h": "─", "v": "│",
|
|
256
|
+
"t": "├", "l": "└", "pipe": "│",
|
|
257
|
+
}
|
|
258
|
+
_ASCII_SYMS = {
|
|
259
|
+
"ok": "+", "fail": "x", "warn": "!", "arrow": ">", "dot": "*", "info": "i",
|
|
260
|
+
"tl": "+", "tr": "+", "bl": "+", "br": "+", "h": "-", "v": "|",
|
|
261
|
+
"t": "|", "l": "`", "pipe": "|",
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def sym(name: str) -> str:
|
|
266
|
+
if os.environ.get("DKT_ASCII"):
|
|
267
|
+
return _ASCII_SYMS[name]
|
|
268
|
+
enc = (getattr(sys.stdout, "encoding", None) or "").lower()
|
|
269
|
+
return (_UNICODE_SYMS if "utf" in enc else _ASCII_SYMS)[name]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def ok(msg: str) -> None:
|
|
273
|
+
print("%s %s" % (c(sym("ok"), "bgreen"), msg))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def warn(msg: str) -> None:
|
|
277
|
+
print("%s %s" % (c(sym("warn"), "byellow"), msg))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def fail(msg: str) -> None:
|
|
281
|
+
print("%s %s" % (c(sym("fail"), "bred"), msg))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def note(msg: str) -> None:
|
|
285
|
+
print("%s %s" % (c(sym("info"), "bcyan"), msg))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def heading(title: str, subtitle: str = "") -> None:
|
|
289
|
+
"""Section heading with a rule underneath."""
|
|
290
|
+
print()
|
|
291
|
+
print(" " + c(title, "bold bcyan") + ((" " + c(subtitle, "gray")) if subtitle else ""))
|
|
292
|
+
print(" " + c(sym("h") * min(term_width() - 4, max(vlen(title) + vlen(subtitle) + 1, 24)), "gray"))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def rule(title: str = "", width: Optional[int] = None) -> str:
|
|
296
|
+
width = width or term_width() - 2
|
|
297
|
+
if not title:
|
|
298
|
+
return c(sym("h") * width, "gray")
|
|
299
|
+
left = sym("h") * 3 + " "
|
|
300
|
+
right = " " + sym("h") * max(3, width - vlen(title) - 5)
|
|
301
|
+
return c(left, "gray") + c(title, "bold") + c(right, "gray")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def box(title: str, lines: Sequence[str], color: str = "bcyan", width: Optional[int] = None) -> None:
|
|
305
|
+
"""Draw a rounded box around lines of text."""
|
|
306
|
+
lines = [str(x) for x in lines]
|
|
307
|
+
inner = max([vlen(x) for x in lines] + [vlen(title) + 2, 20])
|
|
308
|
+
if width:
|
|
309
|
+
inner = max(inner, width - 4)
|
|
310
|
+
inner = min(inner, term_width() - 4)
|
|
311
|
+
top_title = (" " + title + " ") if title else ""
|
|
312
|
+
top = c(sym("tl") + sym("h"), color) + c(top_title, "bold " + color) + \
|
|
313
|
+
c(sym("h") * max(0, inner + 1 - vlen(top_title)) + sym("tr"), color)
|
|
314
|
+
print(top)
|
|
315
|
+
for ln in lines:
|
|
316
|
+
pad = " " * max(0, inner - vlen(ln))
|
|
317
|
+
print(c(sym("v"), color) + " " + ln + pad + " " + c(sym("v"), color))
|
|
318
|
+
print(c(sym("bl") + sym("h") * (inner + 2) + sym("br"), color))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def kv(rows: Sequence[Tuple[str, object]], indent: str = " ", key_color: str = "gray") -> None:
|
|
322
|
+
"""Aligned key/value list."""
|
|
323
|
+
if not rows:
|
|
324
|
+
return
|
|
325
|
+
w = max(len(str(k)) for k, _ in rows)
|
|
326
|
+
for k, v in rows:
|
|
327
|
+
print("%s%s %s" % (indent, c(str(k).ljust(w), key_color), v))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
_GRADIENT = (51, 45, 39, 33, 99, 135, 171, 207)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def gradient(lines: Sequence[str]) -> List[str]:
|
|
334
|
+
"""Color lines with a cyan -> magenta gradient (256-color terminals)."""
|
|
335
|
+
if not _COLOR:
|
|
336
|
+
return list(lines)
|
|
337
|
+
out = []
|
|
338
|
+
n = max(1, len(lines) - 1)
|
|
339
|
+
for i, ln in enumerate(lines):
|
|
340
|
+
code = _GRADIENT[int(round(i / n * (len(_GRADIENT) - 1)))]
|
|
341
|
+
out.append("\033[38;5;%dm%s\033[0m" % (code, ln))
|
|
342
|
+
return out
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
_LOGO = [
|
|
346
|
+
r" ____ _ _______ ",
|
|
347
|
+
r" | _ \| |/ /_ _|",
|
|
348
|
+
r" | | | | ' / | | ",
|
|
349
|
+
r" | |_| | . \ | | ",
|
|
350
|
+
r" |____/|_|\_\ |_| ",
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def banner(subtitle: str = "") -> None:
|
|
355
|
+
from . import __version__
|
|
356
|
+
logo = gradient(_LOGO)
|
|
357
|
+
side = [
|
|
358
|
+
"",
|
|
359
|
+
c("devkittools", "bold") + " " + c("v" + __version__, "gray"),
|
|
360
|
+
c("the developer's swiss army knife", "gray"),
|
|
361
|
+
c(subtitle, "bcyan") if subtitle else "",
|
|
362
|
+
"",
|
|
363
|
+
]
|
|
364
|
+
print()
|
|
365
|
+
for a, b in zip(logo, side):
|
|
366
|
+
print(a + " " + b)
|
|
367
|
+
print()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def table(headers: Sequence[str], rows: Sequence[Sequence[object]], indent: str = " ") -> None:
|
|
371
|
+
"""Like print_table but ANSI-aware so cells can be colored."""
|
|
372
|
+
srows = [[str(x) for x in r] for r in rows]
|
|
373
|
+
widths = [vlen(h) for h in headers]
|
|
374
|
+
for r in srows:
|
|
375
|
+
for i, cell in enumerate(r):
|
|
376
|
+
widths[i] = max(widths[i], vlen(cell))
|
|
377
|
+
pad = lambda s, w: s + " " * (w - vlen(s)) # noqa: E731
|
|
378
|
+
print(indent + c(" ".join(pad(h, widths[i]) for i, h in enumerate(headers)), "bold"))
|
|
379
|
+
print(indent + c(" ".join(sym("h") * w for w in widths), "gray"))
|
|
380
|
+
for r in srows:
|
|
381
|
+
print(indent + " ".join(pad(cell, widths[i]) for i, cell in enumerate(r)))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class Spinner:
|
|
385
|
+
"""`with Spinner("working..."):` - animated on a TTY, silent otherwise."""
|
|
386
|
+
|
|
387
|
+
FRAMES_U = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
388
|
+
FRAMES_A = "|/-\\"
|
|
389
|
+
|
|
390
|
+
def __init__(self, text: str = "working"):
|
|
391
|
+
self.text = text
|
|
392
|
+
self._stop = threading.Event()
|
|
393
|
+
self._thread = None # type: Optional[threading.Thread]
|
|
394
|
+
self._active = False
|
|
395
|
+
|
|
396
|
+
def update(self, text: str) -> None:
|
|
397
|
+
self.text = text
|
|
398
|
+
|
|
399
|
+
def _spin(self) -> None:
|
|
400
|
+
frames = self.FRAMES_U if sym("ok") != "+" else self.FRAMES_A
|
|
401
|
+
i = 0
|
|
402
|
+
while not self._stop.is_set():
|
|
403
|
+
sys.stderr.write("\r %s %s\033[K" % (c(frames[i % len(frames)], "bcyan"), self.text))
|
|
404
|
+
sys.stderr.flush()
|
|
405
|
+
i += 1
|
|
406
|
+
time.sleep(0.08)
|
|
407
|
+
|
|
408
|
+
def __enter__(self):
|
|
409
|
+
if _COLOR and sys.stderr.isatty():
|
|
410
|
+
self._active = True
|
|
411
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
412
|
+
self._thread.start()
|
|
413
|
+
return self
|
|
414
|
+
|
|
415
|
+
def __exit__(self, *exc):
|
|
416
|
+
if self._active and self._thread:
|
|
417
|
+
self._stop.set()
|
|
418
|
+
self._thread.join()
|
|
419
|
+
sys.stderr.write("\r\033[K")
|
|
420
|
+
sys.stderr.flush()
|
|
421
|
+
return False
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ----------------------------------------------------------------- prompts
|
|
425
|
+
|
|
426
|
+
def is_interactive() -> bool:
|
|
427
|
+
try:
|
|
428
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
429
|
+
except Exception:
|
|
430
|
+
return False
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def ask(prompt: str, default: Optional[str] = None) -> str:
|
|
434
|
+
if not is_interactive():
|
|
435
|
+
return default or ""
|
|
436
|
+
suffix = " [%s]" % default if default else ""
|
|
437
|
+
try:
|
|
438
|
+
val = input("%s %s%s: " % (c(sym("arrow"), "bcyan"), prompt, c(suffix, "gray"))).strip()
|
|
439
|
+
except EOFError:
|
|
440
|
+
return default or ""
|
|
441
|
+
return val or (default or "")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def confirm(prompt: str, default: bool = False) -> bool:
|
|
445
|
+
if not is_interactive():
|
|
446
|
+
return default
|
|
447
|
+
hint = "Y/n" if default else "y/N"
|
|
448
|
+
try:
|
|
449
|
+
val = input("%s %s %s " % (c("?", "bmagenta"), prompt, c("(%s)" % hint, "gray"))).strip().lower()
|
|
450
|
+
except EOFError:
|
|
451
|
+
return default
|
|
452
|
+
if not val:
|
|
453
|
+
return default
|
|
454
|
+
return val in ("y", "yes")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# ----------------------------------------------------------------- input helpers
|
|
458
|
+
|
|
459
|
+
def paste_from_clipboard() -> Optional[str]:
|
|
460
|
+
try:
|
|
461
|
+
if os.name == "nt":
|
|
462
|
+
r = subprocess.run(["powershell", "-NoProfile", "-Command", "Get-Clipboard -Raw"],
|
|
463
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
464
|
+
return r.stdout.decode("utf-8", errors="replace").rstrip("\r\n") if r.returncode == 0 else None
|
|
465
|
+
if sys.platform == "darwin" and shutil.which("pbpaste"):
|
|
466
|
+
return subprocess.run(["pbpaste"], stdout=subprocess.PIPE).stdout.decode("utf-8", errors="replace")
|
|
467
|
+
for tool in (["wl-paste", "-n"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "--clipboard", "--output"]):
|
|
468
|
+
if shutil.which(tool[0]):
|
|
469
|
+
return subprocess.run(tool, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout.decode("utf-8", errors="replace")
|
|
470
|
+
except Exception:
|
|
471
|
+
return None
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def read_input(text: Optional[str] = None, file: Optional[str] = None, paste: bool = False,
|
|
476
|
+
what: str = "input") -> str:
|
|
477
|
+
"""Get text from (in order): --file, positional text, --paste, piped stdin."""
|
|
478
|
+
if file:
|
|
479
|
+
p = Path(file).expanduser()
|
|
480
|
+
if not p.is_file():
|
|
481
|
+
raise DktError("File not found: %s" % p)
|
|
482
|
+
return p.read_text(encoding="utf-8", errors="replace")
|
|
483
|
+
if text is not None and text != "-":
|
|
484
|
+
return text
|
|
485
|
+
if paste:
|
|
486
|
+
got = paste_from_clipboard()
|
|
487
|
+
if got is None:
|
|
488
|
+
raise DktError("Could not read the clipboard on this system.")
|
|
489
|
+
return got
|
|
490
|
+
if text == "-" or not sys.stdin.isatty():
|
|
491
|
+
return sys.stdin.read()
|
|
492
|
+
raise DktError("No %s given. Pass it as an argument, use --file/--paste, or pipe it in." % what)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def output_result(text: str, copy: bool = False, quiet: bool = False) -> None:
|
|
496
|
+
"""Print text to stdout and optionally copy it to the clipboard."""
|
|
497
|
+
print(text)
|
|
498
|
+
if copy:
|
|
499
|
+
if copy_to_clipboard(text):
|
|
500
|
+
if not quiet:
|
|
501
|
+
info(c("%s copied to clipboard" % sym("ok"), "green"))
|
|
502
|
+
else:
|
|
503
|
+
info(c("could not copy to clipboard on this system", "yellow"))
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# ----------------------------------------------------------------- config
|
|
507
|
+
|
|
508
|
+
def config_dir() -> Path:
|
|
509
|
+
if os.name == "nt":
|
|
510
|
+
base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
511
|
+
else:
|
|
512
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
513
|
+
return Path(base) / "dkt"
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def load_json_file(path: Path, default):
|
|
517
|
+
try:
|
|
518
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
519
|
+
except Exception:
|
|
520
|
+
return default
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def save_json_file(path: Path, data) -> None:
|
|
524
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
525
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True), encoding="utf-8")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def load_config() -> dict:
|
|
529
|
+
return load_json_file(config_dir() / "config.json", {})
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def get_setting(key: str, default=None):
|
|
533
|
+
"""Environment variable DKT_<KEY> wins, then ~/.config/dkt/config.json."""
|
|
534
|
+
env = os.environ.get("DKT_" + key.upper())
|
|
535
|
+
if env:
|
|
536
|
+
return env
|
|
537
|
+
return load_config().get(key, default)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# ----------------------------------------------------------------- misc
|
|
541
|
+
|
|
542
|
+
def tool_version(cmd: Sequence[str]) -> Optional[str]:
|
|
543
|
+
"""First line of `cmd --version`, or None if the tool is missing."""
|
|
544
|
+
if not shutil.which(cmd[0]):
|
|
545
|
+
return None
|
|
546
|
+
try:
|
|
547
|
+
r = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
548
|
+
stdin=subprocess.DEVNULL, timeout=10)
|
|
549
|
+
out = decode_output(r.stdout).strip().splitlines()
|
|
550
|
+
return out[0].strip() if out else "installed"
|
|
551
|
+
except Exception:
|
|
552
|
+
return "installed"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def walk_files(folder: Path, skip_dirs=None):
|
|
556
|
+
"""Yield files under folder, skipping heavy/vcs directories (no gitignore needed)."""
|
|
557
|
+
skip = IGNORE_DIRS if skip_dirs is None else skip_dirs
|
|
558
|
+
for root, dirs, files in os.walk(str(folder)):
|
|
559
|
+
dirs[:] = [d for d in dirs if d not in skip and not d.endswith(".egg-info")]
|
|
560
|
+
for f in files:
|
|
561
|
+
yield Path(root, f)
|