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.
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/PKG-INFO +1 -1
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/__init__.py +1 -1
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/gui_installer.py +97 -23
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/tui_installer.py +3 -2
- cli_tools_kit-0.8.3/cli_tools_kit/venvs.py +319 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/PKG-INFO +1 -1
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/SOURCES.txt +3 -1
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/pyproject.toml +1 -1
- cli_tools_kit-0.8.3/tests/test_venvs.py +242 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/LICENSE +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/README.md +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/__main__.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/advertise.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/autostart_gate.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/cron_installer.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/host.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/identity.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/onboarding.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/skills.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/sources.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/__init__.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/build.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/capability.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/cluster.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/corpus.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/embedder.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/groups.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit/tool_installer.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/entry_points.txt +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/requires.txt +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/cli_tools_kit.egg-info/top_level.txt +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/setup.cfg +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_autostart_gate.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_capability_groups.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_cron_installer.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_host.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_identity.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_llm_groups.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_skills.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_sources.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_taxonomy_cluster.py +0 -0
- {cli_tools_kit-0.8.1 → cli_tools_kit-0.8.3}/tests/test_tool_installer.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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
|
|
853
|
-
#
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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: {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
16
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|