Coverage for little_loops / init / install_check.py: 24%
80 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:55 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:55 -0500
1"""Installation detection and version comparison for ll-init."""
3from __future__ import annotations
5import importlib.metadata
6import json
7import shutil
8import subprocess
9import sys
10from enum import Enum
11from pathlib import Path
13from little_loops.host_runner import HostNotConfigured, resolve_host
16class InstallStatus(Enum):
17 UpToDate = "up_to_date"
18 OutOfDate = "out_of_date"
19 NotInstalled = "not_installed"
20 Unknown = "unknown"
23def _is_editable_install() -> bool:
24 """Return True if little-loops is installed as an editable (dev) install."""
25 try:
26 result = subprocess.run(
27 [sys.executable, "-m", "pip", "show", "little-loops"],
28 capture_output=True,
29 text=True,
30 timeout=10,
31 )
32 return any(
33 line.startswith("Editable project location:") for line in result.stdout.splitlines()
34 )
35 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
36 return False
39def detect_installation(
40 project_root: Path,
41) -> tuple[str | None, str | None, str | None]:
42 """Detect local or global little-loops installation.
44 Returns:
45 (install_source, installed_version, install_path) where install_source is one of
46 "local-editable", "pypi", "global-claude-code", "project-claude-code", or None
47 (not found). installed_version is the pip version string for pip-based installs,
48 or the plugin version string for claude-code plugin installs. install_path is the
49 installPath from the plugin JSON (claude-code installs only), or None otherwise.
50 """
51 # Check pip metadata first.
52 try:
53 installed = importlib.metadata.version("little-loops")
54 source = "local-editable" if _is_editable_install() else "pypi"
55 return source, installed, None
56 except importlib.metadata.PackageNotFoundError:
57 pass
59 # Global claude plugin check — use --json to retrieve version and scope.
60 if shutil.which("claude"):
61 try:
62 result = subprocess.run(
63 ["claude", "plugin", "list", "--json"],
64 capture_output=True,
65 text=True,
66 timeout=10,
67 )
68 if result.returncode == 0:
69 try:
70 plugins = json.loads(result.stdout)
71 for plugin in plugins:
72 if isinstance(plugin, dict) and plugin.get("name") == "ll@little-loops":
73 scope = plugin.get("scope", "user")
74 source = (
75 "project-claude-code"
76 if scope == "project"
77 else "global-claude-code"
78 )
79 return source, plugin.get("version"), plugin.get("installPath")
80 except (json.JSONDecodeError, TypeError, AttributeError):
81 # Older CLI without --json: fall back to plain-text presence check.
82 if "ll@little-loops" in result.stdout:
83 return "global-claude-code", None, None
84 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
85 pass
87 return None, None, None
90def fetch_latest_pypi(timeout: float = 10.0) -> str | None:
91 """Fetch the latest little-loops version from PyPI.
93 Uses ``pip index versions`` and parses the ``LATEST:`` line.
95 Returns:
96 Latest version string, or None on any failure (offline, timeout, etc.).
97 """
98 try:
99 result = subprocess.run(
100 [sys.executable, "-m", "pip", "index", "versions", "little-loops"],
101 capture_output=True,
102 text=True,
103 timeout=timeout,
104 )
105 for line in result.stdout.splitlines():
106 if line.startswith("LATEST:"):
107 return line.split(":", 1)[1].strip()
108 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
109 pass
110 return None
113def fetch_latest_plugin(timeout: float = 10.0) -> str | None:
114 """Fetch the latest ll@little-loops plugin version from the marketplace.
116 Uses ``resolve_host()`` so the binary name is never hardcoded.
118 Returns:
119 Latest version string, or None on any failure (offline, no host, etc.).
120 Only meaningful when the claude-code host is active.
121 """
122 try:
123 runner = resolve_host()
124 invocation = runner.build_version_check()
125 binary = invocation.binary
126 except HostNotConfigured:
127 return None
129 try:
130 # Update marketplace index (best-effort — ignore failure).
131 subprocess.run(
132 [binary, "plugin", "marketplace", "update", "little-loops"],
133 capture_output=True,
134 text=True,
135 timeout=timeout,
136 )
137 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
138 pass
140 try:
141 result = subprocess.run(
142 [binary, "plugin", "list", "--available", "--json"],
143 capture_output=True,
144 text=True,
145 timeout=timeout,
146 )
147 if result.returncode == 0:
148 plugins = json.loads(result.stdout)
149 for plugin in plugins:
150 if isinstance(plugin, dict) and plugin.get("name") == "ll@little-loops":
151 return plugin.get("version")
152 except (subprocess.TimeoutExpired, FileNotFoundError, OSError, json.JSONDecodeError):
153 pass
154 return None
157def check_version(installed: str, latest: str) -> InstallStatus:
158 """Compare installed version against the latest available version.
160 Args:
161 installed: Version string from the installed pip package or plugin.
162 latest: Version string from the latest available release (PyPI or marketplace).
164 Returns:
165 UpToDate if installed >= latest (semver), OutOfDate if installed < latest.
166 """
168 def _parse(v: str) -> tuple[int, ...]:
169 return tuple(int(x) for x in v.split("."))
171 if _parse(installed) >= _parse(latest):
172 return InstallStatus.UpToDate
173 return InstallStatus.OutOfDate