cli-tools-kit 0.8.3__tar.gz → 0.8.4__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.3 → cli_tools_kit-0.8.4}/PKG-INFO +5 -1
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/__init__.py +1 -1
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/gui_installer.py +7 -42
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/onboarding.py +1 -1
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/tui_installer.py +2 -3
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit.egg-info/PKG-INFO +5 -1
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit.egg-info/SOURCES.txt +1 -3
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit.egg-info/requires.txt +4 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/pyproject.toml +29 -1
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_capability_groups.py +0 -2
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_llm_groups.py +0 -2
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_sources.py +1 -1
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_taxonomy_cluster.py +0 -2
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_tui_installer.py +4 -3
- cli_tools_kit-0.8.3/cli_tools_kit/venvs.py +0 -319
- cli_tools_kit-0.8.3/tests/test_venvs.py +0 -242
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/LICENSE +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/README.md +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/__main__.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/advertise.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/autostart_gate.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/cron_installer.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/host.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/identity.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/skills.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/sources.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/__init__.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/build.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/capability.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/cluster.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/corpus.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/embedder.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/groups.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/tool_installer.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit.egg-info/entry_points.txt +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit.egg-info/top_level.txt +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/setup.cfg +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_autostart_gate.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_cron_installer.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_host.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_identity.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_skills.py +0 -0
- {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_tool_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.4
|
|
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
|
|
@@ -47,6 +47,10 @@ Requires-Dist: termcolor>=2.0
|
|
|
47
47
|
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
48
48
|
Provides-Extra: dev
|
|
49
49
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
50
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
51
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
52
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
53
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
50
54
|
Provides-Extra: gui
|
|
51
55
|
Requires-Dist: Pillow>=9.0; extra == "gui"
|
|
52
56
|
Dynamic: license-file
|
|
@@ -60,7 +60,6 @@ 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
|
|
64
63
|
from .identity import InstallerIdentity, LEGACY_IDENTITY
|
|
65
64
|
from .autostart_gate import (
|
|
66
65
|
KNOWN_CONDITIONS,
|
|
@@ -1150,20 +1149,9 @@ def install_tool(tool: ToolEntry, skip_deps: bool = False) -> tuple[bool, str]:
|
|
|
1150
1149
|
When skip_deps is True, sets TOOLS_INSTALLER_SKIP_DEPS=1 in the child
|
|
1151
1150
|
environment so the shared ToolInstaller writes the .desktop/alias without
|
|
1152
1151
|
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.
|
|
1159
1152
|
"""
|
|
1160
1153
|
try:
|
|
1161
|
-
|
|
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
|
|
1154
|
+
cmd = [sys.executable, tool.script_path, "--install"] + tool.args
|
|
1167
1155
|
env = os.environ.copy()
|
|
1168
1156
|
# The tool writes its own .desktop and alias via ToolInstaller, so it
|
|
1169
1157
|
# needs to know which installer asked — otherwise a third-party org's
|
|
@@ -1183,17 +1171,9 @@ def install_tool(tool: ToolEntry, skip_deps: bool = False) -> tuple[bool, str]:
|
|
|
1183
1171
|
return False, str(e)
|
|
1184
1172
|
|
|
1185
1173
|
def remove_tool(tool: ToolEntry) -> tuple[bool, str]:
|
|
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
|
-
"""
|
|
1174
|
+
"""Invokes the tool's own --remove argument. Returns (success, output)."""
|
|
1193
1175
|
try:
|
|
1194
|
-
|
|
1195
|
-
provision=False)
|
|
1196
|
-
cmd = [python, tool.script_path, "--remove"] + tool.args
|
|
1176
|
+
cmd = [sys.executable, tool.script_path, "--remove"] + tool.args
|
|
1197
1177
|
env = os.environ.copy()
|
|
1198
1178
|
env.update(IDENTITY.env()) # remove the alias from OUR alias file
|
|
1199
1179
|
result = subprocess.run(cmd, cwd=os.path.dirname(tool.script_path),
|
|
@@ -1214,12 +1194,7 @@ def install_skill_for_tool(tool: ToolEntry) -> tuple[bool, str]:
|
|
|
1214
1194
|
detect an up-to-date SKILL.md and no-op.
|
|
1215
1195
|
"""
|
|
1216
1196
|
try:
|
|
1217
|
-
|
|
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
|
|
1197
|
+
cmd = [sys.executable, tool.script_path, "--install-skill"] + tool.args
|
|
1223
1198
|
result = subprocess.run(cmd, cwd=os.path.dirname(tool.script_path),
|
|
1224
1199
|
capture_output=True, text=True)
|
|
1225
1200
|
output = (result.stdout + result.stderr).strip()
|
|
@@ -1233,9 +1208,7 @@ def install_skill_for_tool(tool: ToolEntry) -> tuple[bool, str]:
|
|
|
1233
1208
|
def uninstall_skill_for_tool(tool: ToolEntry) -> tuple[bool, str]:
|
|
1234
1209
|
"""Invokes the tool's --uninstall-skill argument. Returns (success, output)."""
|
|
1235
1210
|
try:
|
|
1236
|
-
|
|
1237
|
-
provision=False)
|
|
1238
|
-
cmd = [python, tool.script_path, "--uninstall-skill"] + tool.args
|
|
1211
|
+
cmd = [sys.executable, tool.script_path, "--uninstall-skill"] + tool.args
|
|
1239
1212
|
result = subprocess.run(cmd, cwd=os.path.dirname(tool.script_path),
|
|
1240
1213
|
capture_output=True, text=True)
|
|
1241
1214
|
output = (result.stdout + result.stderr).strip()
|
|
@@ -1446,15 +1419,8 @@ def get_autostart_path(tool: ToolEntry) -> str:
|
|
|
1446
1419
|
|
|
1447
1420
|
|
|
1448
1421
|
def _cron_line_for_tool(tool: ToolEntry) -> str:
|
|
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)
|
|
1422
|
+
"""Build the crontab line for a cron-based tool."""
|
|
1423
|
+
parts = [tool.cron_schedule, sys.executable, tool.script_path] + list(tool.cron_args)
|
|
1458
1424
|
return " ".join(parts)
|
|
1459
1425
|
|
|
1460
1426
|
|
|
@@ -3416,7 +3382,6 @@ class InstallerApp:
|
|
|
3416
3382
|
return
|
|
3417
3383
|
|
|
3418
3384
|
model_idx = idx // samples_per_model
|
|
3419
|
-
sample_num = idx % samples_per_model
|
|
3420
3385
|
model_name = selected_models[model_idx]
|
|
3421
3386
|
model_path = os.path.join(models_dir, model_name)
|
|
3422
3387
|
|
|
@@ -12,9 +12,8 @@ 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
|
-
|
|
17
|
-
for both screens.
|
|
15
|
+
them (FAU-WW3-Tools routes each tool through its own venv) is honoured here
|
|
16
|
+
as well.
|
|
18
17
|
|
|
19
18
|
Skill targets
|
|
20
19
|
-------------
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cli-tools-kit
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.4
|
|
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
|
|
@@ -47,6 +47,10 @@ Requires-Dist: termcolor>=2.0
|
|
|
47
47
|
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
48
48
|
Provides-Extra: dev
|
|
49
49
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
50
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
51
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
52
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
53
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
50
54
|
Provides-Extra: gui
|
|
51
55
|
Requires-Dist: Pillow>=9.0; extra == "gui"
|
|
52
56
|
Dynamic: license-file
|
|
@@ -14,7 +14,6 @@ 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
|
|
18
17
|
cli_tools_kit.egg-info/PKG-INFO
|
|
19
18
|
cli_tools_kit.egg-info/SOURCES.txt
|
|
20
19
|
cli_tools_kit.egg-info/dependency_links.txt
|
|
@@ -39,5 +38,4 @@ tests/test_skills.py
|
|
|
39
38
|
tests/test_sources.py
|
|
40
39
|
tests/test_taxonomy_cluster.py
|
|
41
40
|
tests/test_tool_installer.py
|
|
42
|
-
tests/test_tui_installer.py
|
|
43
|
-
tests/test_venvs.py
|
|
41
|
+
tests/test_tui_installer.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.4"
|
|
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"
|
|
@@ -36,6 +36,10 @@ dependencies = [
|
|
|
36
36
|
[project.optional-dependencies]
|
|
37
37
|
dev = [
|
|
38
38
|
"pytest>=7.0",
|
|
39
|
+
"pytest-cov>=5.0",
|
|
40
|
+
"ruff>=0.6",
|
|
41
|
+
"build>=1.0",
|
|
42
|
+
"twine>=5.0",
|
|
39
43
|
]
|
|
40
44
|
# The reusable tkinter GUI installer engine (cli_tools_kit.gui_installer) needs
|
|
41
45
|
# Pillow for tool-icon rendering. tkinter itself ships with CPython. The engine
|
|
@@ -60,3 +64,27 @@ Issues = "https://github.com/Probst1nator/cli-tools-kit/issues"
|
|
|
60
64
|
[tool.setuptools.packages.find]
|
|
61
65
|
include = ["cli_tools_kit*"]
|
|
62
66
|
exclude = ["tests*", "examples*"]
|
|
67
|
+
|
|
68
|
+
[tool.ruff]
|
|
69
|
+
target-version = "py310"
|
|
70
|
+
extend-exclude = ["build", "dist"]
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint]
|
|
73
|
+
# F catches undefined names (the 0.8.0-0.8.2 startup crash was one), E9
|
|
74
|
+
# syntax errors, B likely bugs, PLE pylint errors. B905 (zip strict) and
|
|
75
|
+
# B007 (unused loop variable) are style, not bugs, and strict= would change
|
|
76
|
+
# behaviour.
|
|
77
|
+
select = ["F", "E9", "B", "PLE"]
|
|
78
|
+
ignore = ["B905", "B007"]
|
|
79
|
+
|
|
80
|
+
[tool.pytest.ini_options]
|
|
81
|
+
testpaths = ["tests"]
|
|
82
|
+
|
|
83
|
+
[tool.coverage.run]
|
|
84
|
+
source = ["cli_tools_kit"]
|
|
85
|
+
branch = true
|
|
86
|
+
|
|
87
|
+
[tool.coverage.report]
|
|
88
|
+
fail_under = 34
|
|
89
|
+
show_missing = false
|
|
90
|
+
skip_covered = true
|
|
@@ -347,7 +347,7 @@ def test_local_root_is_the_default_when_no_flag(tmp_path: Path, engine,
|
|
|
347
347
|
def test_the_hook_resolves_and_refills_the_roots(tmp_path: Path, engine,
|
|
348
348
|
monkeypatch) -> None:
|
|
349
349
|
config = _tree(tmp_path)
|
|
350
|
-
_write(config,
|
|
350
|
+
_write(config, """
|
|
351
351
|
[[source]]
|
|
352
352
|
name = "org/tools"
|
|
353
353
|
path = "."
|
|
@@ -5,7 +5,7 @@ The curses drawing itself is not tested; everything below it is plain data.
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
from typing import List
|
|
8
|
+
from typing import List, Optional
|
|
9
9
|
|
|
10
10
|
import pytest
|
|
11
11
|
|
|
@@ -22,7 +22,8 @@ def _tool(name: str, skill: str = "", script: str = "") -> ToolEntry:
|
|
|
22
22
|
)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def _fake_host(monkeypatch: pytest.MonkeyPatch, installed: List[str], stale: List[str] =
|
|
25
|
+
def _fake_host(monkeypatch: pytest.MonkeyPatch, installed: List[str], stale: Optional[List[str]] = None):
|
|
26
|
+
stale = stale or []
|
|
26
27
|
monkeypatch.setattr(gi, "is_installed", lambda t: t.name in installed)
|
|
27
28
|
monkeypatch.setattr(gi, "needs_update", lambda t: t.name in stale)
|
|
28
29
|
|
|
@@ -31,7 +32,7 @@ def _target(key: str, have: List[str]) -> tui.SkillTarget:
|
|
|
31
32
|
return tui.SkillTarget(
|
|
32
33
|
key=key, label=key,
|
|
33
34
|
installed=lambda t: t.skill_name in have,
|
|
34
|
-
install=lambda t: (True,
|
|
35
|
+
install=lambda t: (True, "Run: source ~/.bashrc"),
|
|
35
36
|
uninstall=lambda t: (True, ""),
|
|
36
37
|
)
|
|
37
38
|
|
|
@@ -1,319 +0,0 @@
|
|
|
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,242 +0,0 @@
|
|
|
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
|