cli-tools-kit 0.6.0__tar.gz → 0.6.1__tar.gz
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.
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/PKG-INFO +20 -6
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/README.md +19 -5
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/__init__.py +1 -1
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/gui_installer.py +24 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/sources.py +178 -15
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit.egg-info/PKG-INFO +20 -6
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/pyproject.toml +1 -1
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/tests/test_sources.py +164 -4
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/LICENSE +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/__main__.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/advertise.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/cron_installer.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/host.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/identity.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/onboarding.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/skills.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/taxonomy/__init__.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/taxonomy/build.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/taxonomy/capability.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/taxonomy/cluster.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/taxonomy/corpus.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/taxonomy/embedder.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/taxonomy/groups.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/tool_installer.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit/tui_installer.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit.egg-info/SOURCES.txt +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit.egg-info/entry_points.txt +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit.egg-info/requires.txt +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/cli_tools_kit.egg-info/top_level.txt +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/setup.cfg +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/tests/test_capability_groups.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/tests/test_cron_installer.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/tests/test_host.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/tests/test_identity.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/tests/test_llm_groups.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/tests/test_skills.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/tests/test_taxonomy_cluster.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/tests/test_tool_installer.py +0 -0
- {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.1}/tests/test_tui_installer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cli-tools-kit
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Installer protocol + helpers for self-installing Python CLI/GUI tools (desktop shortcuts, bash aliases, cron entries), plus reusable tkinter and curses installer screens
|
|
5
5
|
Author: Steffen Probst
|
|
6
6
|
License: MIT License
|
|
@@ -417,8 +417,20 @@ path = "/home/me/work/lab-tools"
|
|
|
417
417
|
|
|
418
418
|
A source resolves in this order: the path from the local file, then the `path`
|
|
419
419
|
from the tracked file, then an existing `<root>/<name>`, then a clone of `url`
|
|
420
|
-
into `<root>/<name>`.
|
|
421
|
-
|
|
420
|
+
into `<root>/<name>`.
|
|
421
|
+
|
|
422
|
+
The root is `--root DIR` if given, else the local file's `root`, else the answer
|
|
423
|
+
to a question. The GUI asks in a small dialog before discovery, the text screen
|
|
424
|
+
asks in one line on stdin, and both suggest `<current directory>/<name>` as an
|
|
425
|
+
absolute path — `<name>` is `run_installer`'s `default_root_name`, `tools` by
|
|
426
|
+
default. The answer is written into `installer.local.toml` as its `root`, above
|
|
427
|
+
any `[[source]]` tables already there, so the question is asked once per
|
|
428
|
+
machine; a `root` that file already has is left alone. The directory is created
|
|
429
|
+
if it does not exist, and cancelling the dialog installs nothing.
|
|
430
|
+
|
|
431
|
+
A headless run never asks: `--list`, `--apply`, `--check`, or a text screen with
|
|
432
|
+
no terminal take the suggestion and print `root: /abs/path (pass --root to
|
|
433
|
+
change)`.
|
|
422
434
|
|
|
423
435
|
Cloning is deliberately narrow. Only `https://` URLs are cloned, `ext::` and
|
|
424
436
|
`file://` transports and any hook are switched off for the git call, the clone is
|
|
@@ -447,14 +459,16 @@ from cli_tools_kit.sources import run_installer
|
|
|
447
459
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
448
460
|
run_installer(os.path.join(HERE, "installer.toml"),
|
|
449
461
|
identity=InstallerIdentity(slug="acme-tools", title="Acme Tools"),
|
|
462
|
+
default_root_name="acme-tools",
|
|
450
463
|
entry_script=__file__)
|
|
451
464
|
```
|
|
452
465
|
|
|
453
466
|
`run_installer` takes `--root DIR` for itself and leaves every other flag to the
|
|
454
467
|
engine, so `--list`, `--apply`, `--skill-target`, `--check`, `--refresh`, `--tui`
|
|
455
|
-
and `--gui` work as they do without sources. Every keyword besides `config_path
|
|
456
|
-
and `
|
|
457
|
-
function's own to set and passing either raises
|
|
468
|
+
and `--gui` work as they do without sources. Every keyword besides `config_path`,
|
|
469
|
+
`argv` and `default_root_name` goes to `run()`; `discovery_roots` and
|
|
470
|
+
`pre_discovery` are the function's own to set and passing either raises
|
|
471
|
+
`TypeError`.
|
|
458
472
|
|
|
459
473
|
Without a wrapper, the same thing from the command line:
|
|
460
474
|
|
|
@@ -364,8 +364,20 @@ path = "/home/me/work/lab-tools"
|
|
|
364
364
|
|
|
365
365
|
A source resolves in this order: the path from the local file, then the `path`
|
|
366
366
|
from the tracked file, then an existing `<root>/<name>`, then a clone of `url`
|
|
367
|
-
into `<root>/<name>`.
|
|
368
|
-
|
|
367
|
+
into `<root>/<name>`.
|
|
368
|
+
|
|
369
|
+
The root is `--root DIR` if given, else the local file's `root`, else the answer
|
|
370
|
+
to a question. The GUI asks in a small dialog before discovery, the text screen
|
|
371
|
+
asks in one line on stdin, and both suggest `<current directory>/<name>` as an
|
|
372
|
+
absolute path — `<name>` is `run_installer`'s `default_root_name`, `tools` by
|
|
373
|
+
default. The answer is written into `installer.local.toml` as its `root`, above
|
|
374
|
+
any `[[source]]` tables already there, so the question is asked once per
|
|
375
|
+
machine; a `root` that file already has is left alone. The directory is created
|
|
376
|
+
if it does not exist, and cancelling the dialog installs nothing.
|
|
377
|
+
|
|
378
|
+
A headless run never asks: `--list`, `--apply`, `--check`, or a text screen with
|
|
379
|
+
no terminal take the suggestion and print `root: /abs/path (pass --root to
|
|
380
|
+
change)`.
|
|
369
381
|
|
|
370
382
|
Cloning is deliberately narrow. Only `https://` URLs are cloned, `ext::` and
|
|
371
383
|
`file://` transports and any hook are switched off for the git call, the clone is
|
|
@@ -394,14 +406,16 @@ from cli_tools_kit.sources import run_installer
|
|
|
394
406
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
395
407
|
run_installer(os.path.join(HERE, "installer.toml"),
|
|
396
408
|
identity=InstallerIdentity(slug="acme-tools", title="Acme Tools"),
|
|
409
|
+
default_root_name="acme-tools",
|
|
397
410
|
entry_script=__file__)
|
|
398
411
|
```
|
|
399
412
|
|
|
400
413
|
`run_installer` takes `--root DIR` for itself and leaves every other flag to the
|
|
401
414
|
engine, so `--list`, `--apply`, `--skill-target`, `--check`, `--refresh`, `--tui`
|
|
402
|
-
and `--gui` work as they do without sources. Every keyword besides `config_path
|
|
403
|
-
and `
|
|
404
|
-
function's own to set and passing either raises
|
|
415
|
+
and `--gui` work as they do without sources. Every keyword besides `config_path`,
|
|
416
|
+
`argv` and `default_root_name` goes to `run()`; `discovery_roots` and
|
|
417
|
+
`pre_discovery` are the function's own to set and passing either raises
|
|
418
|
+
`TypeError`.
|
|
405
419
|
|
|
406
420
|
Without a wrapper, the same thing from the command line:
|
|
407
421
|
|
|
@@ -1918,6 +1918,17 @@ class InstallerApp:
|
|
|
1918
1918
|
# Windows/Mac use MouseWheel
|
|
1919
1919
|
self.canvas.bind_all("<MouseWheel>", _on_mousewheel)
|
|
1920
1920
|
|
|
1921
|
+
def _fit_canvas_width(self):
|
|
1922
|
+
"""Stretch the scrollable content to the canvas' current width.
|
|
1923
|
+
|
|
1924
|
+
The canvas window item keeps the width it is given, so without this the
|
|
1925
|
+
table falls back to its requested width — the minimum every column
|
|
1926
|
+
needs — and leaves the right of the window empty.
|
|
1927
|
+
"""
|
|
1928
|
+
width = self.canvas.winfo_width()
|
|
1929
|
+
if width > 1:
|
|
1930
|
+
self.canvas.itemconfig(self.canvas_window, width=width)
|
|
1931
|
+
|
|
1921
1932
|
def _on_canvas_configure(self, event):
|
|
1922
1933
|
"""Handle canvas resize: adjust content width and show/hide scrollbar."""
|
|
1923
1934
|
# Make content fill canvas width
|
|
@@ -2203,6 +2214,15 @@ class InstallerApp:
|
|
|
2203
2214
|
# Restore window geometry
|
|
2204
2215
|
self.root.geometry(geometry)
|
|
2205
2216
|
|
|
2217
|
+
# _setup_ui built a new canvas, and the old one died with the old UI.
|
|
2218
|
+
# The mouse wheel is bound to the widget by name, so re-bind it, and
|
|
2219
|
+
# give the new window item the canvas width at once: without it the
|
|
2220
|
+
# table keeps its requested width, which is the sum of the minimum
|
|
2221
|
+
# column widths, and sits at the left with empty space to its right.
|
|
2222
|
+
self._bind_mousewheel()
|
|
2223
|
+
self.root.update_idletasks()
|
|
2224
|
+
self._fit_canvas_width()
|
|
2225
|
+
|
|
2206
2226
|
def _setup_ui(self):
|
|
2207
2227
|
# Configure styles
|
|
2208
2228
|
self.style = ttk.Style()
|
|
@@ -2416,6 +2436,10 @@ class InstallerApp:
|
|
|
2416
2436
|
current_row = self._render_tool_group(group, current_row)
|
|
2417
2437
|
|
|
2418
2438
|
self.canvas.grid(row=0, column=0, sticky="nsew")
|
|
2439
|
+
# Bound here rather than only in _position_window, which runs once at
|
|
2440
|
+
# startup: a theme switch rebuilds the UI, and the new canvas needs the
|
|
2441
|
+
# binding that keeps the table as wide as the window.
|
|
2442
|
+
self.canvas.bind("<Configure>", self._on_canvas_configure)
|
|
2419
2443
|
# Scrollbar initially hidden - will be shown in _position_window if needed
|
|
2420
2444
|
|
|
2421
2445
|
# Footer / Buttons
|
|
@@ -24,6 +24,11 @@ for a source matched by ``name``:
|
|
|
24
24
|
name = "acme/lab"
|
|
25
25
|
path = "/home/me/work/lab-tools"
|
|
26
26
|
|
|
27
|
+
The root is never guessed from where the config file happens to sit.
|
|
28
|
+
``run_installer`` takes ``--root``, else the ``root`` of the local file, else it
|
|
29
|
+
asks the user, and writes the answer back into the local file so the question is
|
|
30
|
+
asked once.
|
|
31
|
+
|
|
27
32
|
A source resolves in this order: the path from the local file, then the ``path``
|
|
28
33
|
from the tracked file, then an existing ``<root>/<name>``, then a clone of
|
|
29
34
|
``url`` into ``<root>/<name>``. Only ``https://`` URLs are cloned, the clone is
|
|
@@ -56,7 +61,8 @@ from dataclasses import dataclass
|
|
|
56
61
|
from pathlib import Path
|
|
57
62
|
from typing import Callable, List, Optional, Sequence
|
|
58
63
|
|
|
59
|
-
__all__ = ["Source", "load_sources", "resolve_sources", "run_installer"
|
|
64
|
+
__all__ = ["Source", "load_sources", "resolve_sources", "run_installer",
|
|
65
|
+
"local_root", "save_local_root", "default_root"]
|
|
60
66
|
|
|
61
67
|
# How far below the top-level installer.toml a nested one is still read.
|
|
62
68
|
MAX_NESTING = 1
|
|
@@ -132,6 +138,35 @@ def local_root(config_path, local_path=None) -> Optional[str]:
|
|
|
132
138
|
return None
|
|
133
139
|
|
|
134
140
|
|
|
141
|
+
def save_local_root(config_path, root, local_path=None, log: Callable = print) -> bool:
|
|
142
|
+
"""Write ``root`` into the local file next to ``config_path``.
|
|
143
|
+
|
|
144
|
+
Returns False and changes nothing when the file already sets a ``root``: a
|
|
145
|
+
value someone put there by hand is never overwritten. Existing
|
|
146
|
+
``[[source]]`` entries are kept, and the key is written above them, because
|
|
147
|
+
a top-level key written after a table would belong to that table.
|
|
148
|
+
"""
|
|
149
|
+
config_path = Path(config_path)
|
|
150
|
+
local = Path(local_path) if local_path is not None else _local_path_for(config_path)
|
|
151
|
+
if local_root(config_path, local) is not None:
|
|
152
|
+
return False
|
|
153
|
+
existing = ""
|
|
154
|
+
if local.is_file():
|
|
155
|
+
try:
|
|
156
|
+
existing = local.read_text(encoding="utf-8")
|
|
157
|
+
except OSError as exc:
|
|
158
|
+
log(f"{local.name}: not updated ({exc})")
|
|
159
|
+
return False
|
|
160
|
+
line = f'root = "{root}"\n'
|
|
161
|
+
try:
|
|
162
|
+
local.write_text(line + ("\n" + existing.lstrip("\n") if existing.strip() else ""),
|
|
163
|
+
encoding="utf-8")
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
log(f"{local.name}: not written ({exc})")
|
|
166
|
+
return False
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
135
170
|
def load_sources(config_path, local_path=None, log: Callable = print) -> List[Source]:
|
|
136
171
|
"""The ``[[source]]`` entries of one TOML file, local overrides applied.
|
|
137
172
|
|
|
@@ -174,10 +209,14 @@ def _git(*args):
|
|
|
174
209
|
return result.returncode == 0, (result.stdout + result.stderr).strip()
|
|
175
210
|
|
|
176
211
|
|
|
177
|
-
def
|
|
212
|
+
def _git_reason(output: str, fallback: str = "git failed") -> str:
|
|
178
213
|
lines = [line.strip() for line in output.splitlines() if line.strip()]
|
|
179
214
|
return next((line for line in lines if line.startswith("fatal:")),
|
|
180
|
-
lines[-1] if lines else
|
|
215
|
+
lines[-1] if lines else fallback)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _clone_reason(output: str) -> str:
|
|
219
|
+
return _git_reason(output, "git clone failed")
|
|
181
220
|
|
|
182
221
|
|
|
183
222
|
def _resolve_one(source: Source, root: Path, refresh: bool, log: Callable,
|
|
@@ -189,9 +228,13 @@ def _resolve_one(source: Source, root: Path, refresh: bool, log: Callable,
|
|
|
189
228
|
target = root.joinpath(*source.name.split("/"))
|
|
190
229
|
if target.is_dir():
|
|
191
230
|
if refresh and clone and (target / ".git").is_dir() and source.url:
|
|
192
|
-
ok,
|
|
231
|
+
ok, out = _git("-C", str(target), "pull", "--ff-only")
|
|
232
|
+
# A pull can fail for reasons the user cannot fix here: the remote
|
|
233
|
+
# is gone, the network is down, the history diverged. One line, and
|
|
234
|
+
# the checkout that is already on disk is used as it stands.
|
|
193
235
|
log(f"{source.name}: pulled" if ok else
|
|
194
|
-
f"{source.name}:
|
|
236
|
+
f"{source.name}: not updated, using the checkout as it is"
|
|
237
|
+
f" ({_git_reason(out)})")
|
|
195
238
|
return target.resolve()
|
|
196
239
|
|
|
197
240
|
if not source.url:
|
|
@@ -279,16 +322,126 @@ def _take_root(argv: List[str]):
|
|
|
279
322
|
return rest, root
|
|
280
323
|
|
|
281
324
|
|
|
282
|
-
def default_root(
|
|
283
|
-
"""
|
|
325
|
+
def default_root(name: str = "tools") -> str:
|
|
326
|
+
"""The suggested install location: ``<current directory>/<name>``.
|
|
284
327
|
|
|
285
|
-
|
|
286
|
-
|
|
328
|
+
Absolute, because the user is shown this string and has to recognise where
|
|
329
|
+
it points. It is only ever a suggestion: what the user types in the dialog
|
|
330
|
+
or on stdin wins, and ``--root`` wins over both.
|
|
287
331
|
"""
|
|
288
|
-
return str(Path(
|
|
332
|
+
return str(Path.cwd().joinpath(name).absolute())
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# Flags that mean nobody is watching the screen, so nothing may block on input.
|
|
336
|
+
HEADLESS_FLAGS = ("--list", "--check", "--apply")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _is_headless(argv: Sequence[str]) -> bool:
|
|
340
|
+
return any(arg in HEADLESS_FLAGS or arg.startswith("--apply=") for arg in argv)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _wants_gui(argv: Sequence[str]) -> bool:
|
|
344
|
+
"""True when the tkinter window is the screen this run will open."""
|
|
345
|
+
from . import tui_installer # noqa: PLC0415 — pulls in the engine, keep it lazy
|
|
346
|
+
from .gui_installer import _HAVE_TK # noqa: PLC0415
|
|
347
|
+
return not tui_installer.prefer_tui(force_tui="--tui" in argv,
|
|
348
|
+
force_gui="--gui" in argv,
|
|
349
|
+
have_tk=_HAVE_TK)
|
|
289
350
|
|
|
290
351
|
|
|
291
|
-
def
|
|
352
|
+
def _ask_root_gui(default: str) -> Optional[str]:
|
|
353
|
+
"""A small window asking where the tools go. None when the user cancels."""
|
|
354
|
+
import tkinter as tk # noqa: PLC0415
|
|
355
|
+
from tkinter import filedialog # noqa: PLC0415
|
|
356
|
+
|
|
357
|
+
win = tk.Tk()
|
|
358
|
+
win.title("Install location")
|
|
359
|
+
chosen: List[str] = []
|
|
360
|
+
value = tk.StringVar(value=default)
|
|
361
|
+
|
|
362
|
+
tk.Label(win, text="Where should the tools be installed?",
|
|
363
|
+
font=("", 12, "bold")).pack(anchor="w", padx=16, pady=(16, 6))
|
|
364
|
+
tk.Label(win, text="The folder is created if it does not exist.",
|
|
365
|
+
justify="left").pack(anchor="w", padx=16)
|
|
366
|
+
|
|
367
|
+
row = tk.Frame(win)
|
|
368
|
+
row.pack(fill="x", padx=16, pady=12)
|
|
369
|
+
entry = tk.Entry(row, textvariable=value, width=54)
|
|
370
|
+
entry.pack(side="left", fill="x", expand=True)
|
|
371
|
+
|
|
372
|
+
def browse():
|
|
373
|
+
picked = filedialog.askdirectory(parent=win, title="Install location",
|
|
374
|
+
initialdir=os.path.dirname(value.get()) or "/")
|
|
375
|
+
if picked:
|
|
376
|
+
value.set(str(Path(picked).absolute()))
|
|
377
|
+
|
|
378
|
+
tk.Button(row, text="Browse…", command=browse).pack(side="left", padx=(8, 0))
|
|
379
|
+
|
|
380
|
+
buttons = tk.Frame(win)
|
|
381
|
+
buttons.pack(fill="x", padx=16, pady=(0, 16))
|
|
382
|
+
|
|
383
|
+
def ok(*_):
|
|
384
|
+
text = value.get().strip()
|
|
385
|
+
if text:
|
|
386
|
+
chosen.append(text)
|
|
387
|
+
win.destroy()
|
|
388
|
+
|
|
389
|
+
tk.Button(buttons, text="OK", command=ok, width=10).pack(side="right")
|
|
390
|
+
tk.Button(buttons, text="Cancel", command=win.destroy, width=10).pack(side="right", padx=8)
|
|
391
|
+
win.bind("<Return>", ok)
|
|
392
|
+
win.protocol("WM_DELETE_WINDOW", win.destroy)
|
|
393
|
+
entry.focus_set()
|
|
394
|
+
entry.icursor("end")
|
|
395
|
+
win.mainloop()
|
|
396
|
+
return chosen[0] if chosen else None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _ask_root_stdin(default: str) -> Optional[str]:
|
|
400
|
+
"""One line on stdin, before any curses screen opens. None on Ctrl-D."""
|
|
401
|
+
try:
|
|
402
|
+
answer = input(f"Where should the tools be installed? [{default}] ")
|
|
403
|
+
except EOFError:
|
|
404
|
+
return None
|
|
405
|
+
return answer.strip() or default
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _resolve_root(config_path: Path, root_arg: Optional[str], argv: Sequence[str],
|
|
409
|
+
default_root_name: str, log: Callable = print) -> str:
|
|
410
|
+
"""Settle the clone root: the flag, the local file, the user, the default.
|
|
411
|
+
|
|
412
|
+
Asks only when the first two miss. The tkinter dialog is used when this run
|
|
413
|
+
opens the tkinter window, the stdin prompt when it opens the text screen on
|
|
414
|
+
a terminal. A headless run (``--list``, ``--apply``, ``--check``, or a text
|
|
415
|
+
screen without a terminal) never asks: it takes the default and says so.
|
|
416
|
+
"""
|
|
417
|
+
if root_arg:
|
|
418
|
+
return str(Path(os.path.expanduser(root_arg)).absolute())
|
|
419
|
+
stored = local_root(config_path)
|
|
420
|
+
if stored:
|
|
421
|
+
return stored
|
|
422
|
+
|
|
423
|
+
default = default_root(default_root_name)
|
|
424
|
+
if _is_headless(argv):
|
|
425
|
+
log(f"root: {default} (pass --root to change)")
|
|
426
|
+
return default
|
|
427
|
+
|
|
428
|
+
if _wants_gui(argv):
|
|
429
|
+
answer = _ask_root_gui(default)
|
|
430
|
+
elif sys.stdin is not None and sys.stdin.isatty():
|
|
431
|
+
answer = _ask_root_stdin(default)
|
|
432
|
+
else:
|
|
433
|
+
log(f"root: {default} (pass --root to change)")
|
|
434
|
+
return default
|
|
435
|
+
|
|
436
|
+
if answer is None:
|
|
437
|
+
print("No install location chosen, nothing was installed.")
|
|
438
|
+
raise SystemExit(0)
|
|
439
|
+
chosen = str(Path(os.path.expanduser(answer)).absolute())
|
|
440
|
+
save_local_root(config_path, chosen, log=log)
|
|
441
|
+
return chosen
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def run_installer(config_path, argv=None, default_root_name: str = "tools", **run_kwargs):
|
|
292
445
|
"""Read a sources file, wire the engine to it, and run the installer.
|
|
293
446
|
|
|
294
447
|
``--root DIR`` is taken from ``argv`` (``sys.argv[1:]`` by default) and the
|
|
@@ -297,11 +450,18 @@ def run_installer(config_path, argv=None, **run_kwargs):
|
|
|
297
450
|
engine's flag: it reaches the pre-discovery hook, which then pulls every
|
|
298
451
|
clone.
|
|
299
452
|
|
|
300
|
-
The root is ``--root`` if given, else the ``root`` of
|
|
301
|
-
|
|
453
|
+
The root is ``--root`` if given, else the ``root`` of
|
|
454
|
+
``installer.local.toml``, else the user's answer to a question, else — on a
|
|
455
|
+
headless run only — ``<current directory>/<default_root_name>``. The answer
|
|
456
|
+
is written into ``installer.local.toml``, so the question is asked once.
|
|
457
|
+
The root directory is created if it does not exist. Cloning happens in the
|
|
302
458
|
pre-discovery hook, which the engine skips on the ``--check`` path, so that
|
|
303
459
|
check stays network-free and sees whatever is already on disk.
|
|
304
460
|
|
|
461
|
+
``default_root_name`` is the folder name the suggestion ends in, so an
|
|
462
|
+
organisation's installer can suggest ``<cwd>/WW3-tools`` rather than
|
|
463
|
+
``<cwd>/tools``.
|
|
464
|
+
|
|
305
465
|
Every other keyword goes to :func:`cli_tools_kit.gui_installer.run`.
|
|
306
466
|
``discovery_roots`` and ``pre_discovery`` are this function's to set.
|
|
307
467
|
``prune`` reaches the walker that way, so a wrapper can name directories
|
|
@@ -316,8 +476,11 @@ def run_installer(config_path, argv=None, **run_kwargs):
|
|
|
316
476
|
argv, root_arg = _take_root(argv)
|
|
317
477
|
sys.argv = [sys.argv[0]] + argv
|
|
318
478
|
|
|
319
|
-
root =
|
|
320
|
-
|
|
479
|
+
root = _resolve_root(config_path, root_arg, argv, default_root_name)
|
|
480
|
+
try:
|
|
481
|
+
os.makedirs(root, exist_ok=True)
|
|
482
|
+
except OSError as exc:
|
|
483
|
+
print(f"{root}: not created ({exc})")
|
|
321
484
|
sources = load_sources(config_path)
|
|
322
485
|
|
|
323
486
|
# The engine reads DISCOVERY_ROOTS after the hook has run, so the hook fills
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cli-tools-kit
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Installer protocol + helpers for self-installing Python CLI/GUI tools (desktop shortcuts, bash aliases, cron entries), plus reusable tkinter and curses installer screens
|
|
5
5
|
Author: Steffen Probst
|
|
6
6
|
License: MIT License
|
|
@@ -417,8 +417,20 @@ path = "/home/me/work/lab-tools"
|
|
|
417
417
|
|
|
418
418
|
A source resolves in this order: the path from the local file, then the `path`
|
|
419
419
|
from the tracked file, then an existing `<root>/<name>`, then a clone of `url`
|
|
420
|
-
into `<root>/<name>`.
|
|
421
|
-
|
|
420
|
+
into `<root>/<name>`.
|
|
421
|
+
|
|
422
|
+
The root is `--root DIR` if given, else the local file's `root`, else the answer
|
|
423
|
+
to a question. The GUI asks in a small dialog before discovery, the text screen
|
|
424
|
+
asks in one line on stdin, and both suggest `<current directory>/<name>` as an
|
|
425
|
+
absolute path — `<name>` is `run_installer`'s `default_root_name`, `tools` by
|
|
426
|
+
default. The answer is written into `installer.local.toml` as its `root`, above
|
|
427
|
+
any `[[source]]` tables already there, so the question is asked once per
|
|
428
|
+
machine; a `root` that file already has is left alone. The directory is created
|
|
429
|
+
if it does not exist, and cancelling the dialog installs nothing.
|
|
430
|
+
|
|
431
|
+
A headless run never asks: `--list`, `--apply`, `--check`, or a text screen with
|
|
432
|
+
no terminal take the suggestion and print `root: /abs/path (pass --root to
|
|
433
|
+
change)`.
|
|
422
434
|
|
|
423
435
|
Cloning is deliberately narrow. Only `https://` URLs are cloned, `ext::` and
|
|
424
436
|
`file://` transports and any hook are switched off for the git call, the clone is
|
|
@@ -447,14 +459,16 @@ from cli_tools_kit.sources import run_installer
|
|
|
447
459
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
448
460
|
run_installer(os.path.join(HERE, "installer.toml"),
|
|
449
461
|
identity=InstallerIdentity(slug="acme-tools", title="Acme Tools"),
|
|
462
|
+
default_root_name="acme-tools",
|
|
450
463
|
entry_script=__file__)
|
|
451
464
|
```
|
|
452
465
|
|
|
453
466
|
`run_installer` takes `--root DIR` for itself and leaves every other flag to the
|
|
454
467
|
engine, so `--list`, `--apply`, `--skill-target`, `--check`, `--refresh`, `--tui`
|
|
455
|
-
and `--gui` work as they do without sources. Every keyword besides `config_path
|
|
456
|
-
and `
|
|
457
|
-
function's own to set and passing either raises
|
|
468
|
+
and `--gui` work as they do without sources. Every keyword besides `config_path`,
|
|
469
|
+
`argv` and `default_root_name` goes to `run()`; `discovery_roots` and
|
|
470
|
+
`pre_discovery` are the function's own to set and passing either raises
|
|
471
|
+
`TypeError`.
|
|
458
472
|
|
|
459
473
|
Without a wrapper, the same thing from the command line:
|
|
460
474
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cli-tools-kit"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.1"
|
|
8
8
|
description = "Installer protocol + helpers for self-installing Python CLI/GUI tools (desktop shortcuts, bash aliases, cron entries), plus reusable tkinter and curses installer screens"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -151,6 +151,29 @@ def test_refresh_pulls_a_clone_ff_only(tmp_path: Path, monkeypatch) -> None:
|
|
|
151
151
|
assert "clone" not in calls[0]
|
|
152
152
|
|
|
153
153
|
|
|
154
|
+
def test_a_failed_pull_keeps_the_clone_as_a_discovery_root(tmp_path: Path,
|
|
155
|
+
monkeypatch) -> None:
|
|
156
|
+
"""A remote that is gone, a network that is down, a diverged history.
|
|
157
|
+
|
|
158
|
+
One line about it, and the checkout that is already on disk is still used.
|
|
159
|
+
"""
|
|
160
|
+
target = _repo(tmp_path / "root" / "org" / "lab")
|
|
161
|
+
|
|
162
|
+
class Result:
|
|
163
|
+
returncode = 128
|
|
164
|
+
stdout = ""
|
|
165
|
+
stderr = ("fatal: repository 'https://example.invalid/lab.git/' not found\n")
|
|
166
|
+
|
|
167
|
+
monkeypatch.setattr(sources.subprocess, "run", lambda cmd, **kw: Result())
|
|
168
|
+
lines: list = []
|
|
169
|
+
source = Source(name="org/lab", url="https://example.invalid/lab.git")
|
|
170
|
+
assert resolve_sources([source], tmp_path / "root", refresh=True,
|
|
171
|
+
log=lines.append) == [target]
|
|
172
|
+
assert target.is_dir()
|
|
173
|
+
assert len(lines) == 1
|
|
174
|
+
assert "not found" in lines[0]
|
|
175
|
+
|
|
176
|
+
|
|
154
177
|
def test_a_checkout_given_by_path_is_never_pulled(tmp_path: Path, monkeypatch) -> None:
|
|
155
178
|
given = _repo(tmp_path / "given")
|
|
156
179
|
calls: list = []
|
|
@@ -289,9 +312,11 @@ def _tree(tmp_path: Path) -> Path:
|
|
|
289
312
|
'[[source]]\nname = "org/tools"\npath = "."\n')
|
|
290
313
|
|
|
291
314
|
|
|
292
|
-
def test_run_installer_wires_roots_and_the_hook(tmp_path: Path, engine
|
|
315
|
+
def test_run_installer_wires_roots_and_the_hook(tmp_path: Path, engine,
|
|
316
|
+
monkeypatch) -> None:
|
|
293
317
|
config = _tree(tmp_path)
|
|
294
|
-
|
|
318
|
+
monkeypatch.chdir(tmp_path)
|
|
319
|
+
sources.run_installer(config, argv=["--list", "--root", str(tmp_path)])
|
|
295
320
|
assert engine["root_dir"] == str(tmp_path)
|
|
296
321
|
assert engine["discovery_roots"] == [str(tmp_path / "org" / "tools")]
|
|
297
322
|
assert callable(engine["pre_discovery"])
|
|
@@ -311,7 +336,8 @@ def test_root_flag_wins_and_is_consumed(tmp_path: Path, engine) -> None:
|
|
|
311
336
|
assert sys.argv[1:] == ["--check"]
|
|
312
337
|
|
|
313
338
|
|
|
314
|
-
def test_local_root_is_the_default_when_no_flag(tmp_path: Path, engine
|
|
339
|
+
def test_local_root_is_the_default_when_no_flag(tmp_path: Path, engine,
|
|
340
|
+
never_asks) -> None:
|
|
315
341
|
config = _tree(tmp_path)
|
|
316
342
|
_write(config.with_name("installer.local.toml"), f'root = "{tmp_path / "here"}"\n')
|
|
317
343
|
sources.run_installer(config, argv=[])
|
|
@@ -332,7 +358,7 @@ url = "https://example.invalid/lab.git"
|
|
|
332
358
|
""")
|
|
333
359
|
calls: list = []
|
|
334
360
|
monkeypatch.setattr(sources.subprocess, "run", _fake_git(calls))
|
|
335
|
-
sources.run_installer(config, argv=[])
|
|
361
|
+
sources.run_installer(config, argv=["--root", str(tmp_path)])
|
|
336
362
|
roots = engine["discovery_roots"]
|
|
337
363
|
# Before the hook: only what is on disk, so --check never needs the network.
|
|
338
364
|
assert roots == [str(tmp_path / "org" / "tools")]
|
|
@@ -341,6 +367,140 @@ url = "https://example.invalid/lab.git"
|
|
|
341
367
|
assert any("clone" in call for call in calls)
|
|
342
368
|
|
|
343
369
|
|
|
370
|
+
# --- where the tools are installed ------------------------------------------
|
|
371
|
+
|
|
372
|
+
def _refuse(default):
|
|
373
|
+
raise AssertionError(f"asked for a root, suggesting {default}")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@pytest.fixture
|
|
377
|
+
def never_asks(monkeypatch):
|
|
378
|
+
"""Fail the test if either prompt is reached."""
|
|
379
|
+
monkeypatch.setattr(sources, "_ask_root_gui", _refuse)
|
|
380
|
+
monkeypatch.setattr(sources, "_ask_root_stdin", _refuse)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _answers(monkeypatch, answer, gui: bool = True):
|
|
384
|
+
"""Route the question to one screen and answer it. Returns the suggestions."""
|
|
385
|
+
seen: list = []
|
|
386
|
+
|
|
387
|
+
def ask(default):
|
|
388
|
+
seen.append(default)
|
|
389
|
+
return answer
|
|
390
|
+
|
|
391
|
+
monkeypatch.setattr(sources, "_wants_gui", lambda argv: gui)
|
|
392
|
+
monkeypatch.setattr(sources, "_ask_root_gui" if gui else "_ask_root_stdin", ask)
|
|
393
|
+
if not gui:
|
|
394
|
+
monkeypatch.setattr(sources.sys, "stdin",
|
|
395
|
+
type("Tty", (), {"isatty": staticmethod(lambda: True)})())
|
|
396
|
+
return seen
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def test_the_root_flag_is_used_without_asking(tmp_path: Path, engine, never_asks,
|
|
400
|
+
monkeypatch) -> None:
|
|
401
|
+
config = _tree(tmp_path)
|
|
402
|
+
monkeypatch.chdir(tmp_path)
|
|
403
|
+
sources.run_installer(config, argv=["--root", str(tmp_path / "chosen")])
|
|
404
|
+
assert engine["root_dir"] == str(tmp_path / "chosen")
|
|
405
|
+
assert (tmp_path / "chosen").is_dir() # created for us
|
|
406
|
+
# Nothing is remembered: the flag is for this run.
|
|
407
|
+
assert not config.with_name("installer.local.toml").exists()
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_the_answer_is_used_and_remembered(tmp_path: Path, engine, monkeypatch) -> None:
|
|
411
|
+
config = _tree(tmp_path)
|
|
412
|
+
monkeypatch.chdir(tmp_path)
|
|
413
|
+
chosen = tmp_path / "picked"
|
|
414
|
+
suggested = _answers(monkeypatch, str(chosen))
|
|
415
|
+
|
|
416
|
+
sources.run_installer(config, argv=[])
|
|
417
|
+
assert engine["root_dir"] == str(chosen)
|
|
418
|
+
assert chosen.is_dir()
|
|
419
|
+
assert suggested == [str(tmp_path / "tools")] # <cwd>/<default_root_name>
|
|
420
|
+
assert sources.local_root(config) == str(chosen)
|
|
421
|
+
|
|
422
|
+
# Asked once: the second run reads the file.
|
|
423
|
+
monkeypatch.setattr(sources, "_ask_root_gui", _refuse)
|
|
424
|
+
sources.run_installer(config, argv=[])
|
|
425
|
+
assert engine["root_dir"] == str(chosen)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def test_the_answer_keeps_the_local_files_sources(tmp_path: Path, engine,
|
|
429
|
+
monkeypatch) -> None:
|
|
430
|
+
config = _tree(tmp_path)
|
|
431
|
+
monkeypatch.chdir(tmp_path)
|
|
432
|
+
local = _write(config.with_name("installer.local.toml"),
|
|
433
|
+
f'[[source]]\nname = "org/tools"\npath = "{tmp_path / "other"}"\n')
|
|
434
|
+
_answers(monkeypatch, str(tmp_path / "picked"))
|
|
435
|
+
sources.run_installer(config, argv=[])
|
|
436
|
+
assert sources.local_root(config) == str(tmp_path / "picked")
|
|
437
|
+
assert load_sources(config)[0].path == str(tmp_path / "other")
|
|
438
|
+
assert '[[source]]' in local.read_text(encoding="utf-8")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_an_existing_root_in_the_local_file_is_never_overwritten(tmp_path: Path) -> None:
|
|
442
|
+
config = _write(tmp_path / "installer.toml", "")
|
|
443
|
+
_write(config.with_name("installer.local.toml"), f'root = "{tmp_path / "mine"}"\n')
|
|
444
|
+
assert sources.save_local_root(config, str(tmp_path / "other")) is False
|
|
445
|
+
assert sources.local_root(config) == str(tmp_path / "mine")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_the_default_root_name_names_the_suggested_folder(tmp_path: Path, engine,
|
|
449
|
+
monkeypatch) -> None:
|
|
450
|
+
config = _tree(tmp_path)
|
|
451
|
+
monkeypatch.chdir(tmp_path)
|
|
452
|
+
suggested = _answers(monkeypatch, str(tmp_path / "picked"))
|
|
453
|
+
sources.run_installer(config, argv=[], default_root_name="WW3-tools")
|
|
454
|
+
assert suggested == [str(tmp_path / "WW3-tools")]
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def test_cancelling_the_question_stops_cleanly(tmp_path: Path, engine,
|
|
458
|
+
monkeypatch) -> None:
|
|
459
|
+
config = _tree(tmp_path)
|
|
460
|
+
monkeypatch.chdir(tmp_path)
|
|
461
|
+
_answers(monkeypatch, None)
|
|
462
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
463
|
+
sources.run_installer(config, argv=[])
|
|
464
|
+
assert exit_info.value.code == 0
|
|
465
|
+
assert engine == {} # the installer never opened
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def test_the_text_screen_asks_on_stdin(tmp_path: Path, engine, monkeypatch) -> None:
|
|
469
|
+
config = _tree(tmp_path)
|
|
470
|
+
monkeypatch.chdir(tmp_path)
|
|
471
|
+
suggested = _answers(monkeypatch, str(tmp_path / "picked"), gui=False)
|
|
472
|
+
sources.run_installer(config, argv=["--tui"])
|
|
473
|
+
assert suggested == [str(tmp_path / "tools")]
|
|
474
|
+
assert engine["root_dir"] == str(tmp_path / "picked")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@pytest.mark.parametrize("argv", [["--list"], ["--check"], ["--apply", "all"],
|
|
478
|
+
["--apply=all"]])
|
|
479
|
+
def test_a_headless_run_takes_the_default_and_says_so(tmp_path: Path, engine,
|
|
480
|
+
never_asks, monkeypatch,
|
|
481
|
+
capsys, argv) -> None:
|
|
482
|
+
config = _tree(tmp_path)
|
|
483
|
+
monkeypatch.chdir(tmp_path)
|
|
484
|
+
sources.run_installer(config, argv=list(argv))
|
|
485
|
+
assert engine["root_dir"] == str(tmp_path / "tools")
|
|
486
|
+
assert (f"root: {tmp_path / 'tools'} (pass --root to change)"
|
|
487
|
+
in capsys.readouterr().out)
|
|
488
|
+
# A default is not an answer, so it is not written to the local file.
|
|
489
|
+
assert not config.with_name("installer.local.toml").exists()
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def test_no_question_without_a_terminal(tmp_path: Path, engine, never_asks,
|
|
493
|
+
monkeypatch, capsys) -> None:
|
|
494
|
+
config = _tree(tmp_path)
|
|
495
|
+
monkeypatch.chdir(tmp_path)
|
|
496
|
+
monkeypatch.setattr(sources, "_wants_gui", lambda argv: False)
|
|
497
|
+
monkeypatch.setattr(sources.sys, "stdin",
|
|
498
|
+
type("Pipe", (), {"isatty": staticmethod(lambda: False)})())
|
|
499
|
+
sources.run_installer(config, argv=[])
|
|
500
|
+
assert engine["root_dir"] == str(tmp_path / "tools")
|
|
501
|
+
assert "pass --root to change" in capsys.readouterr().out
|
|
502
|
+
|
|
503
|
+
|
|
344
504
|
def test_run_installer_refuses_to_have_its_own_arguments_overridden(tmp_path: Path,
|
|
345
505
|
engine) -> None:
|
|
346
506
|
config = _tree(tmp_path)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|