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/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
@@ -0,0 +1,3 @@
1
+ """lib module"""
2
+
3
+ __all__ = []
ixt/libs/constants.py ADDED
@@ -0,0 +1,4 @@
1
+ """Shared constants — no internal imports to avoid circular dependencies."""
2
+
3
+ # Default exposure rule — expose only the main binary.
4
+ EXPOSE_MAIN = "__main__"
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