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/net/http.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Minimal HTTP client built on urllib (stdlib only).
|
|
2
|
+
|
|
3
|
+
Handles JSON GET, file downloads, auth headers, proxy, timeout, and retry.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import shutil
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HttpError(Exception):
|
|
18
|
+
"""HTTP request failed."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, url: str, status: int, reason: str, body: str = ""):
|
|
21
|
+
self.url = url
|
|
22
|
+
self.status = status
|
|
23
|
+
self.reason = reason
|
|
24
|
+
self.body = body
|
|
25
|
+
super().__init__(f"HTTP {status} {reason}: {url}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RateLimitError(HttpError):
|
|
29
|
+
"""GitHub/GitLab API rate limit exceeded."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, url: str, status: int, reason: str, body: str = ""):
|
|
32
|
+
super().__init__(url, status, reason, body)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NotFoundError(HttpError):
|
|
36
|
+
"""Resource not found (404)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, url: str, body: str = ""):
|
|
39
|
+
super().__init__(url, 404, "Not Found", body)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Internal helpers
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
_DEFAULT_TIMEOUT = 30
|
|
47
|
+
_DOWNLOAD_TIMEOUT = 300
|
|
48
|
+
_MAX_RETRIES = 2
|
|
49
|
+
_RETRY_DELAY = 1.0
|
|
50
|
+
_RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_opener() -> urllib.request.OpenerDirector:
|
|
54
|
+
"""Build a urllib opener with proxy support."""
|
|
55
|
+
proxy = urllib.request.ProxyHandler()
|
|
56
|
+
return urllib.request.build_opener(proxy)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _classify_error(url: str, exc: urllib.error.HTTPError) -> HttpError:
|
|
60
|
+
"""Turn an HTTPError into a typed exception."""
|
|
61
|
+
body = ""
|
|
62
|
+
try:
|
|
63
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
status = exc.code
|
|
67
|
+
reason = exc.reason if isinstance(exc.reason, str) else str(exc.reason)
|
|
68
|
+
if status == 404:
|
|
69
|
+
return NotFoundError(url, body)
|
|
70
|
+
if status in (403, 429):
|
|
71
|
+
return RateLimitError(url, status, reason, body)
|
|
72
|
+
return HttpError(url, status, reason, body)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Public API
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_json(
|
|
81
|
+
url: str,
|
|
82
|
+
*,
|
|
83
|
+
headers: dict[str, str] | None = None,
|
|
84
|
+
timeout: int = _DEFAULT_TIMEOUT,
|
|
85
|
+
) -> Any:
|
|
86
|
+
"""GET a URL and parse the response as JSON."""
|
|
87
|
+
data = get_bytes(url, headers=headers, timeout=timeout)
|
|
88
|
+
return json.loads(data)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_text(
|
|
92
|
+
url: str,
|
|
93
|
+
*,
|
|
94
|
+
headers: dict[str, str] | None = None,
|
|
95
|
+
timeout: int = _DEFAULT_TIMEOUT,
|
|
96
|
+
) -> str:
|
|
97
|
+
"""GET a URL and return the response as text."""
|
|
98
|
+
data = get_bytes(url, headers=headers, timeout=timeout)
|
|
99
|
+
return data.decode("utf-8")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_bytes(
|
|
103
|
+
url: str,
|
|
104
|
+
*,
|
|
105
|
+
headers: dict[str, str] | None = None,
|
|
106
|
+
timeout: int = _DEFAULT_TIMEOUT,
|
|
107
|
+
) -> bytes:
|
|
108
|
+
"""GET a URL and return raw bytes, with retry on transient errors."""
|
|
109
|
+
req = urllib.request.Request(url, method="GET")
|
|
110
|
+
req.add_header("User-Agent", "ixt/0.1")
|
|
111
|
+
req.add_header("Accept", "application/json")
|
|
112
|
+
if headers:
|
|
113
|
+
for k, v in headers.items():
|
|
114
|
+
req.add_header(k, v)
|
|
115
|
+
|
|
116
|
+
opener = _build_opener()
|
|
117
|
+
last_exc: Exception | None = None
|
|
118
|
+
|
|
119
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
120
|
+
try:
|
|
121
|
+
with opener.open(req, timeout=timeout) as resp:
|
|
122
|
+
return resp.read()
|
|
123
|
+
except urllib.error.HTTPError as exc:
|
|
124
|
+
typed = _classify_error(url, exc)
|
|
125
|
+
if exc.code not in _RETRYABLE_STATUSES or attempt == _MAX_RETRIES:
|
|
126
|
+
raise typed from exc
|
|
127
|
+
last_exc = typed
|
|
128
|
+
except urllib.error.URLError as exc:
|
|
129
|
+
if attempt == _MAX_RETRIES:
|
|
130
|
+
raise HttpError(url, 0, str(exc.reason)) from exc
|
|
131
|
+
last_exc = exc # type: ignore[assignment]
|
|
132
|
+
|
|
133
|
+
time.sleep(_RETRY_DELAY * (attempt + 1))
|
|
134
|
+
|
|
135
|
+
raise last_exc # type: ignore[misc] # unreachable but keeps mypy happy
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_final_url(
|
|
139
|
+
url: str,
|
|
140
|
+
*,
|
|
141
|
+
headers: dict[str, str] | None = None,
|
|
142
|
+
timeout: int = _DEFAULT_TIMEOUT,
|
|
143
|
+
) -> str:
|
|
144
|
+
"""Issue a HEAD request following redirects and return the final URL.
|
|
145
|
+
|
|
146
|
+
Used by the GitHub ``/releases/latest`` → ``/releases/tag/{tag}`` trick to
|
|
147
|
+
resolve the current latest tag without consuming an API request.
|
|
148
|
+
"""
|
|
149
|
+
req = urllib.request.Request(url, method="HEAD")
|
|
150
|
+
req.add_header("User-Agent", "ixt/0.1")
|
|
151
|
+
if headers:
|
|
152
|
+
for k, v in headers.items():
|
|
153
|
+
req.add_header(k, v)
|
|
154
|
+
|
|
155
|
+
opener = _build_opener()
|
|
156
|
+
try:
|
|
157
|
+
with opener.open(req, timeout=timeout) as resp:
|
|
158
|
+
return resp.geturl()
|
|
159
|
+
except urllib.error.HTTPError as exc:
|
|
160
|
+
raise _classify_error(url, exc) from exc
|
|
161
|
+
except urllib.error.URLError as exc:
|
|
162
|
+
raise HttpError(url, 0, str(exc.reason)) from exc
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def download_file(
|
|
166
|
+
url: str,
|
|
167
|
+
dest: Path,
|
|
168
|
+
*,
|
|
169
|
+
headers: dict[str, str] | None = None,
|
|
170
|
+
timeout: int = _DOWNLOAD_TIMEOUT,
|
|
171
|
+
) -> Path:
|
|
172
|
+
"""Download a URL to a local file path with streaming."""
|
|
173
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
175
|
+
|
|
176
|
+
req = urllib.request.Request(url, method="GET")
|
|
177
|
+
req.add_header("User-Agent", "ixt/0.1")
|
|
178
|
+
if headers:
|
|
179
|
+
for k, v in headers.items():
|
|
180
|
+
req.add_header(k, v)
|
|
181
|
+
|
|
182
|
+
opener = _build_opener()
|
|
183
|
+
try:
|
|
184
|
+
with opener.open(req, timeout=timeout) as resp, tmp.open("wb") as f:
|
|
185
|
+
shutil.copyfileobj(resp, f)
|
|
186
|
+
tmp.rename(dest)
|
|
187
|
+
except urllib.error.HTTPError as exc:
|
|
188
|
+
tmp.unlink(missing_ok=True)
|
|
189
|
+
raise _classify_error(url, exc) from exc
|
|
190
|
+
except Exception:
|
|
191
|
+
tmp.unlink(missing_ok=True)
|
|
192
|
+
raise
|
|
193
|
+
|
|
194
|
+
return dest
|
ixt/net/npm.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""npm registry client — used to resolve the latest version of a package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from ixt.net.http import get_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def latest_version_url(name: str) -> str:
|
|
11
|
+
# @scope/pkg must be URL-encoded (/ becomes %2F).
|
|
12
|
+
return f"https://registry.npmjs.org/{quote(name, safe='@')}/latest"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_latest_version(name: str) -> str:
|
|
16
|
+
"""Return the latest published version of *name* on the npm registry."""
|
|
17
|
+
from ixt.core.resolution_stats import record_npm_registry
|
|
18
|
+
|
|
19
|
+
record_npm_registry(name)
|
|
20
|
+
data = get_json(latest_version_url(name))
|
|
21
|
+
version = data.get("version")
|
|
22
|
+
if not isinstance(version, str) or not version:
|
|
23
|
+
raise ValueError(f"npm response for '{name}' missing version")
|
|
24
|
+
return version
|
ixt/net/pypi.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""PyPI registry client — used to resolve the latest version of a package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ixt.net.http import get_json
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def latest_version_url(name: str) -> str:
|
|
9
|
+
return f"https://pypi.org/pypi/{name}/json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_latest_version(name: str) -> str:
|
|
13
|
+
"""Return the latest published version of *name* on PyPI.
|
|
14
|
+
|
|
15
|
+
Raises any http.HttpError subclass on failure (NotFoundError for unknown
|
|
16
|
+
packages, HttpError for network/timeouts).
|
|
17
|
+
"""
|
|
18
|
+
from ixt.core.resolution_stats import record_pypi_registry
|
|
19
|
+
|
|
20
|
+
record_pypi_registry(name)
|
|
21
|
+
data = get_json(latest_version_url(name))
|
|
22
|
+
info = data.get("info", {})
|
|
23
|
+
version = info.get("version")
|
|
24
|
+
if not isinstance(version, str) or not version:
|
|
25
|
+
raise ValueError(f"PyPI response for '{name}' missing info.version")
|
|
26
|
+
return version
|
ixt/net/source.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""ReleaseSource protocol and spec parsing for GitHub/GitLab detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# Data models
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class Asset:
|
|
15
|
+
"""A single downloadable asset from a release."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
url: str
|
|
19
|
+
size: int = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class Release:
|
|
24
|
+
"""A release with its tag and assets."""
|
|
25
|
+
|
|
26
|
+
tag: str
|
|
27
|
+
assets: list[Asset] = field(default_factory=list)
|
|
28
|
+
prerelease: bool = False
|
|
29
|
+
draft: bool = False
|
|
30
|
+
resolution_source: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# ReleaseSource protocol
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@runtime_checkable
|
|
39
|
+
class ReleaseSource(Protocol):
|
|
40
|
+
"""Interface for fetching releases from a forge (GitHub, GitLab)."""
|
|
41
|
+
|
|
42
|
+
def get_latest_release(self, owner: str, repo: str) -> Release: ...
|
|
43
|
+
|
|
44
|
+
def get_release_by_tag(self, owner: str, repo: str, tag: str) -> Release: ...
|
|
45
|
+
|
|
46
|
+
def list_releases(self, owner: str, repo: str) -> list[Release]: ...
|
|
47
|
+
|
|
48
|
+
def download_asset(self, url: str, dest: Path) -> None: ...
|
|
49
|
+
|
|
50
|
+
def get_file_content(
|
|
51
|
+
self, owner: str, repo: str, path: str, *, ref: str | None = None
|
|
52
|
+
) -> str | None: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Avoid circular import — Path is only needed for type checking
|
|
56
|
+
from pathlib import Path # noqa: E402
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Spec parsing
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class RepoSpec:
|
|
65
|
+
"""Parsed result of a package specification.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
host: The forge hostname (e.g. "github.com", "gitlab.com", "gitlab.company.com").
|
|
69
|
+
owner: Repository owner/org.
|
|
70
|
+
repo: Repository name.
|
|
71
|
+
version: Optional version constraint (tag or semver fragment).
|
|
72
|
+
platform: "github" or "gitlab".
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
host: str
|
|
76
|
+
owner: str
|
|
77
|
+
repo: str
|
|
78
|
+
version: str | None = None
|
|
79
|
+
platform: str = "github" # "github" | "gitlab"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def strip_version_suffix(spec: str) -> str:
|
|
83
|
+
"""Drop a trailing ``@version`` from *spec*, preserving a leading ``@scope``.
|
|
84
|
+
|
|
85
|
+
``owner/repo@v1.2.3`` -> ``owner/repo`` but ``@scope/pkg`` is left intact
|
|
86
|
+
(npm scopes are not version pins).
|
|
87
|
+
"""
|
|
88
|
+
if "@" in spec and not spec.startswith("@"):
|
|
89
|
+
return spec.rsplit("@", 1)[0]
|
|
90
|
+
return spec
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_spec(spec: str) -> RepoSpec | None:
|
|
94
|
+
"""Parse a binary backend spec into a RepoSpec, or None if not a binary spec.
|
|
95
|
+
|
|
96
|
+
Recognized formats:
|
|
97
|
+
owner/repo -> GitHub (default)
|
|
98
|
+
owner/repo@version -> GitHub with version
|
|
99
|
+
github.com/owner/repo -> GitHub (explicit)
|
|
100
|
+
gitlab.com/owner/repo -> GitLab
|
|
101
|
+
gitlab.company.com/owner/repo -> GitLab self-hosted
|
|
102
|
+
*.gitlab.*/owner/repo -> GitLab self-hosted
|
|
103
|
+
|
|
104
|
+
Returns None for:
|
|
105
|
+
simple-name -> not a binary spec (PyPI or registry lookup)
|
|
106
|
+
@scope/pkg -> not a binary spec (npm)
|
|
107
|
+
"""
|
|
108
|
+
raw = spec.strip()
|
|
109
|
+
|
|
110
|
+
# Strip version suffix
|
|
111
|
+
version: str | None = None
|
|
112
|
+
if "@" in raw:
|
|
113
|
+
# Handle @scope/pkg (npm) — starts with @
|
|
114
|
+
if raw.startswith("@"):
|
|
115
|
+
return None
|
|
116
|
+
# owner/repo@version or domain/owner/repo@version
|
|
117
|
+
raw, version = raw.rsplit("@", 1)
|
|
118
|
+
if not version:
|
|
119
|
+
version = None
|
|
120
|
+
|
|
121
|
+
# No slash → not a binary spec (simple name, needs registry lookup)
|
|
122
|
+
if "/" not in raw:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
parts = raw.split("/")
|
|
126
|
+
|
|
127
|
+
# Convention: ``.git`` clone-URL suffix on the repo segment is stripped.
|
|
128
|
+
# ``gitlab-org/cli.git`` → ``gitlab-org/cli`` so the API call hits the
|
|
129
|
+
# actual project, not a literal ``cli.git`` that doesn't exist.
|
|
130
|
+
def _strip_git(name: str) -> str:
|
|
131
|
+
return name[:-4] if name.endswith(".git") and len(name) > 4 else name
|
|
132
|
+
|
|
133
|
+
# domain/owner/repo (3+ parts → has a host)
|
|
134
|
+
if len(parts) >= 3:
|
|
135
|
+
host = parts[0]
|
|
136
|
+
owner = parts[1]
|
|
137
|
+
repo = _strip_git("/".join(parts[2:])) # handle nested groups (GitLab)
|
|
138
|
+
platform = _detect_platform(host)
|
|
139
|
+
return RepoSpec(host=host, owner=owner, repo=repo, version=version, platform=platform)
|
|
140
|
+
|
|
141
|
+
# owner/repo (2 parts → default GitHub)
|
|
142
|
+
if len(parts) == 2:
|
|
143
|
+
owner, repo = parts
|
|
144
|
+
return RepoSpec(
|
|
145
|
+
host="github.com",
|
|
146
|
+
owner=owner,
|
|
147
|
+
repo=_strip_git(repo),
|
|
148
|
+
version=version,
|
|
149
|
+
platform="github",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _detect_platform(host: str) -> str:
|
|
156
|
+
"""Detect whether a host is GitHub or GitLab."""
|
|
157
|
+
h = host.lower()
|
|
158
|
+
if h == "github.com":
|
|
159
|
+
return "github"
|
|
160
|
+
if "gitlab" in h:
|
|
161
|
+
return "gitlab"
|
|
162
|
+
# Unknown host — assume GitLab (GitHub is the default for bare owner/repo)
|
|
163
|
+
return "gitlab"
|
ixt/platform/__init__.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Platform detection and utilities"""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import platform
|
|
5
|
+
import sys
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OS(Enum):
|
|
10
|
+
"""Supported operating systems"""
|
|
11
|
+
|
|
12
|
+
LINUX = "linux"
|
|
13
|
+
MACOS = "macos"
|
|
14
|
+
WINDOWS = "windows"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Arch(Enum):
|
|
18
|
+
"""Supported architectures"""
|
|
19
|
+
|
|
20
|
+
X86_64 = "x86_64"
|
|
21
|
+
ARM64 = "arm64"
|
|
22
|
+
ARMV7 = "armv7"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@functools.lru_cache(maxsize=1)
|
|
26
|
+
def get_os() -> OS:
|
|
27
|
+
"""Get the current operating system"""
|
|
28
|
+
system = platform.system().lower()
|
|
29
|
+
if system == "linux":
|
|
30
|
+
return OS.LINUX
|
|
31
|
+
elif system == "darwin":
|
|
32
|
+
return OS.MACOS
|
|
33
|
+
elif system == "windows":
|
|
34
|
+
return OS.WINDOWS
|
|
35
|
+
else:
|
|
36
|
+
raise RuntimeError(f"Unsupported OS: {system}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@functools.lru_cache(maxsize=1)
|
|
40
|
+
def get_arch() -> Arch:
|
|
41
|
+
"""Get the current architecture"""
|
|
42
|
+
machine = platform.machine().lower()
|
|
43
|
+
|
|
44
|
+
if machine in ("x86_64", "amd64", "x64"):
|
|
45
|
+
return Arch.X86_64
|
|
46
|
+
elif machine in ("arm64", "aarch64"):
|
|
47
|
+
return Arch.ARM64
|
|
48
|
+
elif machine in ("armv7", "armhf"):
|
|
49
|
+
return Arch.ARMV7
|
|
50
|
+
else:
|
|
51
|
+
raise RuntimeError(f"Unsupported architecture: {machine}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_linux() -> bool:
|
|
55
|
+
"""Check if running on Linux"""
|
|
56
|
+
return get_os() == OS.LINUX
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_macos() -> bool:
|
|
60
|
+
"""Check if running on macOS"""
|
|
61
|
+
return get_os() == OS.MACOS
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_windows() -> bool:
|
|
65
|
+
"""Check if running on Windows"""
|
|
66
|
+
return get_os() == OS.WINDOWS
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_x86_64() -> bool:
|
|
70
|
+
"""Check if running on x86_64 architecture"""
|
|
71
|
+
return get_arch() == Arch.X86_64
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_arm64() -> bool:
|
|
75
|
+
"""Check if running on ARM64 architecture"""
|
|
76
|
+
return get_arch() == Arch.ARM64
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PlatformInfo:
|
|
80
|
+
"""Information about the current platform"""
|
|
81
|
+
|
|
82
|
+
def __init__(self):
|
|
83
|
+
self.os = get_os()
|
|
84
|
+
self.arch = get_arch()
|
|
85
|
+
self.python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return (
|
|
89
|
+
f"PlatformInfo(os={self.os.value}, arch={self.arch.value},"
|
|
90
|
+
f" python={self.python_version})"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_platform_info() -> PlatformInfo:
|
|
95
|
+
"""Get current platform information"""
|
|
96
|
+
return PlatformInfo()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_binary_extension() -> str:
|
|
100
|
+
"""Get the binary file extension for the current OS"""
|
|
101
|
+
return ".exe" if is_windows() else ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_shell_profile_paths() -> list[str]:
|
|
105
|
+
"""Get shell profile paths for the current OS
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of shell profile file names (~/.bashrc, ~/.zshrc, etc.)
|
|
109
|
+
"""
|
|
110
|
+
if is_windows():
|
|
111
|
+
return ["$PROFILE"]
|
|
112
|
+
else:
|
|
113
|
+
# Unix-like systems
|
|
114
|
+
return ["~/.profile", "~/.bashrc", "~/.zshrc", "~/.kshrc", "~/.tcshrc"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = [
|
|
118
|
+
"OS",
|
|
119
|
+
"Arch",
|
|
120
|
+
"PlatformInfo",
|
|
121
|
+
"get_arch",
|
|
122
|
+
"get_binary_extension",
|
|
123
|
+
"get_os",
|
|
124
|
+
"get_platform_info",
|
|
125
|
+
"get_shell_profile_paths",
|
|
126
|
+
"is_arm64",
|
|
127
|
+
"is_linux",
|
|
128
|
+
"is_macos",
|
|
129
|
+
"is_windows",
|
|
130
|
+
"is_x86_64",
|
|
131
|
+
]
|
ixt/platform/win.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Windows-specific utilities (registry, PATH management, etc.)"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from ixt.platform import is_windows
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _modify_user_path(mutate: Callable[[list[str]], list[str]]) -> bool:
|
|
13
|
+
"""Open the user PATH registry value, apply *mutate*, and write back.
|
|
14
|
+
|
|
15
|
+
Uses REG_EXPAND_SZ so paths containing %USERPROFILE% etc. are expanded
|
|
16
|
+
correctly by the shell. No-op on non-Windows platforms.
|
|
17
|
+
"""
|
|
18
|
+
if not is_windows():
|
|
19
|
+
return True
|
|
20
|
+
try:
|
|
21
|
+
import winreg as _winreg
|
|
22
|
+
|
|
23
|
+
winreg = cast(Any, _winreg)
|
|
24
|
+
|
|
25
|
+
with winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) as reg:
|
|
26
|
+
with winreg.OpenKey(reg, r"Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE) as key:
|
|
27
|
+
try:
|
|
28
|
+
current_path, _ = winreg.QueryValueEx(key, "Path")
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
current_path = ""
|
|
31
|
+
old = [p for p in current_path.split(os.pathsep) if p]
|
|
32
|
+
new = mutate(old)
|
|
33
|
+
if new != old:
|
|
34
|
+
winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, os.pathsep.join(new))
|
|
35
|
+
return True
|
|
36
|
+
except Exception as e:
|
|
37
|
+
from ixt.libs.logger import get_logger
|
|
38
|
+
|
|
39
|
+
get_logger("platform.win").warn(f"Could not modify Windows PATH: {e}")
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def add_to_user_path(path_str: str) -> bool:
|
|
44
|
+
"""Add a directory to the user PATH on Windows (no-op elsewhere)."""
|
|
45
|
+
return _modify_user_path(lambda ps: ps if path_str in ps else [*ps, path_str])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_user_path() -> str | None:
|
|
49
|
+
"""Return the raw HKCU Path value, or None if unset / not Windows."""
|
|
50
|
+
if not is_windows():
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
import winreg as _winreg
|
|
54
|
+
|
|
55
|
+
winreg = cast(Any, _winreg)
|
|
56
|
+
|
|
57
|
+
with winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) as reg:
|
|
58
|
+
with winreg.OpenKey(reg, r"Environment", 0, winreg.KEY_READ) as key:
|
|
59
|
+
try:
|
|
60
|
+
value, _ = winreg.QueryValueEx(key, "Path")
|
|
61
|
+
return value
|
|
62
|
+
except FileNotFoundError:
|
|
63
|
+
return None
|
|
64
|
+
except Exception as e:
|
|
65
|
+
from ixt.libs.logger import get_logger
|
|
66
|
+
|
|
67
|
+
get_logger("platform.win").warn(f"Could not read Windows user PATH: {e}")
|
|
68
|
+
return None
|