cli-tools-kit 0.8.1__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.1 → cli_tools_kit-0.8.3}/PKG-INFO +1 -1
  2. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/__init__.py +1 -1
  3. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/gui_installer.py +97 -23
  4. {cli_tools_kit-0.8.1 → 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.1 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/PKG-INFO +1 -1
  7. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/SOURCES.txt +3 -1
  8. {cli_tools_kit-0.8.1 → 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.1 → cli_tools_kit-0.8.3}/LICENSE +0 -0
  11. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/README.md +0 -0
  12. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/__main__.py +0 -0
  13. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/advertise.py +0 -0
  14. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/autostart_gate.py +0 -0
  15. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/cron_installer.py +0 -0
  16. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/host.py +0 -0
  17. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/identity.py +0 -0
  18. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/onboarding.py +0 -0
  19. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/skills.py +0 -0
  20. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/sources.py +0 -0
  21. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/__init__.py +0 -0
  22. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/build.py +0 -0
  23. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/capability.py +0 -0
  24. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/cluster.py +0 -0
  25. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/corpus.py +0 -0
  26. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/embedder.py +0 -0
  27. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/groups.py +0 -0
  28. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
  29. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/tool_installer.py +0 -0
  30. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
  31. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/entry_points.txt +0 -0
  32. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/requires.txt +0 -0
  33. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/top_level.txt +0 -0
  34. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/setup.cfg +0 -0
  35. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_autostart_gate.py +0 -0
  36. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_capability_groups.py +0 -0
  37. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_cron_installer.py +0 -0
  38. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_host.py +0 -0
  39. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_identity.py +0 -0
  40. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_llm_groups.py +0 -0
  41. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_skills.py +0 -0
  42. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_sources.py +0 -0
  43. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_taxonomy_cluster.py +0 -0
  44. {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_tool_installer.py +0 -0
  45. {cli_tools_kit-0.8.1 → 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.1
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.1"
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,
@@ -847,15 +848,30 @@ AUTOSTART_DIR = (host.startup_dir() if host.IS_WINDOWS
847
848
  else os.path.join(os.path.expanduser("~"), ".config", "autostart"))
848
849
 
849
850
  # --- Login update-check autostart -----------------------------------------
850
- # A ~/.config/autostart entry that runs `installer.py --check` once per login.
851
+ # A startup entry (~/.config/autostart, or the Start Menu's Startup folder on
852
+ # Windows) that runs `installer.py --check` once per login.
851
853
  # The check APPLIES network-free reconciliations (drifted .desktop Exec paths,
852
- # renamed aliases, stale installed SKILL.md — all rewritten from already-synced
853
- # source via skip_deps, no pip) and only NOTIFIES for updates that would touch
854
+ # renamed aliases, stale installed SKILL.md — all rewritten from the source
855
+ # already on disk via skip_deps, no pip) and only NOTIFIES for updates that would touch
854
856
  # the network (a new, not-yet-installed tool) or that add a new skill. The
855
857
  # pip/network gate stays behind an explicit human action.
856
858
  # Derived from the configurable *_NAME knobs above. run() recomputes these after
857
859
  # a wrapper overrides the names; the defaults keep tools/installer.py unchanged.
858
- AUTOSTART_CHECK_DESKTOP = os.path.join(AUTOSTART_DIR, AUTOSTART_CHECK_DESKTOP_NAME)
860
+
861
+
862
+ def _autostart_check_path() -> str:
863
+ """Where the login-check entry goes, named the way the platform runs it.
864
+
865
+ Windows' Startup folder executes shortcuts, not XDG .desktop files, so the
866
+ name carries .lnk there and the entry is written as one.
867
+ """
868
+ name = AUTOSTART_CHECK_DESKTOP_NAME
869
+ if host.IS_WINDOWS:
870
+ name = os.path.splitext(name)[0] + ".lnk"
871
+ return os.path.join(AUTOSTART_DIR, name)
872
+
873
+
874
+ AUTOSTART_CHECK_DESKTOP = _autostart_check_path()
859
875
  CHECK_LOG = os.path.join(os.path.expanduser("~"), ".local", "log", CHECK_LOG_NAME)
860
876
  # Remembers the last actionable (new tool / new skill / failure) set so the
861
877
  # login check notifies once when it CHANGES instead of nagging every login.
@@ -894,7 +910,7 @@ def _recompute_check_paths() -> None:
894
910
  """Re-derive the login-check artifact paths from the *_NAME knobs (call after
895
911
  a wrapper overrides them, e.g. inside run())."""
896
912
  global AUTOSTART_CHECK_DESKTOP, CHECK_LOG, CHECK_STATE
897
- AUTOSTART_CHECK_DESKTOP = os.path.join(AUTOSTART_DIR, AUTOSTART_CHECK_DESKTOP_NAME)
913
+ AUTOSTART_CHECK_DESKTOP = _autostart_check_path()
898
914
  CHECK_LOG = os.path.join(os.path.expanduser("~"), ".local", "log", CHECK_LOG_NAME)
899
915
  CHECK_STATE = os.path.join(os.path.expanduser("~"), ".local", "state", CHECK_STATE_NAME)
900
916
 
@@ -920,6 +936,23 @@ def autostart_check_enabled() -> bool:
920
936
  def enable_autostart_check() -> str:
921
937
  """Write the login update-check autostart entry. Returns its path."""
922
938
  os.makedirs(AUTOSTART_DIR, exist_ok=True)
939
+
940
+ if host.IS_WINDOWS:
941
+ # The Startup folder runs shortcuts; a .desktop file dropped there is
942
+ # never executed. pythonw keeps the console window from flashing up at
943
+ # every login, since the check reports through a notification anyway.
944
+ runner = sys.executable
945
+ windowless = os.path.join(os.path.dirname(runner), "pythonw.exe")
946
+ if os.path.exists(windowless):
947
+ runner = windowless
948
+ host.write_shortcut(
949
+ AUTOSTART_CHECK_DESKTOP,
950
+ target=runner,
951
+ args=f'"{ENTRY_SCRIPT}" --check',
952
+ workdir=os.path.dirname(ENTRY_SCRIPT),
953
+ )
954
+ return AUTOSTART_CHECK_DESKTOP
955
+
923
956
  exec_line = f"{sys.executable} {ENTRY_SCRIPT} --check"
924
957
  content = (
925
958
  "[Desktop Entry]\n"
@@ -940,10 +973,15 @@ def enable_autostart_check() -> str:
940
973
 
941
974
  def disable_autostart_check() -> bool:
942
975
  """Remove the login update-check autostart entry. True if one was present."""
943
- if os.path.exists(AUTOSTART_CHECK_DESKTOP):
944
- os.remove(AUTOSTART_CHECK_DESKTOP)
945
- return True
946
- return False
976
+ removed = False
977
+ # On Windows, also clear the .desktop an older version wrote into the
978
+ # Startup folder, where it sat inert instead of running the check.
979
+ stale = os.path.join(AUTOSTART_DIR, AUTOSTART_CHECK_DESKTOP_NAME)
980
+ for path in {AUTOSTART_CHECK_DESKTOP, stale}:
981
+ if os.path.exists(path):
982
+ os.remove(path)
983
+ removed = True
984
+ return removed
947
985
 
948
986
 
949
987
  def _notify_send(summary: str, body: str = "") -> None:
@@ -1112,9 +1150,20 @@ def install_tool(tool: ToolEntry, skip_deps: bool = False) -> tuple[bool, str]:
1112
1150
  When skip_deps is True, sets TOOLS_INSTALLER_SKIP_DEPS=1 in the child
1113
1151
  environment so the shared ToolInstaller writes the .desktop/alias without
1114
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.
1115
1159
  """
1116
1160
  try:
1117
- 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
1118
1167
  env = os.environ.copy()
1119
1168
  # The tool writes its own .desktop and alias via ToolInstaller, so it
1120
1169
  # needs to know which installer asked — otherwise a third-party org's
@@ -1134,9 +1183,17 @@ def install_tool(tool: ToolEntry, skip_deps: bool = False) -> tuple[bool, str]:
1134
1183
  return False, str(e)
1135
1184
 
1136
1185
  def remove_tool(tool: ToolEntry) -> tuple[bool, str]:
1137
- """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
+ """
1138
1193
  try:
1139
- 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
1140
1197
  env = os.environ.copy()
1141
1198
  env.update(IDENTITY.env()) # remove the alias from OUR alias file
1142
1199
  result = subprocess.run(cmd, cwd=os.path.dirname(tool.script_path),
@@ -1157,7 +1214,12 @@ def install_skill_for_tool(tool: ToolEntry) -> tuple[bool, str]:
1157
1214
  detect an up-to-date SKILL.md and no-op.
1158
1215
  """
1159
1216
  try:
1160
- 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
1161
1223
  result = subprocess.run(cmd, cwd=os.path.dirname(tool.script_path),
1162
1224
  capture_output=True, text=True)
1163
1225
  output = (result.stdout + result.stderr).strip()
@@ -1171,7 +1233,9 @@ def install_skill_for_tool(tool: ToolEntry) -> tuple[bool, str]:
1171
1233
  def uninstall_skill_for_tool(tool: ToolEntry) -> tuple[bool, str]:
1172
1234
  """Invokes the tool's --uninstall-skill argument. Returns (success, output)."""
1173
1235
  try:
1174
- 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
1175
1239
  result = subprocess.run(cmd, cwd=os.path.dirname(tool.script_path),
1176
1240
  capture_output=True, text=True)
1177
1241
  output = (result.stdout + result.stderr).strip()
@@ -1382,8 +1446,15 @@ def get_autostart_path(tool: ToolEntry) -> str:
1382
1446
 
1383
1447
 
1384
1448
  def _cron_line_for_tool(tool: ToolEntry) -> str:
1385
- """Build the crontab line for a cron-based tool."""
1386
- 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)
1387
1458
  return " ".join(parts)
1388
1459
 
1389
1460
 
@@ -2635,11 +2706,12 @@ class InstallerApp:
2635
2706
  self._attach_tooltip(
2636
2707
  self._autostart_check_cb,
2637
2708
  "Check for updates on login (local, no network)\n\n"
2638
- "Installs a ~/.config/autostart entry that, once per login, applies "
2709
+ "Installs a startup entry that, once per login, applies "
2639
2710
  "network-free reconciliations (drifted shortcuts, renamed aliases, and "
2640
- "stale installed skills — rewritten from your synced source, never pip) "
2641
- "and only notifies for updates that need the network (a new, "
2711
+ "stale installed skills — rewritten from the tool's source on disk, "
2712
+ "never pip) and only notifies for updates that need the network (a new, "
2642
2713
  "not-yet-installed tool).\n\n"
2714
+ f"Entry: {AUTOSTART_CHECK_DESKTOP}\n"
2643
2715
  f"Log: {CHECK_LOG}")
2644
2716
 
2645
2717
  # "Reinstall deps" is the deliberate, network-touching pip action — kept in
@@ -3745,8 +3817,10 @@ class InstallerApp:
3745
3817
  dialog.after(0, mark_failed)
3746
3818
 
3747
3819
  except Exception as e:
3820
+ error_msg = str(e)[:20]
3821
+
3748
3822
  def mark_error():
3749
- update_grid_cell(idx, None, f"Error: {str(e)[:20]}")
3823
+ update_grid_cell(idx, None, f"Error: {error_msg}")
3750
3824
  current_index[0] += 1
3751
3825
  dialog.after(100, generate_next_varied)
3752
3826
  dialog.after(0, mark_error)
@@ -4625,7 +4699,7 @@ class InstallerApp:
4625
4699
  autostart_var.trace_add("write", self._on_checkbox_changed)
4626
4700
  # A tool with conditions gets the checkbox and a ⚙ side by side;
4627
4701
  # without them the checkbox stays centred as before.
4628
- if tool.autostart_conditions:
4702
+ if parent.autostart_conditions:
4629
4703
  holder = ttk.Frame(autostart_frame, style="Card.TFrame")
4630
4704
  holder.pack(expand=True)
4631
4705
  autostart_cb = ttk.Checkbutton(holder, variable=autostart_var, style="Card.TCheckbutton")
@@ -6014,7 +6088,7 @@ def cli_check() -> int:
6014
6088
 
6015
6089
  Auto-applies the network-free reconciliations and notifies for the rest:
6016
6090
 
6017
- APPLY (no pip, no network — rewritten from already-synced source):
6091
+ APPLY (no pip, no network — rewritten from the source already on disk):
6018
6092
  • a drifted shortcut (moved .desktop Exec path / renamed alias) -> install_tool(skip_deps=True)
6019
6093
  • a currently-installed SKILL.md that has drifted -> idempotent --install-skill
6020
6094
  (only touched when the skill is already present, so a skill the
@@ -6138,7 +6212,7 @@ def main():
6138
6212
  parser.add_argument("--check", action="store_true",
6139
6213
  help="Headless login check: auto-apply network-free reconciliations, notify for new tools")
6140
6214
  parser.add_argument("--enable-autostart-check", action="store_true",
6141
- help="Install the login update-check autostart entry (~/.config/autostart)")
6215
+ help="Install the login update-check autostart entry")
6142
6216
  parser.add_argument("--disable-autostart-check", action="store_true",
6143
6217
  help="Remove the login update-check autostart entry")
6144
6218
  parser.add_argument("--refresh", action="store_true",
@@ -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.1
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.1"
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