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/data/registry.toml
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# registry.toml — Short name -> spec resolution
|
|
2
|
+
#
|
|
3
|
+
# The registry serves ONE purpose: `ixt tool install ripgrep` (short name)
|
|
4
|
+
# -> find the corresponding spec (e.g. @gh:BurntSushi/ripgrep).
|
|
5
|
+
#
|
|
6
|
+
# It does NOT:
|
|
7
|
+
# - Detect the right asset (scoring does that)
|
|
8
|
+
# - Find the binary (auto-expose does that)
|
|
9
|
+
# - Declare the format (bare binary auto-detected)
|
|
10
|
+
#
|
|
11
|
+
# When the user types `ixt tool install BurntSushi/ripgrep` (full path),
|
|
12
|
+
# no registry entry is needed.
|
|
13
|
+
#
|
|
14
|
+
# Format:
|
|
15
|
+
# ripgrep = "@gh:BurntSushi/ripgrep"
|
|
16
|
+
#
|
|
17
|
+
# User can add/override entries in $IXT_HOME/config/registry.toml.
|
|
18
|
+
|
|
19
|
+
[tools]
|
|
20
|
+
ast-grep = "@gh:ast-grep/ast-grep"
|
|
21
|
+
atuin = "@gh:atuinsh/atuin"
|
|
22
|
+
bat = "@gh:sharkdp/bat"
|
|
23
|
+
bottom = "@gh:ClementTsang/bottom"
|
|
24
|
+
btop = "@gh:aristocratos/btop"
|
|
25
|
+
chezmoi = "@gh:twpayne/chezmoi"
|
|
26
|
+
delta = "@gh:dandavison/delta"
|
|
27
|
+
difftastic = "@gh:Wilfred/difftastic"
|
|
28
|
+
dive = "@gh:wagoodman/dive"
|
|
29
|
+
duf = "@gh:muesli/duf"
|
|
30
|
+
dust = "@gh:bootandy/dust"
|
|
31
|
+
eza = "@gh:eza-community/eza"
|
|
32
|
+
fd = "@gh:sharkdp/fd"
|
|
33
|
+
fimod = "@gh:pytgaen/fimod"
|
|
34
|
+
fzf = "@gh:junegunn/fzf"
|
|
35
|
+
gh = "@gh:cli/cli"
|
|
36
|
+
git-cliff = "@gh:orhun/git-cliff"
|
|
37
|
+
gitleaks = "@gh:gitleaks/gitleaks"
|
|
38
|
+
gitui = "@gh:extrawurst/gitui"
|
|
39
|
+
glow = "@gh:charmbracelet/glow"
|
|
40
|
+
gum = "@gh:charmbracelet/gum"
|
|
41
|
+
helix = "@gh:helix-editor/helix"
|
|
42
|
+
hexyl = "@gh:sharkdp/hexyl"
|
|
43
|
+
hyperfine = "@gh:sharkdp/hyperfine"
|
|
44
|
+
jj = "@gh:jj-vcs/jj"
|
|
45
|
+
jq = "@gh:jqlang/jq"
|
|
46
|
+
just = "@gh:casey/just"
|
|
47
|
+
k9s = "@gh:derailed/k9s"
|
|
48
|
+
lazydocker = "@gh:jesseduffield/lazydocker"
|
|
49
|
+
lazygit = "@gh:jesseduffield/lazygit"
|
|
50
|
+
lazyssh = "@gh:Adembc/lazyssh"
|
|
51
|
+
lnav = "@gh:tstack/lnav"
|
|
52
|
+
lsd = "@gh:lsd-rs/lsd"
|
|
53
|
+
mise = "@gh:jdx/mise"
|
|
54
|
+
nushell = "@gh:nushell/nushell"
|
|
55
|
+
procs = "@gh:dalance/procs"
|
|
56
|
+
rclone = "@gh:rclone/rclone"
|
|
57
|
+
ripgrep = "@gh:BurntSushi/ripgrep"
|
|
58
|
+
sd = "@gh:chmln/sd"
|
|
59
|
+
shellcheck = "@gh:koalaman/shellcheck"
|
|
60
|
+
starship = "@gh:starship/starship"
|
|
61
|
+
task = "@gh:go-task/task"
|
|
62
|
+
tealdeer = "@gh:tealdeer-rs/tealdeer"
|
|
63
|
+
television = "@gh:alexpasmantier/television"
|
|
64
|
+
trivy = "@gh:aquasecurity/trivy"
|
|
65
|
+
typos = "@gh:crate-ci/typos"
|
|
66
|
+
watchexec = "@gh:watchexec/watchexec"
|
|
67
|
+
xh = "@gh:ducaale/xh"
|
|
68
|
+
yazi = "@gh:sxyazi/yazi"
|
|
69
|
+
yq = "@gh:mikefarah/yq"
|
|
70
|
+
zellij = "@gh:zellij-org/zellij"
|
|
71
|
+
zoxide = "@gh:ajeetdsouza/zoxide"
|
ixt/libs/__init__.py
ADDED
ixt/libs/constants.py
ADDED
ixt/libs/fmt.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Text formatting utilities.
|
|
2
|
+
|
|
3
|
+
Includes table formatting with word-wrap support ported from uvpipx's
|
|
4
|
+
text_formatter.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import textwrap
|
|
10
|
+
|
|
11
|
+
from ixt.libs.style import visible_len
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_table(rows: list[list[str]], headers: list[str] | None = None) -> str:
|
|
15
|
+
"""Format data as a simple ASCII table."""
|
|
16
|
+
if not rows:
|
|
17
|
+
return ""
|
|
18
|
+
|
|
19
|
+
all_rows: list[list[str]] = [headers] if headers else []
|
|
20
|
+
all_rows.extend(rows)
|
|
21
|
+
|
|
22
|
+
col_widths = max_col_widths(all_rows)
|
|
23
|
+
|
|
24
|
+
def _format_row(row: list[str]) -> str:
|
|
25
|
+
cells = []
|
|
26
|
+
for cell, width in zip(row, col_widths, strict=False):
|
|
27
|
+
s = str(cell)
|
|
28
|
+
pad = width - visible_len(s)
|
|
29
|
+
cells.append(s + " " * max(pad, 0))
|
|
30
|
+
return " ".join(cells)
|
|
31
|
+
|
|
32
|
+
lines: list[str] = []
|
|
33
|
+
if headers:
|
|
34
|
+
lines.append(_format_row(headers))
|
|
35
|
+
lines.append("-" * (sum(col_widths) + 2 * (len(col_widths) - 1)))
|
|
36
|
+
|
|
37
|
+
for row in rows:
|
|
38
|
+
lines.append(_format_row(row))
|
|
39
|
+
|
|
40
|
+
return "\n".join(lines)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def indent(text: str, spaces: int = 2) -> str:
|
|
44
|
+
"""Indent each line of *text* by *spaces* spaces."""
|
|
45
|
+
prefix = " " * spaces
|
|
46
|
+
return prefix + ("\n" + prefix).join(text.split("\n"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def truncate(text: str, max_width: int, suffix: str = "...") -> str:
|
|
50
|
+
"""Truncate *text* to *max_width*, adding *suffix* if truncated."""
|
|
51
|
+
if len(text) <= max_width:
|
|
52
|
+
return text
|
|
53
|
+
return text[: max_width - len(suffix)] + suffix
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# -- Column width helpers ----------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def max_col_widths(table: list[list[str]]) -> list[int]:
|
|
60
|
+
"""Return the maximum visible width for each column, ignoring ANSI codes."""
|
|
61
|
+
if not table:
|
|
62
|
+
return []
|
|
63
|
+
num_cols = len(table[0])
|
|
64
|
+
return [max(visible_len(row[i]) for row in table) for i in range(num_cols)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# -- Word-wrap aware table --------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _NewLinePreservingWrapper(textwrap.TextWrapper):
|
|
71
|
+
"""TextWrapper that preserves explicit newlines in the source text."""
|
|
72
|
+
|
|
73
|
+
def wrap(self, text: str) -> list[str]:
|
|
74
|
+
lines: list[str] = []
|
|
75
|
+
for part in text.split("\n"):
|
|
76
|
+
lines.extend(super().wrap(part))
|
|
77
|
+
return lines
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def wrap_text_in_table(
|
|
81
|
+
table: list[list[str]],
|
|
82
|
+
column_widths: list[int],
|
|
83
|
+
) -> list[list[list[str]]]:
|
|
84
|
+
"""Wrap cell text to *column_widths* and pad lines for vertical alignment.
|
|
85
|
+
|
|
86
|
+
Returns a 3-D structure: ``rows → cells → lines``.
|
|
87
|
+
Each line is left-justified to its column width so that columns align when
|
|
88
|
+
printed side by side.
|
|
89
|
+
"""
|
|
90
|
+
wrappers = [_NewLinePreservingWrapper(width=w) for w in column_widths]
|
|
91
|
+
|
|
92
|
+
wrapped_table: list[list[list[str]]] = []
|
|
93
|
+
for row in table:
|
|
94
|
+
wrapped_row: list[list[str]] = []
|
|
95
|
+
max_lines = 0
|
|
96
|
+
|
|
97
|
+
for content, wrapper in zip(row, wrappers, strict=False):
|
|
98
|
+
cell_lines = [line.ljust(wrapper.width) for line in wrapper.wrap(content)]
|
|
99
|
+
max_lines = max(max_lines, len(cell_lines))
|
|
100
|
+
wrapped_row.append(cell_lines)
|
|
101
|
+
|
|
102
|
+
# Pad all cells in the row to the same number of lines.
|
|
103
|
+
for i, cell in enumerate(wrapped_row):
|
|
104
|
+
cell.extend(["".ljust(column_widths[i])] * (max_lines - len(cell)))
|
|
105
|
+
|
|
106
|
+
wrapped_table.append(wrapped_row)
|
|
107
|
+
|
|
108
|
+
return wrapped_table
|
ixt/libs/logger.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Structured logger for ixt with colored level prefixes.
|
|
2
|
+
|
|
3
|
+
Ported from uvpipx's Logger.py — simplified, integrated with style.py.
|
|
4
|
+
Output goes to stderr so it never pollutes piped stdout.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from enum import IntEnum
|
|
11
|
+
|
|
12
|
+
from ixt.libs.style import Color, Painter, no_color_stderr, strip_ansi
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Level(IntEnum):
|
|
16
|
+
"""Log levels (higher value = more severe)."""
|
|
17
|
+
|
|
18
|
+
DEBUG = 0
|
|
19
|
+
INFO = 50
|
|
20
|
+
WARN = 200
|
|
21
|
+
ERROR = 300
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_LEVEL_STYLE: dict[Level, tuple[str, tuple[Color, ...]]] = {
|
|
25
|
+
Level.DEBUG: ("DEBG", (Color.BLUE,)),
|
|
26
|
+
Level.INFO: ("INFO", (Color.BRIGHT_CYAN, Color.ST_BOLD)),
|
|
27
|
+
Level.WARN: ("WARN", (Color.BRIGHT_YELLOW, Color.ST_BOLD)),
|
|
28
|
+
Level.ERROR: ("ERR ", (Color.BRIGHT_RED, Color.ST_BOLD)),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Logger:
|
|
33
|
+
"""Logger that writes to stderr with optional colored level prefixes."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
name: str,
|
|
38
|
+
level: Level = Level.INFO,
|
|
39
|
+
*,
|
|
40
|
+
show_prefix: bool = True,
|
|
41
|
+
):
|
|
42
|
+
self.name = name
|
|
43
|
+
self.level = level
|
|
44
|
+
self.show_prefix = show_prefix
|
|
45
|
+
|
|
46
|
+
def _format_prefix(self, level: Level) -> str:
|
|
47
|
+
if not self.show_prefix:
|
|
48
|
+
return ""
|
|
49
|
+
label, styles = _LEVEL_STYLE[level]
|
|
50
|
+
return f"[{Painter.color_str(label, *styles)}] "
|
|
51
|
+
|
|
52
|
+
def _emit(self, level: Level, message: str) -> None:
|
|
53
|
+
if level < self.level:
|
|
54
|
+
return
|
|
55
|
+
prefix = self._format_prefix(level)
|
|
56
|
+
strip = no_color_stderr()
|
|
57
|
+
for line in message.split("\n"):
|
|
58
|
+
out = f"{prefix}{line}\n"
|
|
59
|
+
if strip:
|
|
60
|
+
out = strip_ansi(out)
|
|
61
|
+
sys.stderr.write(out)
|
|
62
|
+
sys.stderr.flush()
|
|
63
|
+
|
|
64
|
+
def debug(self, message: str) -> None:
|
|
65
|
+
self._emit(Level.DEBUG, message)
|
|
66
|
+
|
|
67
|
+
def info(self, message: str) -> None:
|
|
68
|
+
self._emit(Level.INFO, message)
|
|
69
|
+
|
|
70
|
+
def warn(self, message: str) -> None:
|
|
71
|
+
self._emit(Level.WARN, message)
|
|
72
|
+
|
|
73
|
+
def error(self, message: str) -> None:
|
|
74
|
+
self._emit(Level.ERROR, message)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# -- Singleton registry ------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
_LOGGERS: dict[str, Logger] = {}
|
|
80
|
+
_GLOBAL_LEVEL: Level | None = None
|
|
81
|
+
_VERBOSITY: int = 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def set_global_level(level: Level, *, verbosity: int = 1) -> None:
|
|
85
|
+
"""Override the level of all existing and future loggers."""
|
|
86
|
+
global _GLOBAL_LEVEL, _VERBOSITY
|
|
87
|
+
_GLOBAL_LEVEL = level
|
|
88
|
+
_VERBOSITY = verbosity
|
|
89
|
+
for lg in _LOGGERS.values():
|
|
90
|
+
lg.level = level
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_verbosity() -> int:
|
|
94
|
+
"""Return the CLI verbosity count (0=normal, 1=-v, 2=-vv, …)."""
|
|
95
|
+
return _VERBOSITY
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_logger(name: str = "ixt", level: Level = Level.INFO) -> Logger:
|
|
99
|
+
"""Get or create a named logger instance.
|
|
100
|
+
|
|
101
|
+
If the logger already exists, its level is updated to *level*.
|
|
102
|
+
A global level override (via *set_global_level*) takes precedence.
|
|
103
|
+
"""
|
|
104
|
+
effective = _GLOBAL_LEVEL if _GLOBAL_LEVEL is not None else level
|
|
105
|
+
if name not in _LOGGERS:
|
|
106
|
+
_LOGGERS[name] = Logger(name, effective)
|
|
107
|
+
else:
|
|
108
|
+
_LOGGERS[name].level = effective
|
|
109
|
+
return _LOGGERS[name]
|
ixt/libs/output.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Data output to stdout — pipeable, color-aware.
|
|
2
|
+
|
|
3
|
+
Use for the *result* of a command (list, info, export, config get) so that
|
|
4
|
+
``ixt <cmd> > file`` and ``ixt <cmd> | other`` work as users expect.
|
|
5
|
+
|
|
6
|
+
Operational messages (progress, hints, errors) stay on the logger (stderr).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from ixt.libs.style import no_color_stdout, strip_ansi
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def data(line: str) -> None:
|
|
17
|
+
"""Write *line* to stdout, followed by a newline.
|
|
18
|
+
|
|
19
|
+
ANSI sequences are stripped when stdout is not a TTY or ``NO_COLOR`` is set,
|
|
20
|
+
so callers can freely use ``dim()`` / ``bold()`` without worrying about
|
|
21
|
+
garbage escape codes landing in files or pipes.
|
|
22
|
+
"""
|
|
23
|
+
if no_color_stdout():
|
|
24
|
+
line = strip_ansi(line)
|
|
25
|
+
sys.stdout.write(line + "\n")
|
ixt/libs/req_spec.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Simple PEP 508 requirement specifier parsing
|
|
2
|
+
|
|
3
|
+
Handles basic parsing of package specifications like:
|
|
4
|
+
- ruff
|
|
5
|
+
- ruff>=1.0.0
|
|
6
|
+
- ruff>=1.0.0,<2.0.0
|
|
7
|
+
- ruff[dev]
|
|
8
|
+
- ruff[dev]>=1.0.0
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
NAME_PATTERN = r"(?:@[A-Za-z0-9._-]+/[A-Za-z0-9._-]+|[A-Za-z0-9._-]+)"
|
|
15
|
+
VERSION_OPERATOR_PATTERN = r"(>=|<=|==|!=|~=|>|<)"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class VersionSpec:
|
|
20
|
+
"""Version specification for a package"""
|
|
21
|
+
|
|
22
|
+
operator: str # >=, <=, ==, !=, ~=, etc.
|
|
23
|
+
version: str
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
return f"{self.operator}{self.version}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class RequirementSpec:
|
|
31
|
+
"""Parsed PEP 508 requirement specification"""
|
|
32
|
+
|
|
33
|
+
name: str # Package name
|
|
34
|
+
extras: list[str] # Optional extras [dev, test, ...]
|
|
35
|
+
specs: list[VersionSpec] # Version specifiers
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def version_constraint(self) -> str | None:
|
|
39
|
+
"""Get the version constraint as a string, or None if not specified"""
|
|
40
|
+
if not self.specs:
|
|
41
|
+
return None
|
|
42
|
+
return ",".join(str(spec) for spec in self.specs)
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
s = self.name
|
|
46
|
+
if self.extras:
|
|
47
|
+
s += f"[{','.join(self.extras)}]"
|
|
48
|
+
if self.specs:
|
|
49
|
+
s += ",".join(str(spec) for spec in self.specs)
|
|
50
|
+
return s
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_requirement(req_str: str) -> RequirementSpec:
|
|
54
|
+
"""Parse a PEP 508 requirement string
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
req_str: Requirement string (e.g., "ruff>=1.0.0,<2.0.0[dev]")
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Parsed RequirementSpec
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If the requirement string is invalid
|
|
64
|
+
"""
|
|
65
|
+
req_str = req_str.strip()
|
|
66
|
+
|
|
67
|
+
# Extract extras [...]
|
|
68
|
+
extras = []
|
|
69
|
+
extras_match = re.search(r"\[([^\]]+)\]", req_str)
|
|
70
|
+
if extras_match:
|
|
71
|
+
extras = [e.strip() for e in extras_match.group(1).split(",")]
|
|
72
|
+
req_str = req_str[: extras_match.start()] + req_str[extras_match.end() :]
|
|
73
|
+
|
|
74
|
+
# Split name and version specs.
|
|
75
|
+
# Allow classic package names and npm-style scoped names.
|
|
76
|
+
match = re.match(rf"^(?P<name>{NAME_PATTERN})(?P<rest>.*)$", req_str.strip())
|
|
77
|
+
|
|
78
|
+
if not match:
|
|
79
|
+
raise ValueError(f"Invalid requirement: {req_str}")
|
|
80
|
+
|
|
81
|
+
name = match.group("name").strip()
|
|
82
|
+
version_part = match.group("rest").strip()
|
|
83
|
+
|
|
84
|
+
specs = []
|
|
85
|
+
if version_part:
|
|
86
|
+
# Parse version specifiers (comma-separated)
|
|
87
|
+
for part in version_part.split(","):
|
|
88
|
+
part = part.strip()
|
|
89
|
+
if not part:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
# Extract operator and version
|
|
93
|
+
spec_match = re.match(rf"^{VERSION_OPERATOR_PATTERN}\s*(.+)$", part)
|
|
94
|
+
if not spec_match:
|
|
95
|
+
raise ValueError(f"Invalid version spec: {part}")
|
|
96
|
+
|
|
97
|
+
operator = spec_match.group(1)
|
|
98
|
+
version = spec_match.group(2).strip()
|
|
99
|
+
if not version or re.match(rf"^{VERSION_OPERATOR_PATTERN}", version):
|
|
100
|
+
raise ValueError(f"Invalid version spec: {part}")
|
|
101
|
+
specs.append(VersionSpec(operator, version))
|
|
102
|
+
|
|
103
|
+
return RequirementSpec(name, extras, specs)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_requirements(req_strings: list[str]) -> list[RequirementSpec]:
|
|
107
|
+
"""Parse a list of requirement strings
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
req_strings: List of requirement strings
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
List of parsed RequirementSpec objects
|
|
114
|
+
"""
|
|
115
|
+
return [parse_requirement(req) for req in req_strings]
|
ixt/libs/semver.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Minimal semver utilities for version parsing, comparison, and partial matching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_VERSION_RE = re.compile(r"v?(\d+(?:\.\d+)*)")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_version(tag: str) -> tuple[int, ...] | None:
|
|
11
|
+
"""Parse a version string into a tuple of ints.
|
|
12
|
+
|
|
13
|
+
Strips leading 'v' and any suffix after the numeric part.
|
|
14
|
+
Returns None if the string contains no version.
|
|
15
|
+
|
|
16
|
+
>>> parse_version("v14.1.0")
|
|
17
|
+
(14, 1, 0)
|
|
18
|
+
>>> parse_version("14.1")
|
|
19
|
+
(14, 1)
|
|
20
|
+
>>> parse_version("not-a-version")
|
|
21
|
+
"""
|
|
22
|
+
m = _VERSION_RE.match(tag.strip())
|
|
23
|
+
if m is None:
|
|
24
|
+
return None
|
|
25
|
+
return tuple(int(x) for x in m.group(1).split("."))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def compare_versions(a: str, b: str) -> int:
|
|
29
|
+
"""Compare two version strings.
|
|
30
|
+
|
|
31
|
+
Returns -1 if a < b, 0 if a == b, 1 if a > b.
|
|
32
|
+
Missing components are treated as 0: "14.1" == "14.1.0".
|
|
33
|
+
Returns 0 if either version is unparsable.
|
|
34
|
+
"""
|
|
35
|
+
va = parse_version(a)
|
|
36
|
+
vb = parse_version(b)
|
|
37
|
+
if va is None or vb is None:
|
|
38
|
+
return 0
|
|
39
|
+
# Pad to same length
|
|
40
|
+
length = max(len(va), len(vb))
|
|
41
|
+
va = va + (0,) * (length - len(va))
|
|
42
|
+
vb = vb + (0,) * (length - len(vb))
|
|
43
|
+
if va < vb:
|
|
44
|
+
return -1
|
|
45
|
+
if va > vb:
|
|
46
|
+
return 1
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def matches_partial(version: str, partial: str) -> bool:
|
|
51
|
+
"""Check whether *version* matches a partial version prefix.
|
|
52
|
+
|
|
53
|
+
>>> matches_partial("14.1.0", "14")
|
|
54
|
+
True
|
|
55
|
+
>>> matches_partial("14.1.0", "14.1")
|
|
56
|
+
True
|
|
57
|
+
>>> matches_partial("14.1.0", "15")
|
|
58
|
+
False
|
|
59
|
+
"""
|
|
60
|
+
vt = parse_version(version)
|
|
61
|
+
pt = parse_version(partial)
|
|
62
|
+
if vt is None or pt is None:
|
|
63
|
+
return False
|
|
64
|
+
return vt[: len(pt)] == pt
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_partial_version(version: str) -> bool:
|
|
68
|
+
"""Return True if *version* looks like a partial semver (1 or 2 components).
|
|
69
|
+
|
|
70
|
+
>>> is_partial_version("14")
|
|
71
|
+
True
|
|
72
|
+
>>> is_partial_version("14.1")
|
|
73
|
+
True
|
|
74
|
+
>>> is_partial_version("14.1.0")
|
|
75
|
+
False
|
|
76
|
+
"""
|
|
77
|
+
vt = parse_version(version)
|
|
78
|
+
if vt is None:
|
|
79
|
+
return False
|
|
80
|
+
return len(vt) <= 2
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_OPERATOR_RE = re.compile(r"^\s*(==|!=|>=|<=|~=|>|<)\s*(.+?)\s*$")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _compatible_release_upper(version: str) -> str:
|
|
87
|
+
"""Compute the upper bound for a ``~=`` compatible release.
|
|
88
|
+
|
|
89
|
+
``~=X.Y`` → ``<X+1.0``
|
|
90
|
+
``~=X.Y.Z`` → ``<X.Y+1.0``
|
|
91
|
+
"""
|
|
92
|
+
parts = version.split(".")
|
|
93
|
+
if len(parts) < 2:
|
|
94
|
+
return version # ill-formed; caller will treat as non-matching
|
|
95
|
+
prefix = parts[:-2]
|
|
96
|
+
try:
|
|
97
|
+
bumped = int(parts[-2]) + 1
|
|
98
|
+
except ValueError:
|
|
99
|
+
return version
|
|
100
|
+
return ".".join([*prefix, str(bumped), "0"])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _matches_single(version: str, operator: str, target: str) -> bool:
|
|
104
|
+
"""Check *version* against one operator+target."""
|
|
105
|
+
if operator == "==":
|
|
106
|
+
return compare_versions(version, target) == 0
|
|
107
|
+
if operator == "!=":
|
|
108
|
+
return compare_versions(version, target) != 0
|
|
109
|
+
if operator == ">":
|
|
110
|
+
return compare_versions(version, target) > 0
|
|
111
|
+
if operator == ">=":
|
|
112
|
+
return compare_versions(version, target) >= 0
|
|
113
|
+
if operator == "<":
|
|
114
|
+
return compare_versions(version, target) < 0
|
|
115
|
+
if operator == "<=":
|
|
116
|
+
return compare_versions(version, target) <= 0
|
|
117
|
+
if operator == "~=":
|
|
118
|
+
upper = _compatible_release_upper(target)
|
|
119
|
+
return compare_versions(version, target) >= 0 and compare_versions(version, upper) < 0
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def version_satisfies(version: str, constraint: str) -> bool:
|
|
124
|
+
"""Return True if *version* satisfies *constraint* (PEP-440-ish).
|
|
125
|
+
|
|
126
|
+
Constraint may be a single specifier (``>=1.0``) or a comma-separated
|
|
127
|
+
conjunction (``>=1.0,<2.0``). Unknown/unparsable pieces are treated as
|
|
128
|
+
non-matching — callers should decide upstream whether to trust the
|
|
129
|
+
constraint string.
|
|
130
|
+
|
|
131
|
+
>>> version_satisfies("1.5", ">=1.0,<2.0")
|
|
132
|
+
True
|
|
133
|
+
>>> version_satisfies("2.0", ">=1.0,<2.0")
|
|
134
|
+
False
|
|
135
|
+
>>> version_satisfies("1.2.3", "~=1.2")
|
|
136
|
+
True
|
|
137
|
+
"""
|
|
138
|
+
if not constraint.strip():
|
|
139
|
+
return True
|
|
140
|
+
for part in constraint.split(","):
|
|
141
|
+
part = part.strip()
|
|
142
|
+
if not part:
|
|
143
|
+
continue
|
|
144
|
+
m = _OPERATOR_RE.match(part)
|
|
145
|
+
if not m:
|
|
146
|
+
return False
|
|
147
|
+
if not _matches_single(version, m.group(1), m.group(2)):
|
|
148
|
+
return False
|
|
149
|
+
return True
|