cli-tools-kit 0.8.2__tar.gz → 0.8.3__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.
Files changed (45) hide show
  1. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/PKG-INFO +1 -1
  2. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/__init__.py +1 -1
  3. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/gui_installer.py +45 -9
  4. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/tui_installer.py +3 -2
  5. cli_tools_kit-0.8.3/cli_tools_kit/venvs.py +319 -0
  6. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/PKG-INFO +1 -1
  7. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/SOURCES.txt +3 -1
  8. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/pyproject.toml +1 -1
  9. cli_tools_kit-0.8.3/tests/test_venvs.py +242 -0
  10. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/LICENSE +0 -0
  11. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/README.md +0 -0
  12. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/__main__.py +0 -0
  13. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/advertise.py +0 -0
  14. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/autostart_gate.py +0 -0
  15. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/cron_installer.py +0 -0
  16. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/host.py +0 -0
  17. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/identity.py +0 -0
  18. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/onboarding.py +0 -0
  19. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/skills.py +0 -0
  20. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/sources.py +0 -0
  21. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/__init__.py +0 -0
  22. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/build.py +0 -0
  23. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/capability.py +0 -0
  24. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/cluster.py +0 -0
  25. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/corpus.py +0 -0
  26. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/embedder.py +0 -0
  27. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/groups.py +0 -0
  28. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
  29. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit/tool_installer.py +0 -0
  30. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
  31. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/entry_points.txt +0 -0
  32. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/requires.txt +0 -0
  33. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/top_level.txt +0 -0
  34. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/setup.cfg +0 -0
  35. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/tests/test_autostart_gate.py +0 -0
  36. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/tests/test_capability_groups.py +0 -0
  37. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/tests/test_cron_installer.py +0 -0
  38. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/tests/test_host.py +0 -0
  39. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/tests/test_identity.py +0 -0
  40. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/tests/test_llm_groups.py +0 -0
  41. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/tests/test_skills.py +0 -0
  42. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/tests/test_sources.py +0 -0
  43. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/tests/test_taxonomy_cluster.py +0 -0
  44. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/tests/test_tool_installer.py +0 -0
  45. {cli_tools_kit-0.8.2 → cli_tools_kit-0.8.3}/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.8.2
3
+ Version: 0.8.3
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
@@ -56,4 +56,4 @@ __all__ = [
56
56
  "read_installed_skill",
57
57
  ]
58
58
 
59
- __version__ = "0.8.2"
59
+ __version__ = "0.8.3"
@@ -60,6 +60,7 @@ except ImportError: # pragma: no cover - exercised only where python3-tk is mis
60
60
  from typing import Callable, Dict, List, NamedTuple, Optional, Sequence
61
61
 
62
62
  from . import host
63
+ from . import venvs
63
64
  from .identity import InstallerIdentity, LEGACY_IDENTITY
