ixt-cli 0.8.0__py3-none-any.whl
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.
- ixt/__init__.py +8 -0
- ixt/__main__.py +8 -0
- ixt/backends/__init__.py +1 -0
- ixt/backends/binary.py +935 -0
- ixt/backends/binary_resolver.py +307 -0
- ixt/backends/node.py +490 -0
- ixt/backends/python.py +234 -0
- ixt/cli/__init__.py +31 -0
- ixt/cli/argparse_completion.py +557 -0
- ixt/cli/cmd_apply.py +404 -0
- ixt/cli/cmd_cache.py +86 -0
- ixt/cli/cmd_config.py +295 -0
- ixt/cli/cmd_info.py +116 -0
- ixt/cli/cmd_install.py +508 -0
- ixt/cli/cmd_misc.py +261 -0
- ixt/cli/cmd_registry.py +35 -0
- ixt/cli/cmd_upgrade.py +336 -0
- ixt/cli/commands.py +70 -0
- ixt/cli/parser.py +555 -0
- ixt/cli/render.py +85 -0
- ixt/config/__init__.py +5 -0
- ixt/config/asset_index.py +305 -0
- ixt/config/asset_pattern_cache.py +87 -0
- ixt/config/env_policy.py +340 -0
- ixt/config/flags.py +29 -0
- ixt/config/fs_policy.py +17 -0
- ixt/config/heuristics.py +465 -0
- ixt/config/models.py +176 -0
- ixt/config/registry.py +145 -0
- ixt/config/settings.py +173 -0
- ixt/config/setup_toml.py +179 -0
- ixt/config/toml.py +416 -0
- ixt/core/__init__.py +16 -0
- ixt/core/apply.py +564 -0
- ixt/core/apply_actions.py +106 -0
- ixt/core/backend.py +187 -0
- ixt/core/bootstrap.py +410 -0
- ixt/core/cache.py +332 -0
- ixt/core/discover.py +150 -0
- ixt/core/doctor.py +591 -0
- ixt/core/export.py +419 -0
- ixt/core/expose.py +350 -0
- ixt/core/extract.py +261 -0
- ixt/core/hooks.py +182 -0
- ixt/core/identity.py +148 -0
- ixt/core/inject.py +143 -0
- ixt/core/install.py +509 -0
- ixt/core/install_local.py +229 -0
- ixt/core/locks.py +54 -0
- ixt/core/pathlink.py +86 -0
- ixt/core/resolution_stats.py +191 -0
- ixt/core/resolve.py +150 -0
- ixt/core/resolve_cache.py +185 -0
- ixt/core/runtimes.py +192 -0
- ixt/core/save.py +237 -0
- ixt/core/setup_completions.py +11 -0
- ixt/core/setup_path.py +368 -0
- ixt/core/upgrade.py +596 -0
- ixt/data/__init__.py +10 -0
- ixt/data/asset_index.json +574 -0
- ixt/data/heuristics.toml +98 -0
- ixt/data/registry.toml +71 -0
- ixt/libs/__init__.py +3 -0
- ixt/libs/constants.py +4 -0
- ixt/libs/fmt.py +108 -0
- ixt/libs/logger.py +109 -0
- ixt/libs/output.py +25 -0
- ixt/libs/req_spec.py +115 -0
- ixt/libs/semver.py +149 -0
- ixt/libs/shell.py +126 -0
- ixt/libs/style.py +238 -0
- ixt/net/__init__.py +1 -0
- ixt/net/github_api.py +158 -0
- ixt/net/gitlab_api.py +149 -0
- ixt/net/http.py +194 -0
- ixt/net/npm.py +24 -0
- ixt/net/pypi.py +26 -0
- ixt/net/source.py +163 -0
- ixt/platform/__init__.py +131 -0
- ixt/platform/win.py +68 -0
- ixt_cli-0.8.0.dist-info/METADATA +294 -0
- ixt_cli-0.8.0.dist-info/RECORD +84 -0
- ixt_cli-0.8.0.dist-info/WHEEL +4 -0
- ixt_cli-0.8.0.dist-info/entry_points.txt +2 -0
ixt/libs/shell.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Safe shell execution utilities.
|
|
2
|
+
|
|
3
|
+
Ported from uvpipx's misc.py — only the secure (shell=False) path is kept.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess # nosemgrep: gitlab.bandit.B404
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ShellError(Exception):
|
|
17
|
+
"""Raised when a shell command fails."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, cmd: list[str], returncode: int, stderr: str):
|
|
20
|
+
self.cmd = cmd
|
|
21
|
+
self.returncode = returncode
|
|
22
|
+
self.stderr = stderr
|
|
23
|
+
super().__init__(f"Command failed with code {returncode}: {' '.join(cmd)}\n{stderr}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def shell_run(
|
|
27
|
+
cmd: list[str],
|
|
28
|
+
cwd: Path | None = None,
|
|
29
|
+
capture_output: bool = False,
|
|
30
|
+
check: bool = True,
|
|
31
|
+
env: dict[str, str] | None = None,
|
|
32
|
+
timeout: float | None = None,
|
|
33
|
+
) -> subprocess.CompletedProcess:
|
|
34
|
+
"""Run a command safely (shell=False to prevent injection).
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
cmd: Command as a list of strings.
|
|
38
|
+
cwd: Working directory.
|
|
39
|
+
capture_output: Capture stdout/stderr.
|
|
40
|
+
check: Raise ShellError on non-zero exit.
|
|
41
|
+
env: Environment variables (defaults to clean env without VIRTUAL_ENV).
|
|
42
|
+
timeout: Wall-clock timeout in seconds; raises ``subprocess.TimeoutExpired``.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
subprocess.CompletedProcess
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ShellError: If check=True and command fails.
|
|
49
|
+
subprocess.TimeoutExpired: If timeout elapses before completion.
|
|
50
|
+
"""
|
|
51
|
+
resolved_env = env if env is not None else _clean_env()
|
|
52
|
+
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
cmd,
|
|
55
|
+
cwd=cwd,
|
|
56
|
+
capture_output=capture_output,
|
|
57
|
+
text=capture_output,
|
|
58
|
+
shell=False,
|
|
59
|
+
env=resolved_env,
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if check and result.returncode != 0:
|
|
64
|
+
stderr = result.stderr or ""
|
|
65
|
+
raise ShellError(cmd, result.returncode, stderr)
|
|
66
|
+
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def shell_run_output(
|
|
71
|
+
cmd: list[str],
|
|
72
|
+
cwd: Path | None = None,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Run a command and return stdout (stripped).
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ShellError: If command fails.
|
|
78
|
+
"""
|
|
79
|
+
result = shell_run(cmd, cwd=cwd, capture_output=True, check=True)
|
|
80
|
+
return result.stdout.strip() if result.stdout else ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def command_exists(cmd: str) -> bool:
|
|
84
|
+
"""Check if a command exists in PATH using shutil.which."""
|
|
85
|
+
return shutil.which(cmd) is not None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _clean_env() -> dict[str, str]:
|
|
89
|
+
"""Return a copy of os.environ without VIRTUAL_ENV variables."""
|
|
90
|
+
env = os.environ.copy()
|
|
91
|
+
for key in ("VIRTUAL_ENV", "VIRTUAL_ENV_PROMPT"):
|
|
92
|
+
env.pop(key, None)
|
|
93
|
+
return env
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# -- Elapser -----------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Elapser:
|
|
101
|
+
"""Context manager for measuring elapsed time.
|
|
102
|
+
|
|
103
|
+
Usage::
|
|
104
|
+
|
|
105
|
+
with Elapser() as ela:
|
|
106
|
+
do_work()
|
|
107
|
+
print(ela.elapsed) # "1.234 seconds"
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
start: float = 0.0
|
|
111
|
+
end: float = 0.0
|
|
112
|
+
seconds: float = 0.0
|
|
113
|
+
elapsed: str = ""
|
|
114
|
+
|
|
115
|
+
def __enter__(self) -> Elapser:
|
|
116
|
+
self.start = time.perf_counter()
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def __exit__(self, *args: object) -> None:
|
|
120
|
+
self.end = time.perf_counter()
|
|
121
|
+
self.seconds = self.end - self.start
|
|
122
|
+
self.elapsed = f"{self.seconds:.3f} seconds"
|
|
123
|
+
|
|
124
|
+
def format(self, message: str) -> str:
|
|
125
|
+
"""Return ``message`` followed by the elapsed time."""
|
|
126
|
+
return f"{message} ({self.elapsed})"
|
ixt/libs/style.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""ANSI color and style utilities.
|
|
2
|
+
|
|
3
|
+
Ported and cleaned up from uvpipx's stylist.py.
|
|
4
|
+
Supports NO_COLOR (https://no-color.org/) and NO_EMOJI environment variables.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
import unicodedata
|
|
14
|
+
from enum import Enum
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Color(Enum):
|
|
18
|
+
"""ANSI color and style codes."""
|
|
19
|
+
|
|
20
|
+
# Foreground
|
|
21
|
+
RED = "\033[31m"
|
|
22
|
+
GREEN = "\033[32m"
|
|
23
|
+
YELLOW = "\033[33m"
|
|
24
|
+
BLUE = "\033[34m"
|
|
25
|
+
MAGENTA = "\033[35m"
|
|
26
|
+
CYAN = "\033[36m"
|
|
27
|
+
WHITE = "\033[37m"
|
|
28
|
+
|
|
29
|
+
BRIGHT_RED = "\033[91m"
|
|
30
|
+
BRIGHT_GREEN = "\033[92m"
|
|
31
|
+
BRIGHT_YELLOW = "\033[93m"
|
|
32
|
+
BRIGHT_BLUE = "\033[94m"
|
|
33
|
+
BRIGHT_MAGENTA = "\033[95m"
|
|
34
|
+
BRIGHT_CYAN = "\033[96m"
|
|
35
|
+
BRIGHT_WHITE = "\033[97m"
|
|
36
|
+
|
|
37
|
+
# Background
|
|
38
|
+
BG_RED = "\033[41m"
|
|
39
|
+
BG_GREEN = "\033[42m"
|
|
40
|
+
BG_YELLOW = "\033[43m"
|
|
41
|
+
BG_BLUE = "\033[44m"
|
|
42
|
+
BG_MAGENTA = "\033[45m"
|
|
43
|
+
BG_CYAN = "\033[46m"
|
|
44
|
+
BG_WHITE = "\033[47m"
|
|
45
|
+
|
|
46
|
+
BG_BRIGHT_RED = "\033[101m"
|
|
47
|
+
BG_BRIGHT_GREEN = "\033[102m"
|
|
48
|
+
BG_BRIGHT_YELLOW = "\033[103m"
|
|
49
|
+
BG_BRIGHT_BLUE = "\033[104m"
|
|
50
|
+
BG_BRIGHT_MAGENTA = "\033[105m"
|
|
51
|
+
BG_BRIGHT_CYAN = "\033[106m"
|
|
52
|
+
BG_BRIGHT_WHITE = "\033[107m"
|
|
53
|
+
|
|
54
|
+
# Styles
|
|
55
|
+
ST_RESET = "\033[0m"
|
|
56
|
+
ST_BOLD = "\033[1m"
|
|
57
|
+
ST_DIM = "\033[2m"
|
|
58
|
+
ST_ITALIC = "\033[3m"
|
|
59
|
+
ST_UNDERLINE = "\033[4m"
|
|
60
|
+
ST_REVERSE = "\033[7m"
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def rgb(r: int, g: int, b: int, *, background: bool = False) -> str:
|
|
64
|
+
"""Generate an ANSI escape sequence for a 24-bit RGB color."""
|
|
65
|
+
layer = 48 if background else 38
|
|
66
|
+
return f"\033[{layer};2;{r};{g};{b}m"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# -- ANSI escape stripping ---------------------------------------------------
|
|
70
|
+
|
|
71
|
+
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def strip_ansi(text: str) -> str:
|
|
75
|
+
"""Remove all ANSI escape sequences from *text*."""
|
|
76
|
+
return _ANSI_RE.sub("", text)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def visible_len(text: str) -> int:
|
|
80
|
+
"""Return the visible (printable) length of *text*, ignoring ANSI codes."""
|
|
81
|
+
return len(strip_ansi(text))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# -- NO_COLOR / NO_EMOJI detection -------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _no_color_env() -> bool:
|
|
88
|
+
"""Return True when the ``NO_COLOR`` env var opts out globally."""
|
|
89
|
+
return os.getenv("NO_COLOR", "") != ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _no_color_for(stream) -> bool:
|
|
93
|
+
return _no_color_env() or not stream.isatty()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@functools.lru_cache(maxsize=1)
|
|
97
|
+
def no_color_stderr() -> bool:
|
|
98
|
+
"""Return True when color output should be suppressed on stderr.
|
|
99
|
+
|
|
100
|
+
Consulted by the logger before writing. Cached for the process lifetime.
|
|
101
|
+
"""
|
|
102
|
+
return _no_color_for(sys.stderr)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@functools.lru_cache(maxsize=1)
|
|
106
|
+
def no_color_stdout() -> bool:
|
|
107
|
+
"""Return True when color output should be suppressed on stdout.
|
|
108
|
+
|
|
109
|
+
Consulted by ``libs/output.data()`` before writing. Cached for the process lifetime.
|
|
110
|
+
"""
|
|
111
|
+
return _no_color_for(sys.stdout)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@functools.lru_cache(maxsize=1)
|
|
115
|
+
def _use_emoji() -> bool:
|
|
116
|
+
"""Return True when the terminal is likely to render emoji."""
|
|
117
|
+
if os.getenv("NO_EMOJI", "") != "":
|
|
118
|
+
return False
|
|
119
|
+
encoding = getattr(sys.stdout, "encoding", "") or ""
|
|
120
|
+
return encoding.lower() == "utf-8"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# -- Painter -----------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Painter:
|
|
127
|
+
"""Static helpers for applying ANSI styles to text.
|
|
128
|
+
|
|
129
|
+
The helpers are flux-neutral: they always emit ANSI unless ``NO_COLOR`` is
|
|
130
|
+
set. Writers (logger → stderr, ``data()`` → stdout) are responsible for
|
|
131
|
+
stripping the codes when their target stream is not a TTY.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def color_str(message: str, *styles: Color) -> str:
|
|
136
|
+
"""Apply one or more Color styles to *message*."""
|
|
137
|
+
if _no_color_env():
|
|
138
|
+
return message
|
|
139
|
+
codes = "".join(s.value for s in styles)
|
|
140
|
+
return f"{codes}{message}{Color.ST_RESET.value}"
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def hex_color(hexcode: str, *, background: bool = False) -> str:
|
|
144
|
+
"""Return an ANSI escape from a hex color code like ``#ff8800``."""
|
|
145
|
+
if _no_color_env():
|
|
146
|
+
return ""
|
|
147
|
+
hexcode = hexcode.lstrip("#")
|
|
148
|
+
r, g, b = int(hexcode[:2], 16), int(hexcode[2:4], 16), int(hexcode[4:6], 16)
|
|
149
|
+
return Color.rgb(r, g, b, background=background)
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def reset() -> str:
|
|
153
|
+
if _no_color_env():
|
|
154
|
+
return ""
|
|
155
|
+
return Color.ST_RESET.value
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def parse_color_tags(message: str) -> str:
|
|
159
|
+
"""Process ``<COLOR>text</COLOR>`` markup into ANSI escapes.
|
|
160
|
+
|
|
161
|
+
Supports nesting: ``<RED><ST_BOLD>text</ST_BOLD></RED>``.
|
|
162
|
+
"""
|
|
163
|
+
if _no_color_env():
|
|
164
|
+
return re.sub(r"</?[A-Z_]+>", "", message)
|
|
165
|
+
|
|
166
|
+
tag_re = re.compile(r"</?([A-Z_]+)>")
|
|
167
|
+
active: list[str] = []
|
|
168
|
+
result = message
|
|
169
|
+
|
|
170
|
+
match = tag_re.search(result)
|
|
171
|
+
while match:
|
|
172
|
+
tag = match.group(0)
|
|
173
|
+
inner = tag.strip("<>/")
|
|
174
|
+
if not tag.startswith("</"):
|
|
175
|
+
active.append(inner)
|
|
176
|
+
try:
|
|
177
|
+
replacement = Color[inner].value
|
|
178
|
+
except KeyError:
|
|
179
|
+
replacement = tag
|
|
180
|
+
else:
|
|
181
|
+
if inner in active:
|
|
182
|
+
active.remove(inner)
|
|
183
|
+
replacement = Color.ST_RESET.value
|
|
184
|
+
replacement += "".join(Color[s].value for s in active if s in Color.__members__)
|
|
185
|
+
result = result.replace(tag, replacement, 1)
|
|
186
|
+
match = tag_re.search(result)
|
|
187
|
+
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# -- Emoji helpers -----------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def strip_emoji(text: str) -> str:
|
|
195
|
+
"""Remove all emoji characters from *text*."""
|
|
196
|
+
return "".join(c for c in text if unicodedata.category(c) not in ("So", "Sk", "Sm"))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def emoji(char: str) -> str:
|
|
200
|
+
"""Return *char* if emoji rendering is available, else empty string."""
|
|
201
|
+
return char if _use_emoji() else ""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# -- Convenience shortcuts ---------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _colored(text: str, color: Color, bold: bool = False) -> str:
|
|
208
|
+
"""Apply *color* (and optionally bold) to *text*."""
|
|
209
|
+
styles: tuple[Color, ...] = (Color.ST_BOLD, color) if bold else (color,)
|
|
210
|
+
return Painter.color_str(text, *styles)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def red(text: str, bold: bool = False) -> str:
|
|
214
|
+
return _colored(text, Color.RED, bold)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def green(text: str, bold: bool = False) -> str:
|
|
218
|
+
return _colored(text, Color.GREEN, bold)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def yellow(text: str, bold: bool = False) -> str:
|
|
222
|
+
return _colored(text, Color.YELLOW, bold)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def blue(text: str, bold: bool = False) -> str:
|
|
226
|
+
return _colored(text, Color.BLUE, bold)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def cyan(text: str, bold: bool = False) -> str:
|
|
230
|
+
return _colored(text, Color.CYAN, bold)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def bold(text: str) -> str:
|
|
234
|
+
return Painter.color_str(text, Color.ST_BOLD)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def dim(text: str) -> str:
|
|
238
|
+
return Painter.color_str(text, Color.ST_DIM)
|
ixt/net/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Networking layer for ixt — HTTP client and release source APIs."""
|
ixt/net/github_api.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""GitHub Releases API client — implements ReleaseSource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ixt.net.http import HttpError, NotFoundError, download_file, get_final_url, get_json, get_text
|
|
9
|
+
from ixt.net.source import Asset, Release
|
|
10
|
+
|
|
11
|
+
_API_BASE = "https://api.github.com"
|
|
12
|
+
_RAW_BASE = "https://raw.githubusercontent.com"
|
|
13
|
+
_WEB_BASE = "https://github.com"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GitHubSource:
|
|
17
|
+
"""Fetch releases and files from the GitHub API.
|
|
18
|
+
|
|
19
|
+
Token resolution order:
|
|
20
|
+
1. Explicit ``token`` argument
|
|
21
|
+
2. ``GITHUB_TOKEN`` environment variable
|
|
22
|
+
3. ``GH_TOKEN`` environment variable
|
|
23
|
+
4. No token (unauthenticated, 60 req/h)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, token: str | None = None):
|
|
27
|
+
self._token = token or os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
28
|
+
|
|
29
|
+
def _headers(self) -> dict[str, str]:
|
|
30
|
+
h: dict[str, str] = {"Accept": "application/vnd.github+json"}
|
|
31
|
+
if self._token:
|
|
32
|
+
h["Authorization"] = f"Bearer {self._token}"
|
|
33
|
+
return h
|
|
34
|
+
|
|
35
|
+
# -- ReleaseSource interface ------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def get_latest_release(self, owner: str, repo: str) -> Release:
|
|
38
|
+
"""GET /repos/{owner}/{repo}/releases/latest
|
|
39
|
+
|
|
40
|
+
Falls back to listing releases if no 'latest' is set.
|
|
41
|
+
"""
|
|
42
|
+
from ixt.core.resolution_stats import record_github_api
|
|
43
|
+
|
|
44
|
+
url = f"{_API_BASE}/repos/{owner}/{repo}/releases/latest"
|
|
45
|
+
try:
|
|
46
|
+
record_github_api(owner, repo, "releases/latest")
|
|
47
|
+
data = get_json(url, headers=self._headers())
|
|
48
|
+
return _parse_release(data)
|
|
49
|
+
except NotFoundError:
|
|
50
|
+
# Some repos have releases but none marked 'latest'
|
|
51
|
+
releases = self.list_releases(owner, repo)
|
|
52
|
+
for r in releases:
|
|
53
|
+
if not r.prerelease and not r.draft:
|
|
54
|
+
return r
|
|
55
|
+
if releases:
|
|
56
|
+
return releases[0]
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
def get_release_by_tag(self, owner: str, repo: str, tag: str) -> Release:
|
|
60
|
+
"""GET /repos/{owner}/{repo}/releases/tags/{tag}"""
|
|
61
|
+
from ixt.core.resolution_stats import record_github_api
|
|
62
|
+
|
|
63
|
+
url = f"{_API_BASE}/repos/{owner}/{repo}/releases/tags/{tag}"
|
|
64
|
+
record_github_api(owner, repo, f"releases/tags/{tag}")
|
|
65
|
+
data = get_json(url, headers=self._headers())
|
|
66
|
+
return _parse_release(data)
|
|
67
|
+
|
|
68
|
+
def list_releases(self, owner: str, repo: str) -> list[Release]:
|
|
69
|
+
"""GET /repos/{owner}/{repo}/releases (first page, up to 30)."""
|
|
70
|
+
from ixt.core.resolution_stats import record_github_api
|
|
71
|
+
|
|
72
|
+
url = f"{_API_BASE}/repos/{owner}/{repo}/releases?per_page=30"
|
|
73
|
+
record_github_api(owner, repo, "releases")
|
|
74
|
+
data = get_json(url, headers=self._headers())
|
|
75
|
+
return [_parse_release(r) for r in data]
|
|
76
|
+
|
|
77
|
+
def download_asset(self, url: str, dest: Path) -> None:
|
|
78
|
+
"""Download a release asset to *dest*.
|
|
79
|
+
|
|
80
|
+
*url* is expected to be a ``browser_download_url`` pointing at the CDN
|
|
81
|
+
(``github.com/{owner}/{repo}/releases/download/...``). No API quota
|
|
82
|
+
consumed, no auth header required.
|
|
83
|
+
"""
|
|
84
|
+
download_file(url, dest)
|
|
85
|
+
|
|
86
|
+
def get_file_content(
|
|
87
|
+
self, owner: str, repo: str, path: str, *, ref: str | None = None
|
|
88
|
+
) -> str | None:
|
|
89
|
+
"""Fetch a file from ``raw.githubusercontent.com`` — no API quota.
|
|
90
|
+
|
|
91
|
+
When *ref* is None, uses ``HEAD`` (default branch). Callers that know
|
|
92
|
+
the target tag should pass it so the fetched content stays coherent
|
|
93
|
+
with the installed release.
|
|
94
|
+
"""
|
|
95
|
+
url = f"{_RAW_BASE}/{owner}/{repo}/{ref or 'HEAD'}/{path}"
|
|
96
|
+
try:
|
|
97
|
+
return get_text(url)
|
|
98
|
+
except NotFoundError:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_RELEASE_TAG_MARKER = "/releases/tag/"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_tag_from_release_url(final_url: str) -> str | None:
|
|
106
|
+
"""Extract the tag from a resolved ``.../releases/tag/{tag}`` URL.
|
|
107
|
+
|
|
108
|
+
Returns None when the marker is absent (unexpected redirect) or the tag
|
|
109
|
+
segment is empty.
|
|
110
|
+
"""
|
|
111
|
+
idx = final_url.find(_RELEASE_TAG_MARKER)
|
|
112
|
+
if idx < 0:
|
|
113
|
+
return None
|
|
114
|
+
return final_url[idx + len(_RELEASE_TAG_MARKER) :].rstrip("/").split("/")[0] or None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def fetch_latest_tag(owner: str, repo: str, *, timeout: int | None = None) -> str | None:
|
|
118
|
+
"""Resolve the latest release tag without calling the GitHub API.
|
|
119
|
+
|
|
120
|
+
Issues a HEAD on ``github.com/{owner}/{repo}/releases/latest`` and parses
|
|
121
|
+
the tag from the redirect's final URL (``.../releases/tag/{tag}``).
|
|
122
|
+
Returns None if the repo has no releases or the format is unexpected.
|
|
123
|
+
"""
|
|
124
|
+
url = f"{_WEB_BASE}/{owner}/{repo}/releases/latest"
|
|
125
|
+
try:
|
|
126
|
+
if timeout is None:
|
|
127
|
+
final = get_final_url(url)
|
|
128
|
+
else:
|
|
129
|
+
final = get_final_url(url, timeout=timeout)
|
|
130
|
+
except HttpError:
|
|
131
|
+
return None
|
|
132
|
+
return parse_tag_from_release_url(final)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Internal helpers
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _parse_release(data: dict) -> Release:
|
|
141
|
+
"""Parse a GitHub release JSON into a Release."""
|
|
142
|
+
tag = data.get("tag_name", "")
|
|
143
|
+
assets = [
|
|
144
|
+
Asset(
|
|
145
|
+
name=a["name"],
|
|
146
|
+
# Prefer CDN URL (no API quota). The API asset URL is only used
|
|
147
|
+
# as fallback for legacy responses that omit browser_download_url.
|
|
148
|
+
url=a.get("browser_download_url") or a.get("url", ""),
|
|
149
|
+
size=a.get("size", 0),
|
|
150
|
+
)
|
|
151
|
+
for a in data.get("assets", [])
|
|
152
|
+
]
|
|
153
|
+
return Release(
|
|
154
|
+
tag=tag,
|
|
155
|
+
assets=assets,
|
|
156
|
+
prerelease=data.get("prerelease", False),
|
|
157
|
+
draft=data.get("draft", False),
|
|
158
|
+
)
|
ixt/net/gitlab_api.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""GitLab Releases API client — implements ReleaseSource.
|
|
2
|
+
|
|
3
|
+
Supports gitlab.com and self-hosted instances via dynamic base_url.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ixt.net.http import NotFoundError, download_file, get_json, get_text
|
|
13
|
+
from ixt.net.source import Asset, Release
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GitLabSource:
|
|
17
|
+
"""Fetch releases and files from the GitLab API v4.
|
|
18
|
+
|
|
19
|
+
Token resolution order:
|
|
20
|
+
1. Explicit ``token`` argument
|
|
21
|
+
2. ``GITLAB_TOKEN`` environment variable
|
|
22
|
+
3. No token (public repos only)
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
base_url: GitLab instance URL (e.g. "https://gitlab.com",
|
|
26
|
+
"https://gitlab.company.com"). No trailing slash.
|
|
27
|
+
token: Private access token.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
base_url: str = "https://gitlab.com",
|
|
33
|
+
token: str | None = None,
|
|
34
|
+
):
|
|
35
|
+
self._base = base_url.rstrip("/")
|
|
36
|
+
self._api = f"{self._base}/api/v4"
|
|
37
|
+
self._token = token or os.environ.get("GITLAB_TOKEN")
|
|
38
|
+
|
|
39
|
+
def _headers(self) -> dict[str, str]:
|
|
40
|
+
h: dict[str, str] = {}
|
|
41
|
+
if self._token:
|
|
42
|
+
h["PRIVATE-TOKEN"] = self._token
|
|
43
|
+
return h
|
|
44
|
+
|
|
45
|
+
def _project_id(self, owner: str, repo: str) -> str:
|
|
46
|
+
"""URL-encode the project path for API calls."""
|
|
47
|
+
return urllib.parse.quote(f"{owner}/{repo}", safe="")
|
|
48
|
+
|
|
49
|
+
# -- ReleaseSource interface ------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def get_latest_release(self, owner: str, repo: str) -> Release:
|
|
52
|
+
"""GET /projects/{id}/releases/permalink/latest
|
|
53
|
+
|
|
54
|
+
Falls back to listing releases if permalink not available.
|
|
55
|
+
"""
|
|
56
|
+
from ixt.core.resolution_stats import record_gitlab_api
|
|
57
|
+
|
|
58
|
+
pid = self._project_id(owner, repo)
|
|
59
|
+
url = f"{self._api}/projects/{pid}/releases/permalink/latest"
|
|
60
|
+
try:
|
|
61
|
+
record_gitlab_api(owner, repo, "releases/permalink/latest")
|
|
62
|
+
data = get_json(url, headers=self._headers())
|
|
63
|
+
return _parse_release(data, self._base)
|
|
64
|
+
except NotFoundError:
|
|
65
|
+
releases = self.list_releases(owner, repo)
|
|
66
|
+
if releases:
|
|
67
|
+
return releases[0]
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
def get_release_by_tag(self, owner: str, repo: str, tag: str) -> Release:
|
|
71
|
+
"""GET /projects/{id}/releases/{tag}"""
|
|
72
|
+
from ixt.core.resolution_stats import record_gitlab_api
|
|
73
|
+
|
|
74
|
+
pid = self._project_id(owner, repo)
|
|
75
|
+
encoded_tag = urllib.parse.quote(tag, safe="")
|
|
76
|
+
url = f"{self._api}/projects/{pid}/releases/{encoded_tag}"
|
|
77
|
+
record_gitlab_api(owner, repo, f"releases/{tag}")
|
|
78
|
+
data = get_json(url, headers=self._headers())
|
|
79
|
+
return _parse_release(data, self._base)
|
|
80
|
+
|
|
81
|
+
def list_releases(self, owner: str, repo: str) -> list[Release]:
|
|
82
|
+
"""GET /projects/{id}/releases (first page, up to 20)."""
|
|
83
|
+
from ixt.core.resolution_stats import record_gitlab_api
|
|
84
|
+
|
|
85
|
+
pid = self._project_id(owner, repo)
|
|
86
|
+
url = f"{self._api}/projects/{pid}/releases?per_page=20"
|
|
87
|
+
record_gitlab_api(owner, repo, "releases")
|
|
88
|
+
data = get_json(url, headers=self._headers())
|
|
89
|
+
return [_parse_release(r, self._base) for r in data]
|
|
90
|
+
|
|
91
|
+
def download_asset(self, url: str, dest: Path) -> None:
|
|
92
|
+
"""Download a release asset to *dest*."""
|
|
93
|
+
download_file(url, dest, headers=self._headers())
|
|
94
|
+
|
|
95
|
+
def get_file_content(
|
|
96
|
+
self, owner: str, repo: str, path: str, *, ref: str | None = None
|
|
97
|
+
) -> str | None:
|
|
98
|
+
"""Fetch a file from GitLab's raw endpoint — no API quota.
|
|
99
|
+
|
|
100
|
+
Uses ``{base}/{owner}/{repo}/-/raw/{ref}/{path}`` which is served
|
|
101
|
+
outside the API. When *ref* is None, uses ``HEAD``.
|
|
102
|
+
"""
|
|
103
|
+
encoded_ref = urllib.parse.quote(ref or "HEAD", safe="")
|
|
104
|
+
url = f"{self._base}/{owner}/{repo}/-/raw/{encoded_ref}/{path}"
|
|
105
|
+
try:
|
|
106
|
+
return get_text(url, headers=self._headers())
|
|
107
|
+
except NotFoundError:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Internal helpers
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_release(data: dict, base_url: str) -> Release:
|
|
117
|
+
"""Parse a GitLab release JSON into a Release."""
|
|
118
|
+
tag = data.get("tag_name", "")
|
|
119
|
+
assets_data = data.get("assets", {})
|
|
120
|
+
|
|
121
|
+
assets: list[Asset] = []
|
|
122
|
+
|
|
123
|
+
# Release links (uploaded assets)
|
|
124
|
+
for link in assets_data.get("links", []):
|
|
125
|
+
assets.append(
|
|
126
|
+
Asset(
|
|
127
|
+
name=link.get("name", ""),
|
|
128
|
+
url=link.get("direct_asset_url", link.get("url", "")),
|
|
129
|
+
size=0, # GitLab links don't report size
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Source archives (auto-generated by GitLab)
|
|
134
|
+
for src in assets_data.get("sources", []):
|
|
135
|
+
fmt = src.get("format", "")
|
|
136
|
+
assets.append(
|
|
137
|
+
Asset(
|
|
138
|
+
name=f"source.{fmt}",
|
|
139
|
+
url=src.get("url", ""),
|
|
140
|
+
size=0,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return Release(
|
|
145
|
+
tag=tag,
|
|
146
|
+
assets=assets,
|
|
147
|
+
prerelease=data.get("upcoming_release", False),
|
|
148
|
+
draft=False, # GitLab doesn't have draft releases
|
|
149
|
+
)
|