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.
Files changed (45) hide show
  1. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/PKG-INFO +5 -1
  2. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/__init__.py +1 -1
  3. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/gui_installer.py +7 -42
  4. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/onboarding.py +1 -1
  5. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/tui_installer.py +2 -3
  6. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit.egg-info/PKG-INFO +5 -1
  7. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit.egg-info/SOURCES.txt +1 -3
  8. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit.egg-info/requires.txt +4 -0
  9. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/pyproject.toml +29 -1
  10. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_capability_groups.py +0 -2
  11. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_llm_groups.py +0 -2
  12. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_sources.py +1 -1
  13. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_taxonomy_cluster.py +0 -2
  14. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_tui_installer.py +4 -3
  15. cli_tools_kit-0.8.3/cli_tools_kit/venvs.py +0 -319
  16. cli_tools_kit-0.8.3/tests/test_venvs.py +0 -242
  17. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/LICENSE +0 -0
  18. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/README.md +0 -0
  19. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/__main__.py +0 -0
  20. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/advertise.py +0 -0
  21. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/autostart_gate.py +0 -0
  22. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/cron_installer.py +0 -0
  23. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/host.py +0 -0
  24. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/identity.py +0 -0
  25. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/skills.py +0 -0
  26. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/sources.py +0 -0
  27. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/__init__.py +0 -0
  28. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/build.py +0 -0
  29. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/capability.py +0 -0
  30. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/cluster.py +0 -0
  31. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/corpus.py +0 -0
  32. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/embedder.py +0 -0
  33. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/groups.py +0 -0
  34. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
  35. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit/tool_installer.py +0 -0
  36. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
  37. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit.egg-info/entry_points.txt +0 -0
  38. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/cli_tools_kit.egg-info/top_level.txt +0 -0
  39. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/setup.cfg +0 -0
  40. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_autostart_gate.py +0 -0
  41. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_cron_installer.py +0 -0
  42. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_host.py +0 -0
  43. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_identity.py +0 -0
  44. {cli_tools_kit-0.8.3 → cli_tools_kit-0.8.4}/tests/test_skills.py +0 -0
  45. {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
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
@@ -56,4 +56,4 @@ __all__ = [
56
56
  "read_installed_skill",
57
57
  ]
58
58
 
59
- __version__ = "0.8.3"
59
+ __version__ = "0.8.4"
@@ -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
- 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
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
- python = venvs.ensure_venv(os.path.dirname(tool.script_path),
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
- # 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
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
- python = venvs.ensure_venv(os.path.dirname(tool.script_path),
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
 
@@ -157,7 +157,7 @@ def _ask(prompt: str, default: str = "") -> str:
157
157
  answer = input(f"{prompt}{suffix}: ").strip()
158
158
  except (EOFError, KeyboardInterrupt):
159
159
  print()
160
- raise SystemExit(1)
160
+ raise SystemExit(1) from None
161
161
  return answer or default
162
162
 
163
163
 
@@ -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 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.
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
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
@@ -5,6 +5,10 @@ tomli>=2.0
5
5
 
6
6
  [dev]
7
7
  pytest>=7.0
8
+ pytest-cov>=5.0
9
+ ruff>=0.6
10
+ build>=1.0
11
+ twine>=5.0
8
12
 
9
13
  [gui]
10
14
  Pillow>=9.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cli-tools-kit"
7
- version = "0.8.3"
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
@@ -1,7 +1,5 @@
1
1
  """Tests for the network-free capability grouping tier."""
2
2
 
3
- import os
4
- import sys
5
3
 
6
4
  from cli_tools_kit.taxonomy.capability import DEFAULT_BANDS, capability_groups
7
5
 
@@ -1,8 +1,6 @@
1
1
  """Tests for the Gemini tool grouping: prompts, reply parsing, repair."""
2
2
 
3
3
  import json
4
- import os
5
- import sys
6
4
 
7
5
  import pytest
8
6
 
@@ -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, f"""
350
+ _write(config, """
351
351
  [[source]]
352
352
  name = "org/tools"
353
353
  path = "."
@@ -2,8 +2,6 @@
2
2
 
3
3
  import json
4
4
  import math
5
- import os
6
- import sys
7
5
 
8
6
  import pytest
9
7
 
@@ -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, f"Run: source ~/.bashrc"),
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