64
65
  from .autostart_gate import (
65
66
  KNOWN_CONDITIONS,
@@ -1149,9 +1150,20 @@ def install_tool(tool: ToolEntry, skip_deps: bool = False) -> tuple[bool, str]:
1149
1150
  When skip_deps is True, sets TOOLS_INSTALLER_SKIP_DEPS=1 in the child
1150
1151
  environment so the shared ToolInstaller writes the .desktop/alias without
1151
1152
  running 'pip install'. This keeps a shortcut refresh local and network-free.
1153
+
1154
+ The tool runs in the venv :mod:`cli_tools_kit.venvs` resolves for its
1155
+ checkout — shared with every other checkout of the same upstream repo, so
1156
+ a tool offered by two installers is built once. That venv is provisioned
1157
+ here, before --install runs, because the tool imports this package to
1158
+ handle the flag and cannot install what it is already missing.
1152
1159
  """
1153
1160
  try:
1154
- cmd = [sys.executable, tool.script_path, "--install"] + tool.args
1161
+ tool_dir = os.path.dirname(tool.script_path)
1162
+ try:
1163
+ python = venvs.ensure_venv(tool_dir, provision=not skip_deps)
1164
+ except RuntimeError as exc:
1165
+ return False, str(exc)
1166
+ cmd = [python, tool.script_path, "--install"] + tool.args
1155
1167
  env = os.environ.copy()
1156
1168
  # The tool writes its own .desktop and alias via ToolInstaller, so it
1157
1169
  # needs to know which installer asked — otherwise a third-party org's
@@ -1171,9 +1183,17 @@ def install_tool(tool: ToolEntry, skip_deps: bool = False) -> tuple[bool, str]:
1171
1183
  return False, str(e)
1172
1184
 
1173
1185
  def remove_tool(tool: ToolEntry) -> tuple[bool, str]:
1174
- """Invokes the tool's own --remove argument. Returns (success, output)."""
1186
+ """Invokes the tool's own --remove argument. Returns (success, output).
1187
+
1188
+ Runs in the tool's venv, provisioning nothing: removal must work even when
1189
+ the environment is half-built, and it must not rebuild what it is about to
1190
+ stop using. The shared venv stays behind — another installer's checkout of
1191
+ the same upstream may still need it.
1192
+ """
1175
1193
  try:
1176
- cmd = [sys.executable, tool.script_path, "--remove"] + tool.args
1194
+ python = venvs.ensure_venv(os.path.dirname(tool.script_path),
1195
+ provision=False)
1196
+ cmd = [python, tool.script_path, "--remove"] + tool.args
1177
1197
  env = os.environ.copy()
1178
1198
  env.update(IDENTITY.env()) # remove the alias from OUR alias file
1179
1199
  result = subprocess.run(cmd, cwd=os.path.dirname(tool.script_path),
@@ -1194,7 +1214,12 @@ def install_skill_for_tool(tool: ToolEntry) -> tuple[bool, str]:
1194
1214
  detect an up-to-date SKILL.md and no-op.
1195
1215
  """
1196
1216
  try:
1197
- cmd = [sys.executable, tool.script_path, "--install-skill"] + tool.args
1217
+ # provision=False: ticking a skill box must not build a venv. An
1218
+ # uninstalled tool still registers its skill, under whichever
1219
+ # interpreter is to hand.
1220
+ python = venvs.ensure_venv(os.path.dirname(tool.script_path),
1221
+ provision=False)
1222
+ cmd = [python, tool.script_path, "--install-skill"] + tool.args
1198
1223
  result = subprocess.run(cmd, cwd=os.path.dirname(tool.script_path),
1199
1224
  capture_output=True, text=True)
1200
1225
  output = (result.stdout + result.stderr).strip()
@@ -1208,7 +1233,9 @@ def install_skill_for_tool(tool: ToolEntry) -> tuple[bool, str]:
1208
1233
  def uninstall_skill_for_tool(tool: ToolEntry) -> tuple[bool, str]:
1209
1234
  """Invokes the tool's --uninstall-skill argument. Returns (success, output)."""
1210
1235
  try:
1211
- cmd = [sys.executable, tool.script_path, "--uninstall-skill"] + tool.args
1236
+ python = venvs.ensure_venv(os.path.dirname(tool.script_path),
1237
+ provision=False)
1238
+ cmd = [python, tool.script_path, "--uninstall-skill"] + tool.args
1212
1239
  result = subprocess.run(cmd, cwd=os.path.dirname(tool.script_path),
1213
1240
  capture_output=True, text=True)
1214
1241
  output = (result.stdout + result.stderr).strip()
@@ -1419,8 +1446,15 @@ def get_autostart_path(tool: ToolEntry) -> str:
1419
1446
 
1420
1447
 
1421
1448
  def _cron_line_for_tool(tool: ToolEntry) -> str:
1422
- """Build the crontab line for a cron-based tool."""
1423
- parts = [tool.cron_schedule, sys.executable, tool.script_path] + list(tool.cron_args)
1449
+ """Build the crontab line for a cron-based tool.
1450
+
1451
+ The interpreter is the tool's own venv, not the installer's: cron runs the
1452
+ line long after the installer has exited, and the installer's Python has
1453
+ none of the tool's dependencies.
1454
+ """
1455
+ python = venvs.ensure_venv(os.path.dirname(tool.script_path),
1456
+ provision=False)
1457
+ parts = [tool.cron_schedule, python, tool.script_path] + list(tool.cron_args)
1424
1458
  return " ".join(parts)
1425
1459
 
1426
1460
 
@@ -3783,8 +3817,10 @@ class InstallerApp:
3783
3817
  dialog.after(0, mark_failed)
3784
3818
 
3785
3819
  except Exception as e:
3820
+ error_msg = str(e)[:20]
3821
+
3786
3822
  def mark_error():
3787
- update_grid_cell(idx, None, f"Error: {str(e)[:20]}")
3823
+ update_grid_cell(idx, None, f"Error: {error_msg}")
3788
3824
  current_index[0] += 1
3789
3825
  dialog.after(100, generate_next_varied)
3790
3826
  dialog.after(0, mark_error)
@@ -4663,7 +4699,7 @@ class InstallerApp:
4663
4699
  autostart_var.trace_add("write", self._on_checkbox_changed)
4664
4700
  # A tool with conditions gets the checkbox and a ⚙ side by side;
4665
4701
  # without them the checkbox stays centred as before.
4666
- if tool.autostart_conditions:
4702
+ if parent.autostart_conditions:
4667
4703
  holder = ttk.Frame(autostart_frame, style="Card.TFrame")
4668
4704
  holder.pack(expand=True)
4669
4705
  autostart_cb = ttk.Checkbutton(holder, variable=autostart_var, style="Card.TCheckbutton")
@@ -12,8 +12,9 @@ step, drawn with the stdlib ``curses`` module.
12
12
 
13
13
  Everything that touches the host goes through :mod:`gui_installer`'s
14
14
  primitives, looked up on that module at call time, so a wrapper that replaced
15
- them (FAU-WW3-Tools routes each tool through its own venv) is honoured here
16
- as well.
15
+ them is honoured here as well. Routing each tool through its own venv is no
16
+ longer something a wrapper has to do: :mod:`cli_tools_kit.venvs` is the default
17
+ for both screens.
17
18
 
18
19
  Skill targets
19
20
  -------------
@@ -0,0 +1,319 @@
1
+ """One venv per tool, shared by every installer that offers that tool.
2
+
3
+ A host often carries the same tool twice. Two installers each clone
4
+ ``manim-kit`` from the same GitHub repo into their own tree, and each
5
+ provisions its own ``.venv`` next to the checkout — two copies of manim, two
6
+ copies of Kokoro, 666M apiece, from one upstream.
7
+
8
+ This module puts the venv somewhere neither checkout owns:
9
+
10
+ <IDENTITY.cache_path>/venvs/<key>/
11
+
12
+ ``key`` comes from the checkout's ``origin`` remote, normalised so that
13
+ ``git@github.com:Org/tool`` and ``https://github.com/Org/tool.git`` agree
14
+ (:func:`upstream_key`). Both checkouts resolve to the same directory, so the
15
+ second install finds the environment already built. A checkout with no origin
16
+ remote is nobody's duplicate: it keeps a private ``.venv`` in the tool
17
+ directory, exactly as before this module existed.
18
+
19
+ Requirements drift between checkouts
20
+ ------------------------------------
21
+ Two checkouts of one tool sit at different commits, so their
22
+ ``requirements.txt`` differ. The shared venv records what it last installed in
23
+ ``.cli-tools-kit.json`` (:func:`read_manifest`) together with the checkout and
24
+ commit that wrote it. :func:`ensure_venv` compares the incoming requirement
25
+ lines against that record and reinstalls when they differ, so whichever
26
+ checkout ran most recently has its dependencies present.
27
+
28
+ Where one checkout's requirements are a superset of the other's — the usual
29
+ case, a tool that grew a dependency — pip converges and both checkouts work.
30
+ Where two pins genuinely contradict each other pip fails, and the error names
31
+ the checkout that last wrote the venv, because that is the one holding the
32
+ environment the current install is fighting.
33
+
34
+ Adopting what is already on disk
35
+ --------------------------------
36
+ A per-checkout ``.venv`` from before the move is not deleted. The first
37
+ install after the upgrade adopts one (:func:`adopt_existing`) as the shared
38
+ venv when no shared venv exists yet; any further ones are left alone on disk
39
+ for the user to remove. Deleting a few hundred megabytes is the user's call,
40
+ so the installer only reports them.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import hashlib
46
+ import json
47
+ import os
48
+ import re
49
+ import subprocess
50
+ import sys
51
+ from typing import Optional
52
+
53
+ MANIFEST_NAME = ".cli-tools-kit.json"
54
+
55
+ # git@host:org/repo.git and ssh://git@host/org/repo both mean the same upstream
56
+ # as https://host/org/repo — strip the transport so the key does not.
57
+ _SCP_LIKE = re.compile(r"^(?:[\w.-]+@)?([\w.-]+):(.+)$")
58
+ _URL_LIKE = re.compile(r"^[a-z][a-z0-9+.-]*://(?:[^@/]+@)?([\w.-]+)/(.+)$", re.I)
59
+
60
+
61
+ def normalise_remote(url: str) -> Optional[str]:
62
+ """``host/path`` for a git remote, or None when it does not look like one.
63
+
64
+ Lowercased and stripped of the transport, any credentials, a trailing
65
+ ``.git`` and trailing slashes, so every spelling of one repo gives one
66
+ string::
67
+
68
+ git@github.com:Org/tool.git -> github.com/org/tool
69
+ https://github.com/Org/tool -> github.com/org/tool
70
+
71
+ A remote that is a local path — a bare repo on this machine, or a mirror on
72
+ a mounted share — is resolved to its absolute path under a ``local`` host.
73
+ Two clones of one such repo are as much the same upstream as two clones of
74
+ one GitHub repo, and share a venv on the same terms. Case is preserved
75
+ here: unlike a hostname, a POSIX path distinguishes it.
76
+ """
77
+ url = (url or "").strip()
78
+ if not url:
79
+ return None
80
+ if url.startswith("file://"):
81
+ url = url[len("file://"):]
82
+ # A local path first: on Windows `C:\repos\tool` also matches the scp-like
83
+ # pattern, and reading it as host `c` would be wrong.
84
+ if url.startswith(("/", "./", "../", "~")) or os.path.isabs(url) \
85
+ or os.path.isdir(url):
86
+ path = os.path.normpath(os.path.abspath(os.path.expanduser(url)))
87
+ if path.endswith(".git"):
88
+ path = path[: -len(".git")]
89
+ return f"local{path}" if path.startswith("/") else f"local/{path}"
90
+ m = _URL_LIKE.match(url) or _SCP_LIKE.match(url)
91
+ if not m:
92
+ return None
93
+ host, path = m.group(1), m.group(2)
94
+ path = path.strip("/")
95
+ if path.lower().endswith(".git"):
96
+ path = path[: -len(".git")]
97
+ if not path:
98
+ return None
99
+ return f"{host.lower()}/{path.lower()}"
100
+
101
+
102
+ def origin_url(tool_dir: str) -> Optional[str]:
103
+ """The ``origin`` remote of the checkout at ``tool_dir``, if it has one."""
104
+ try:
105
+ result = subprocess.run(
106
+ ["git", "-C", tool_dir, "remote", "get-url", "origin"],
107
+ capture_output=True, text=True, timeout=10,
108
+ )
109
+ except (OSError, subprocess.SubprocessError):
110
+ return None
111
+ if result.returncode != 0:
112
+ return None
113
+ return result.stdout.strip() or None
114
+
115
+
116
+ def head_commit(tool_dir: str) -> Optional[str]:
117
+ """Short HEAD of the checkout, for the manifest's record of who wrote it."""
118
+ try:
119
+ result = subprocess.run(
120
+ ["git", "-C", tool_dir, "rev-parse", "--short", "HEAD"],
121
+ capture_output=True, text=True, timeout=10,
122
+ )
123
+ except (OSError, subprocess.SubprocessError):
124
+ return None
125
+ if result.returncode != 0:
126
+ return None
127
+ return result.stdout.strip() or None
128
+
129
+
130
+ def upstream_key(tool_dir: str) -> Optional[str]:
131
+ """Stable directory name for the venv shared by one upstream repo.
132
+
133
+ None when the checkout has no usable ``origin``, which means it cannot be
134
+ matched with any other checkout and so keeps a private venv.
135
+ """
136
+ normalised = normalise_remote(origin_url(tool_dir) or "")
137
+ if not normalised:
138
+ return None
139
+ digest = hashlib.sha256(normalised.encode("utf-8")).hexdigest()[:12]
140
+ # The leaf name is readable on purpose: a user looking through the cache
141
+ # should see which tool a directory belongs to without resolving a hash.
142
+ leaf = re.sub(r"[^a-z0-9]+", "-", normalised.rsplit("/", 1)[-1]).strip("-")
143
+ return f"{leaf}-{digest}" if leaf else digest
144
+
145
+
146
+ def venvs_root() -> str:
147
+ """Directory holding every shared venv for this installer's organisation.
148
+
149
+ ``gui_installer.IDENTITY`` is rebound when a wrapper calls ``run()``, so it
150
+ is read here at call time rather than imported once — two organisations on
151
+ one host must not share a cache directory.
152
+ """
153
+ from . import gui_installer
154
+ return os.path.join(gui_installer.IDENTITY.cache_path, "venvs")
155
+
156
+
157
+ def venv_python(venv_dir: str) -> str:
158
+ """Interpreter path inside ``venv_dir`` on this platform."""
159
+ if os.name == "nt":
160
+ return os.path.join(venv_dir, "Scripts", "python.exe")
161
+ return os.path.join(venv_dir, "bin", "python3")
162
+
163
+
164
+ def private_venv(tool_dir: str) -> str:
165
+ """The pre-0.9 location: a ``.venv`` beside the tool's own script."""
166
+ return os.path.join(tool_dir, ".venv")
167
+
168
+
169
+ def venv_for(tool_dir: str) -> str:
170
+ """Where this checkout's venv belongs — shared when it has an upstream."""
171
+ key = upstream_key(tool_dir)
172
+ if not key:
173
+ return private_venv(tool_dir)
174
+ return os.path.join(venvs_root(), key)
175
+
176
+
177
+ def requirement_lines(requirements: str) -> list:
178
+ """Requirement lines of a requirements file, without comments or blanks."""
179
+ try:
180
+ with open(requirements, encoding="utf-8") as fh:
181
+ lines = fh.readlines()
182
+ except OSError:
183
+ return []
184
+ out = []
185
+ for line in lines:
186
+ line = line.strip()
187
+ if line and not line.startswith("#"):
188
+ out.append(line)
189
+ return out
190
+
191
+
192
+ def manifest_path(venv_dir: str) -> str:
193
+ return os.path.join(venv_dir, MANIFEST_NAME)
194
+
195
+
196
+ def read_manifest(venv_dir: str) -> dict:
197
+ """What this venv last installed, or ``{}`` when it has no record."""
198
+ try:
199
+ with open(manifest_path(venv_dir), encoding="utf-8") as fh:
200
+ data = json.load(fh)
201
+ except (OSError, ValueError):
202
+ return {}
203
+ return data if isinstance(data, dict) else {}
204
+
205
+
206
+ def write_manifest(venv_dir: str, requirements: list, tool_dir: str) -> None:
207
+ """Record the requirements just installed and the checkout that asked."""
208
+ data = {
209
+ "requirements": list(requirements),
210
+ "written_by": os.path.abspath(tool_dir),
211
+ "commit": head_commit(tool_dir),
212
+ }
213
+ try:
214
+ os.makedirs(venv_dir, exist_ok=True)
215
+ with open(manifest_path(venv_dir), "w", encoding="utf-8") as fh:
216
+ json.dump(data, fh, indent=2, sort_keys=True)
217
+ except OSError:
218
+ pass # a venv that works but cannot be annotated still works
219
+
220
+
221
+ def adopt_existing(tool_dir: str, venv_dir: str) -> bool:
222
+ """Move a pre-existing private ``.venv`` into the shared location.
223
+
224
+ Only when the shared venv does not exist yet, so the first install after
225
+ the upgrade reuses what is already built instead of downloading it again.
226
+ Returns True when a venv was adopted. Other checkouts' private venvs are
227
+ left where they are; :func:`stale_private_venvs` reports them.
228
+ """
229
+ private = private_venv(tool_dir)
230
+ if os.path.abspath(private) == os.path.abspath(venv_dir):
231
+ return False
232
+ if os.path.exists(venv_dir) or not os.path.isdir(private):
233
+ return False
234
+ if not os.path.exists(venv_python(private)):
235
+ return False
236
+ try:
237
+ os.makedirs(os.path.dirname(venv_dir), exist_ok=True)
238
+ os.rename(private, venv_dir)
239
+ except OSError:
240
+ return False # across filesystems, or in use: build a fresh one instead
241
+ # A venv records its own location in pyvenv.cfg and in the shebangs of its
242
+ # scripts, so a moved one is only safe through its interpreter, never
243
+ # through `<venv>/bin/<entry-point>`. The kit always calls the interpreter.
244
+ return True
245
+
246
+
247
+ def stale_private_venvs(tool_dirs) -> list:
248
+ """Private ``.venv`` directories made redundant by a shared venv.
249
+
250
+ Reported to the user, never deleted: these run to hundreds of megabytes
251
+ and removing them is a decision the installer does not get to make.
252
+ """
253
+ out = []
254
+ for tool_dir in tool_dirs:
255
+ private = private_venv(tool_dir)
256
+ shared = venv_for(tool_dir)
257
+ if os.path.abspath(private) == os.path.abspath(shared):
258
+ continue
259
+ if os.path.isdir(private) and os.path.isdir(shared):
260
+ out.append(private)
261
+ return out
262
+
263
+
264
+ def ensure_venv(tool_dir: str, requirements: Optional[str] = None,
265
+ provision: bool = True) -> str:
266
+ """Interpreter to run this tool with, building the venv when asked.
267
+
268
+ Creates the shared venv if it is missing, adopting an existing private one
269
+ first. Installs the checkout's requirements whenever they differ from what
270
+ the manifest records, so a second checkout at a different commit gets its
271
+ own dependencies rather than inheriting a stale set.
272
+
273
+ With ``provision=False`` nothing is created or installed: the existing
274
+ interpreter is returned when there is one, else the current one. That is
275
+ the path taken by the login check and by a shortcuts-only refresh.
276
+ """
277
+ venv_dir = venv_for(tool_dir)
278
+ py = venv_python(venv_dir)
279
+
280
+ if not provision:
281
+ return py if os.path.exists(py) else sys.executable
282
+
283
+ if requirements is None:
284
+ requirements = os.path.join(tool_dir, "requirements.txt")
285
+ wanted = requirement_lines(requirements)
286
+ if not wanted:
287
+ # Nothing to install: a venv would add nothing over the interpreter
288
+ # already running, and building one costs a directory for no gain.
289
+ return py if os.path.exists(py) else sys.executable
290
+
291
+ if not os.path.exists(py):
292
+ adopt_existing(tool_dir, venv_dir)
293
+ if not os.path.exists(py):
294
+ try:
295
+ os.makedirs(os.path.dirname(venv_dir), exist_ok=True)
296
+ subprocess.run([sys.executable, "-m", "venv", venv_dir], check=True)
297
+ except (OSError, subprocess.CalledProcessError):
298
+ return sys.executable # no venv to be had; the caller still runs
299
+
300
+ manifest = read_manifest(venv_dir)
301
+ if manifest.get("requirements") == wanted:
302
+ return py if os.path.exists(py) else sys.executable
303
+
304
+ try:
305
+ subprocess.run([py, "-m", "pip", "install", "-r", requirements],
306
+ check=True)
307
+ except (OSError, subprocess.CalledProcessError) as exc:
308
+ owner = manifest.get("written_by")
309
+ if owner and os.path.abspath(owner) != os.path.abspath(tool_dir):
310
+ commit = manifest.get("commit") or "unknown commit"
311
+ raise RuntimeError(
312
+ f"{requirements} could not be installed into the venv shared "
313
+ f"with {owner} (last written from {commit}). The two checkouts "
314
+ f"pin dependencies that cannot both hold. Pip said: {exc}"
315
+ ) from exc
316
+ raise
317
+
318
+ write_manifest(venv_dir, wanted, tool_dir)
319
+ return py if os.path.exists(py) else sys.executable
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-tools-kit
3
- Version: 0.8.2
3
+ Version: 0.8.3
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
@@ -14,6 +14,7 @@ cli_tools_kit/skills.py
14
14
  cli_tools_kit/sources.py
15
15
  cli_tools_kit/tool_installer.py
16
16
  cli_tools_kit/tui_installer.py
17
+ cli_tools_kit/venvs.py
17
18
  cli_tools_kit.egg-info/PKG-INFO
18
19
  cli_tools_kit.egg-info/SOURCES.txt
19
20
  cli_tools_kit.egg-info/dependency_links.txt
@@ -38,4 +39,5 @@ tests/test_skills.py
38
39
  tests/test_sources.py
39
40
  tests/test_taxonomy_cluster.py
40
41
  tests/test_tool_installer.py
41
- tests/test_tui_installer.py
42
+ tests/test_tui_installer.py
43
+ tests/test_venvs.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cli-tools-kit"
7
- version = "0.8.2"
7
+ version = "0.8.3"
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"
@@ -0,0 +1,242 @@
1
+ """Tests for venvs — one venv per upstream, shared across installers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from cli_tools_kit import venvs
11
+
12
+
13
+ def _pip_calls(monkeypatch: pytest.MonkeyPatch, tool: Path) -> list:
14
+ """Run ensure_venv with pip stubbed out, returning the pip commands it ran.
15
+
16
+ Only pip is intercepted: the git calls that resolve the upstream key have
17
+ to keep working, or the venv would not resolve to the shared location the
18
+ test just prepared.
19
+ """
20
+ real_run = subprocess.run
21
+ calls = []
22
+
23
+ def fake_run(cmd, *a, **k):
24
+ if "pip" in cmd:
25
+ calls.append(cmd)
26
+ return subprocess.CompletedProcess(cmd, 0, "", "")
27
+ return real_run(cmd, *a, **k)
28
+
29
+ monkeypatch.setattr(venvs.subprocess, "run", fake_run)
30
+ venvs.ensure_venv(str(tool))
31
+ return calls
32
+
33
+
34
+ def _checkout(home: Path, name: str, origin: str | None,
35
+ requirements: str = "somedep==1.0\n") -> Path:
36
+ """A tool directory, optionally a git checkout with an origin remote."""
37
+ tool_dir = home / name
38
+ tool_dir.mkdir(parents=True)
39
+ (tool_dir / "requirements.txt").write_text(requirements)
40
+ if origin is not None:
41
+ subprocess.run(["git", "init", "-q", str(tool_dir)], check=True)
42
+ subprocess.run(["git", "-C", str(tool_dir), "remote", "add",
43
+ "origin", origin], check=True)
44
+ return tool_dir
45
+
46
+
47
+ # --- key derivation ----------------------------------------------------------------
48
+
49
+ @pytest.mark.parametrize("url, expected", [
50
+ ("git@github.com:Org/tool.git", "github.com/org/tool"),
51
+ ("git@github.com:Org/tool", "github.com/org/tool"),
52
+ ("https://github.com/Org/tool.git", "github.com/org/tool"),
53
+ ("https://github.com/Org/tool", "github.com/org/tool"),
54
+ ("https://user@github.com/Org/tool/", "github.com/org/tool"),
55
+ ("ssh://git@github.com/Org/tool.git", "github.com/org/tool"),
56
+ ])
57
+ def test_normalise_remote_agrees_across_spellings(url: str, expected: str) -> None:
58
+ assert venvs.normalise_remote(url) == expected
59
+
60
+
61
+ @pytest.mark.parametrize("url", ["", " ", "not a url"])
62
+ def test_normalise_remote_rejects_non_remotes(url: str) -> None:
63
+ assert venvs.normalise_remote(url) is None
64
+
65
+
66
+ def test_local_path_remotes_agree(tmp_path: Path) -> None:
67
+ """A bare repo on disk is an upstream too — two clones of it should share."""
68
+ bare = tmp_path / "upstream.git"
69
+ bare.mkdir()
70
+ direct = venvs.normalise_remote(str(bare))
71
+ assert direct is not None
72
+ assert venvs.normalise_remote(f"file://{bare}") == direct
73
+ assert venvs.normalise_remote(str(tmp_path / "." / "upstream.git")) == direct
74
+
75
+
76
+ def test_different_repos_get_different_keys(sandbox_home: Path) -> None:
77
+ a = _checkout(sandbox_home, "a", "git@github.com:Org/one.git")
78
+ b = _checkout(sandbox_home, "b", "git@github.com:Org/two.git")
79
+ assert venvs.upstream_key(str(a)) != venvs.upstream_key(str(b))
80
+
81
+
82
+ def test_key_is_none_without_origin(sandbox_home: Path) -> None:
83
+ plain = _checkout(sandbox_home, "plain", None)
84
+ assert venvs.upstream_key(str(plain)) is None
85
+
86
+
87
+ # --- the point of the module -------------------------------------------------------
88
+
89
+ def test_two_checkouts_of_one_upstream_share_a_venv(sandbox_home: Path) -> None:
90
+ """The whole reason this module exists: install once, reuse everywhere."""
91
+ first = _checkout(sandbox_home, "installer-a/manim-kit",
92
+ "git@github.com:Org/manim-kit.git")
93
+ second = _checkout(sandbox_home, "installer-b/manim-kit",
94
+ "https://github.com/Org/manim-kit")
95
+ assert venvs.venv_for(str(first)) == venvs.venv_for(str(second))
96
+
97
+
98
+ def test_checkout_without_origin_keeps_a_private_venv(sandbox_home: Path) -> None:
99
+ plain = _checkout(sandbox_home, "plain", None)
100
+ assert venvs.venv_for(str(plain)) == str(plain / ".venv")
101
+
102
+
103
+ def test_shared_venv_lives_outside_both_checkouts(sandbox_home: Path) -> None:
104
+ tool = _checkout(sandbox_home, "installer-a/tool", "git@github.com:Org/tool.git")
105
+ assert not venvs.venv_for(str(tool)).startswith(str(tool))
106
+
107
+
108
+ # --- requirements --------------------------------------------------------------
109
+
110
+ def test_requirement_lines_skip_comments_and_blanks(tmp_path: Path) -> None:
111
+ req = tmp_path / "requirements.txt"
112
+ req.write_text("# a comment\n\nfoo==1.0\n bar>=2 \n\n# trailing\n")
113
+ assert venvs.requirement_lines(str(req)) == ["foo==1.0", "bar>=2"]
114
+
115
+
116
+ def test_requirement_lines_of_missing_file_is_empty(tmp_path: Path) -> None:
117
+ assert venvs.requirement_lines(str(tmp_path / "nope.txt")) == []
118
+
119
+
120
+ def test_manifest_round_trip(sandbox_home: Path) -> None:
121
+ tool = _checkout(sandbox_home, "tool", "git@github.com:Org/tool.git")
122
+ venv_dir = sandbox_home / "venv"
123
+ venv_dir.mkdir()
124
+ venvs.write_manifest(str(venv_dir), ["foo==1.0"], str(tool))
125
+ stored = venvs.read_manifest(str(venv_dir))
126
+ assert stored["requirements"] == ["foo==1.0"]
127
+ assert stored["written_by"] == str(tool)
128
+
129
+
130
+ def test_read_manifest_of_unwritten_venv_is_empty(tmp_path: Path) -> None:
131
+ assert venvs.read_manifest(str(tmp_path)) == {}
132
+
133
+
134
+ def test_read_manifest_survives_corruption(tmp_path: Path) -> None:
135
+ (tmp_path / venvs.MANIFEST_NAME).write_text("{not json")
136
+ assert venvs.read_manifest(str(tmp_path)) == {}
137
+
138
+
139
+ # --- provisioning ------------------------------------------------------------------
140
+
141
+ def test_no_requirements_means_no_venv_is_built(sandbox_home: Path) -> None:
142
+ """A tool with nothing to install gains nothing from a venv."""
143
+ tool = _checkout(sandbox_home, "tool", "git@github.com:Org/tool.git",
144
+ requirements="# only a comment\n")
145
+ import sys
146
+ assert venvs.ensure_venv(str(tool)) == sys.executable
147
+ assert not Path(venvs.venv_for(str(tool))).exists()
148
+
149
+
150
+ def test_provision_false_never_creates_anything(sandbox_home: Path) -> None:
151
+ tool = _checkout(sandbox_home, "tool", "git@github.com:Org/tool.git")
152
+ venvs.ensure_venv(str(tool), provision=False)
153
+ assert not Path(venvs.venv_for(str(tool))).exists()
154
+
155
+
156
+ def test_matching_requirements_skip_reinstall(sandbox_home: Path,
157
+ monkeypatch: pytest.MonkeyPatch) -> None:
158
+ """The second installer must not re-run pip for an already-built venv."""
159
+ tool = _checkout(sandbox_home, "tool", "git@github.com:Org/tool.git")
160
+ venv_dir = Path(venvs.venv_for(str(tool)))
161
+ (venv_dir / "bin").mkdir(parents=True)
162
+ (venv_dir / "bin" / "python3").write_text("")
163
+ venvs.write_manifest(str(venv_dir), ["somedep==1.0"], str(tool))
164
+
165
+ assert _pip_calls(monkeypatch, tool) == []
166
+
167
+
168
+ def test_drifted_requirements_trigger_reinstall(sandbox_home: Path,
169
+ monkeypatch: pytest.MonkeyPatch) -> None:
170
+ """A checkout at a different commit gets its own dependencies installed."""
171
+ tool = _checkout(sandbox_home, "tool", "git@github.com:Org/tool.git",
172
+ requirements="somedep==2.0\n")
173
+ venv_dir = Path(venvs.venv_for(str(tool)))
174
+ (venv_dir / "bin").mkdir(parents=True)
175
+ (venv_dir / "bin" / "python3").write_text("")
176
+ venvs.write_manifest(str(venv_dir), ["somedep==1.0"], str(tool))
177
+
178
+ assert _pip_calls(monkeypatch, tool) != []
179
+ assert venvs.read_manifest(str(venv_dir))["requirements"] == ["somedep==2.0"]
180
+
181
+
182
+ def test_conflicting_pins_name_the_other_checkout(sandbox_home: Path,
183
+ monkeypatch: pytest.MonkeyPatch) -> None:
184
+ """When pip cannot satisfy both checkouts, say which one holds the venv."""
185
+ other = _checkout(sandbox_home, "installer-a/tool", "git@github.com:Org/tool.git")
186
+ tool = _checkout(sandbox_home, "installer-b/tool", "git@github.com:Org/tool.git",
187
+ requirements="somedep==2.0\n")
188
+ venv_dir = Path(venvs.venv_for(str(tool)))
189
+ (venv_dir / "bin").mkdir(parents=True)
190
+ (venv_dir / "bin" / "python3").write_text("")
191
+ venvs.write_manifest(str(venv_dir), ["somedep==1.0"], str(other))
192
+
193
+ real_run = subprocess.run
194
+
195
+ def fail_pip(cmd, *a, **k):
196
+ if "pip" in cmd:
197
+ raise subprocess.CalledProcessError(1, cmd)
198
+ return real_run(cmd, *a, **k)
199
+
200
+ monkeypatch.setattr(venvs.subprocess, "run", fail_pip)
201
+ with pytest.raises(RuntimeError, match=str(other)):
202
+ venvs.ensure_venv(str(tool))
203
+
204
+
205
+ # --- migration ---------------------------------------------------------------------
206
+
207
+ def test_existing_private_venv_is_adopted(sandbox_home: Path) -> None:
208
+ """The 666M already on disk is reused, not downloaded again."""
209
+ tool = _checkout(sandbox_home, "tool", "git@github.com:Org/tool.git")
210
+ private = tool / ".venv"
211
+ (private / "bin").mkdir(parents=True)
212
+ (private / "bin" / "python3").write_text("")
213
+ (private / "marker").write_text("original")
214
+
215
+ shared = venvs.venv_for(str(tool))
216
+ assert venvs.adopt_existing(str(tool), shared) is True
217
+ assert (Path(shared) / "marker").read_text() == "original"
218
+ assert not private.exists()
219
+
220
+
221
+ def test_adoption_does_not_clobber_an_existing_shared_venv(sandbox_home: Path) -> None:
222
+ tool = _checkout(sandbox_home, "tool", "git@github.com:Org/tool.git")
223
+ private = tool / ".venv"
224
+ (private / "bin").mkdir(parents=True)
225
+ (private / "bin" / "python3").write_text("")
226
+
227
+ shared = Path(venvs.venv_for(str(tool)))
228
+ shared.mkdir(parents=True)
229
+ assert venvs.adopt_existing(str(tool), str(shared)) is False
230
+ assert private.exists()
231
+
232
+
233
+ def test_stale_private_venvs_are_reported_not_removed(sandbox_home: Path) -> None:
234
+ first = _checkout(sandbox_home, "installer-a/tool", "git@github.com:Org/tool.git")
235
+ second = _checkout(sandbox_home, "installer-b/tool", "git@github.com:Org/tool.git")
236
+ leftover = second / ".venv"
237
+ leftover.mkdir()
238
+ Path(venvs.venv_for(str(first))).mkdir(parents=True)
239
+
240
+ stale = venvs.stale_private_venvs([str(first), str(second)])
241
+ assert stale == [str(leftover)]
242
+ assert leftover.exists()
File without changes
File without changes
File without changes