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.
Files changed (84) hide show
  1. ixt/__init__.py +8 -0
  2. ixt/__main__.py +8 -0
  3. ixt/backends/__init__.py +1 -0
  4. ixt/backends/binary.py +935 -0
  5. ixt/backends/binary_resolver.py +307 -0
  6. ixt/backends/node.py +490 -0
  7. ixt/backends/python.py +234 -0
  8. ixt/cli/__init__.py +31 -0
  9. ixt/cli/argparse_completion.py +557 -0
  10. ixt/cli/cmd_apply.py +404 -0
  11. ixt/cli/cmd_cache.py +86 -0
  12. ixt/cli/cmd_config.py +295 -0
  13. ixt/cli/cmd_info.py +116 -0
  14. ixt/cli/cmd_install.py +508 -0
  15. ixt/cli/cmd_misc.py +261 -0
  16. ixt/cli/cmd_registry.py +35 -0
  17. ixt/cli/cmd_upgrade.py +336 -0
  18. ixt/cli/commands.py +70 -0
  19. ixt/cli/parser.py +555 -0
  20. ixt/cli/render.py +85 -0
  21. ixt/config/__init__.py +5 -0
  22. ixt/config/asset_index.py +305 -0
  23. ixt/config/asset_pattern_cache.py +87 -0
  24. ixt/config/env_policy.py +340 -0
  25. ixt/config/flags.py +29 -0
  26. ixt/config/fs_policy.py +17 -0
  27. ixt/config/heuristics.py +465 -0
  28. ixt/config/models.py +176 -0
  29. ixt/config/registry.py +145 -0
  30. ixt/config/settings.py +173 -0
  31. ixt/config/setup_toml.py +179 -0
  32. ixt/config/toml.py +416 -0
  33. ixt/core/__init__.py +16 -0
  34. ixt/core/apply.py +564 -0
  35. ixt/core/apply_actions.py +106 -0
  36. ixt/core/backend.py +187 -0
  37. ixt/core/bootstrap.py +410 -0
  38. ixt/core/cache.py +332 -0
  39. ixt/core/discover.py +150 -0
  40. ixt/core/doctor.py +591 -0
  41. ixt/core/export.py +419 -0
  42. ixt/core/expose.py +350 -0
  43. ixt/core/extract.py +261 -0
  44. ixt/core/hooks.py +182 -0
  45. ixt/core/identity.py +148 -0
  46. ixt/core/inject.py +143 -0
  47. ixt/core/install.py +509 -0
  48. ixt/core/install_local.py +229 -0
  49. ixt/core/locks.py +54 -0
  50. ixt/core/pathlink.py +86 -0
  51. ixt/core/resolution_stats.py +191 -0
  52. ixt/core/resolve.py +150 -0
  53. ixt/core/resolve_cache.py +185 -0
  54. ixt/core/runtimes.py +192 -0
  55. ixt/core/save.py +237 -0
  56. ixt/core/setup_completions.py +11 -0
  57. ixt/core/setup_path.py +368 -0
  58. ixt/core/upgrade.py +596 -0
  59. ixt/data/__init__.py +10 -0
  60. ixt/data/asset_index.json +574 -0
  61. ixt/data/heuristics.toml +98 -0
  62. ixt/data/registry.toml +71 -0
  63. ixt/libs/__init__.py +3 -0
  64. ixt/libs/constants.py +4 -0
  65. ixt/libs/fmt.py +108 -0
  66. ixt/libs/logger.py +109 -0
  67. ixt/libs/output.py +25 -0
  68. ixt/libs/req_spec.py +115 -0
  69. ixt/libs/semver.py +149 -0
  70. ixt/libs/shell.py +126 -0
  71. ixt/libs/style.py +238 -0
  72. ixt/net/__init__.py +1 -0
  73. ixt/net/github_api.py +158 -0
  74. ixt/net/gitlab_api.py +149 -0
  75. ixt/net/http.py +194 -0
  76. ixt/net/npm.py +24 -0
  77. ixt/net/pypi.py +26 -0
  78. ixt/net/source.py +163 -0
  79. ixt/platform/__init__.py +131 -0
  80. ixt/platform/win.py +68 -0
  81. ixt_cli-0.8.0.dist-info/METADATA +294 -0
  82. ixt_cli-0.8.0.dist-info/RECORD +84 -0
  83. ixt_cli-0.8.0.dist-info/WHEEL +4 -0
  84. 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
+ )