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/config/registry.py ADDED
@@ -0,0 +1,145 @@
1
+ """Registry for short name -> spec resolution.
2
+
3
+ `ixt tool install ripgrep` -> lookup registry -> `@gh:BurntSushi/ripgrep` -> binary backend.
4
+
5
+ Loads embedded registry.toml + optional user overrides from
6
+ $IXT_HOME/config/registry.toml + optional high-priority files from
7
+ IXT_REGISTRY.
8
+
9
+ Format:
10
+
11
+ [tools]
12
+ ripgrep = "@gh:BurntSushi/ripgrep"
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import functools
18
+ import os
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from ixt.config.toml import _load_toml
24
+ from ixt.data import get_data_path
25
+
26
+ REGISTRY_ENV = "IXT_REGISTRY"
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Data model
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class RegistryEntry:
35
+ """A single tool entry in the registry."""
36
+
37
+ spec: str
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Loading
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def _parse_registry(data: dict[str, Any]) -> dict[str, RegistryEntry]:
46
+ """Build a name -> RegistryEntry mapping from parsed TOML dict."""
47
+ tools = data.get("tools", {})
48
+ result: dict[str, RegistryEntry] = {}
49
+ for name, value in tools.items():
50
+ if isinstance(value, str):
51
+ result[name] = RegistryEntry(spec=value)
52
+ return result
53
+
54
+
55
+ @functools.lru_cache(maxsize=1)
56
+ def load_registry(user_path: Path | None = None) -> dict[str, RegistryEntry]:
57
+ """Load registry sources in priority order.
58
+
59
+ Lower-priority sources are loaded first. Later files override earlier
60
+ entries, so env-supplied registries have the highest priority.
61
+
62
+ Args:
63
+ user_path: Explicit path to user override file.
64
+ If None, checks $IXT_HOME/config/registry.toml.
65
+
66
+ Returns:
67
+ Dict mapping short name to RegistryEntry.
68
+ """
69
+ registry: dict[str, RegistryEntry] = {}
70
+ _merge_registry_file(registry, get_data_path("registry.toml"))
71
+
72
+ # User overrides
73
+ if user_path is None:
74
+ from ixt.config.settings import get_ixt_config_dir
75
+
76
+ user_path = get_ixt_config_dir() / "registry.toml"
77
+
78
+ _merge_registry_file(registry, user_path)
79
+
80
+ for raw in _split_env_paths(os.environ.get(REGISTRY_ENV, "")):
81
+ if raw.startswith(("http://", "https://")):
82
+ continue
83
+ _merge_registry_file(registry, Path(raw).expanduser())
84
+
85
+ return registry
86
+
87
+
88
+ def _merge_registry_file(registry: dict[str, RegistryEntry], path: Path) -> None:
89
+ if not path.is_file():
90
+ return
91
+ registry.update(_parse_registry(_load_toml(path)))
92
+
93
+
94
+ def _split_env_paths(value: str) -> list[str]:
95
+ return [part for part in (item.strip() for item in value.split(";")) if part]
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Lookup
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ def lookup(name: str, registry: dict[str, RegistryEntry] | None = None) -> RegistryEntry | None:
104
+ """Resolve a short name to a RegistryEntry.
105
+
106
+ Lookup strategy:
107
+ 1. Exact match on registry key (e.g. "ripgrep" -> RegistryEntry)
108
+ 2. Match on repo part of owner/repo (e.g. "nushell" matches "nushell/nushell")
109
+
110
+ Args:
111
+ name: Short tool name to look up.
112
+ registry: Registry dict. Loads defaults if None.
113
+
114
+ Returns:
115
+ RegistryEntry, or None if not found.
116
+ """
117
+ if registry is None:
118
+ registry = load_registry()
119
+
120
+ lower = name.lower()
121
+
122
+ # 1. Exact key match (case-insensitive)
123
+ for key, entry in registry.items():
124
+ if key.lower() == lower:
125
+ return entry
126
+
127
+ # 2. Match on repo name part (last segment after /)
128
+ for entry in registry.values():
129
+ # Strip @prefix: if present to get the raw spec
130
+ raw = entry.spec.partition(":")[2] if ":" in entry.spec else entry.spec
131
+ if "/" in raw:
132
+ repo_name = raw.rsplit("/", 1)[1]
133
+ if repo_name.lower() == lower:
134
+ return entry
135
+
136
+ return None
137
+
138
+
139
+ def lookup_spec(name: str, registry: dict[str, RegistryEntry] | None = None) -> str | None:
140
+ """Resolve a short name to a spec string (e.g. ``@gh:BurntSushi/ripgrep``).
141
+
142
+ Convenience wrapper around :func:`lookup` that returns just the spec.
143
+ """
144
+ entry = lookup(name, registry)
145
+ return entry.spec if entry else None
ixt/config/settings.py ADDED
@@ -0,0 +1,173 @@
1
+ """Path management and global configuration for ixt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from ixt.platform import get_binary_extension
9
+
10
+
11
+ def get_ixt_home() -> Path:
12
+ """Get the ixt home directory.
13
+
14
+ Resolution order:
15
+
16
+ 1. ``$IXT_HOME`` if set — explicit user override.
17
+ 2. ``$XDG_DATA_HOME/ixt`` if ``XDG_DATA_HOME`` is set.
18
+ 3. ``~/.local/share/ixt`` otherwise (XDG default).
19
+
20
+ The home directory stores user-visible ixt state: ``config/`` and
21
+ ``installed/``. Regenerable cache content lives under
22
+ :func:`get_ixt_cache_home`.
23
+ """
24
+ if env_home := os.environ.get("IXT_HOME"):
25
+ return Path(env_home).expanduser()
26
+ if xdg_data := os.environ.get("XDG_DATA_HOME"):
27
+ return Path(xdg_data).expanduser() / "ixt"
28
+ return Path.home() / ".local" / "share" / "ixt"
29
+
30
+
31
+ def get_ixt_installed_dir() -> Path:
32
+ """Get the active installation root (``$IXT_HOME/installed``)."""
33
+ return get_ixt_home() / "installed"
34
+
35
+
36
+ def get_ixt_bin_dir() -> Path:
37
+ """Get the directory for exposed tool shims."""
38
+ if env_bin := os.environ.get("IXT_BIN_DIR"):
39
+ return Path(env_bin).expanduser()
40
+ return get_ixt_installed_dir() / "bin"
41
+
42
+
43
+ def get_ixt_envs_dir() -> Path:
44
+ """Get the directory containing isolated environments per tool."""
45
+ return get_ixt_installed_dir() / "envs"
46
+
47
+
48
+ def get_ixt_runtimes_dir() -> Path:
49
+ """Get the directory for bootstrapped runtimes (uv, bun, python)."""
50
+ return get_ixt_installed_dir() / "runtimes"
51
+
52
+
53
+ def get_ixt_config_dir() -> Path:
54
+ """Get the directory for user-editable ixt tool config files."""
55
+ return get_ixt_home() / "config"
56
+
57
+
58
+ def get_ixt_cache_home() -> Path:
59
+ """Get the ixt cache root.
60
+
61
+ Resolution order:
62
+
63
+ 1. ``$IXT_CACHE_HOME`` if set — explicit ixt override.
64
+ 2. ``$XDG_CACHE_HOME/ixt`` if ``XDG_CACHE_HOME`` is set.
65
+ 3. ``~/.cache/ixt`` otherwise (XDG default).
66
+ """
67
+ if env_cache := os.environ.get("IXT_CACHE_HOME"):
68
+ return Path(env_cache).expanduser()
69
+ if xdg_cache := os.environ.get("XDG_CACHE_HOME"):
70
+ return Path(xdg_cache).expanduser() / "ixt"
71
+ return Path.home() / ".cache" / "ixt"
72
+
73
+
74
+ def get_ixt_downloads_dir() -> Path:
75
+ """Get the cache directory for network artifacts."""
76
+ return get_ixt_cache_home() / "downloads"
77
+
78
+
79
+ def get_ixt_metadata_dir() -> Path:
80
+ """Get the cache directory for metadata and resolution state."""
81
+ return get_ixt_cache_home() / "metadata"
82
+
83
+
84
+ def get_ixt_tmp_dir() -> Path:
85
+ """Get the cache directory for ixt-owned temporary files."""
86
+ return get_ixt_cache_home() / "tmp"
87
+
88
+
89
+ def get_ixt_cache_dir() -> Path:
90
+ """Get the network-artifact cache directory.
91
+
92
+ Kept as the historical helper name for backend code that stores
93
+ downloaded release assets and fetched ``ixt.setup.toml`` files.
94
+ """
95
+ return get_ixt_downloads_dir()
96
+
97
+
98
+ class Settings:
99
+ """Central, backend-agnostic configuration for ixt."""
100
+
101
+ def __init__(self):
102
+ self.home = get_ixt_home()
103
+ self.installed_dir = get_ixt_installed_dir()
104
+ self.bin_dir = get_ixt_bin_dir()
105
+ self.envs_dir = get_ixt_envs_dir()
106
+ self.runtimes_dir = get_ixt_runtimes_dir()
107
+ self.config_dir = get_ixt_config_dir()
108
+ self.cache_home = get_ixt_cache_home()
109
+ self.downloads_dir = get_ixt_downloads_dir()
110
+ self.metadata_dir = get_ixt_metadata_dir()
111
+ self.tmp_dir = get_ixt_tmp_dir()
112
+ self.cache_dir = get_ixt_cache_dir()
113
+
114
+ def ensure_directories(self):
115
+ """Create all necessary ixt state and cache directories."""
116
+ for d in [
117
+ self.home,
118
+ self.config_dir,
119
+ self.installed_dir,
120
+ self.bin_dir,
121
+ self.envs_dir,
122
+ self.runtimes_dir,
123
+ self.cache_home,
124
+ self.downloads_dir,
125
+ self.metadata_dir,
126
+ self.tmp_dir,
127
+ self.cache_dir,
128
+ ]:
129
+ d.mkdir(parents=True, exist_ok=True)
130
+
131
+ @property
132
+ def uv_runtime(self) -> Path:
133
+ """Path to the bootstrapped uv binary."""
134
+ return self.runtimes_dir / f"uv{get_binary_extension()}"
135
+
136
+ @property
137
+ def bun_runtime(self) -> Path:
138
+ """Path to the bootstrapped bun binary."""
139
+ return self.runtimes_dir / f"bun{get_binary_extension()}"
140
+
141
+ def get_tool_env_dir(self, tool_name: str) -> Path:
142
+ """Get the isolated environment directory for a specific tool."""
143
+ safe = tool_name.replace("@", "").replace("/", "-")
144
+ return self.envs_dir / safe
145
+
146
+ def get_tool_metadata_file(self, tool_name: str) -> Path:
147
+ """Get the ixt.json metadata file for a tool."""
148
+ return self.get_tool_env_dir(tool_name) / "ixt.json"
149
+
150
+ def iter_installed_metadata(self) -> list[Path]:
151
+ """Return sorted list of ixt.json paths for all installed tools."""
152
+ if not self.envs_dir.exists():
153
+ return []
154
+ results = []
155
+ for entry in sorted(self.envs_dir.iterdir()):
156
+ if entry.name.startswith(".") or not entry.is_dir():
157
+ continue
158
+ meta = entry / "ixt.json"
159
+ if meta.exists():
160
+ results.append(meta)
161
+ return results
162
+
163
+
164
+ # Global settings singleton
165
+ _settings: Settings | None = None
166
+
167
+
168
+ def get_settings() -> Settings:
169
+ """Get or create the global Settings instance."""
170
+ global _settings
171
+ if _settings is None:
172
+ _settings = Settings()
173
+ return _settings
@@ -0,0 +1,179 @@
1
+ """Parser for ``ixt.setup.toml`` — metadata defined by tool authors.
2
+
3
+ A repo can contain an ``ixt.setup.toml`` at its root, which ixt reads
4
+ during installation to discover how the tool should be installed and
5
+ exposed. This avoids relying solely on heuristics or the built-in registry.
6
+
7
+ Priority: ixt.setup.toml > heuristic auto-detection.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ if TYPE_CHECKING:
18
+ from ixt.net.source import ReleaseSource
19
+
20
+ if sys.version_info >= (3, 11):
21
+ import tomllib
22
+
23
+ _loads = tomllib.loads
24
+ else:
25
+ from ixt.config.toml import _mini_toml_parse
26
+
27
+ _loads = _mini_toml_parse
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Data model
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class HookConfig:
37
+ """A lifecycle hook (post_install or pre_uninstall)."""
38
+
39
+ run: str | None = None
40
+ check: str | None = None
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class SetupConfig:
45
+ """Parsed ``ixt.setup.toml`` from a tool repo."""
46
+
47
+ name: str | None = None
48
+ expose: list[str] = field(default_factory=list)
49
+ description: str | None = None
50
+ asset_pattern: str | None = None
51
+
52
+ completions: dict[str, str] = field(default_factory=dict)
53
+ man: dict[str, str] = field(default_factory=dict)
54
+ meta: dict[str, Any] = field(default_factory=dict)
55
+
56
+ post_install: HookConfig = field(default_factory=HookConfig)
57
+ pre_uninstall: HookConfig = field(default_factory=HookConfig)
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Parsing
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ def _parse_toml_string(text: str) -> dict[str, Any]:
66
+ """Parse a TOML string using stdlib (3.11+) or fallback parser."""
67
+ return _loads(text)
68
+
69
+
70
+ def parse_setup_toml(text: str) -> SetupConfig:
71
+ """Parse the text content of an ``ixt.setup.toml`` file.
72
+
73
+ The file is expected to have an ``[ixt]`` top-level table.
74
+ Returns a SetupConfig with all recognized fields, or an empty
75
+ SetupConfig if the ``[ixt]`` section is absent.
76
+ """
77
+ data = _parse_toml_string(text)
78
+ ixt = data.get("ixt", {})
79
+ if not isinstance(ixt, dict):
80
+ return SetupConfig()
81
+
82
+ expose_raw = ixt.get("expose")
83
+ if isinstance(expose_raw, str):
84
+ expose = [expose_raw]
85
+ elif isinstance(expose_raw, list):
86
+ expose = [str(e) for e in expose_raw]
87
+ else:
88
+ expose = []
89
+
90
+ completions = ixt.get("completions", {})
91
+ if not isinstance(completions, dict):
92
+ completions = {}
93
+
94
+ man = ixt.get("man", {})
95
+ if not isinstance(man, dict):
96
+ man = {}
97
+
98
+ meta = ixt.get("meta", {})
99
+ if not isinstance(meta, dict):
100
+ meta = {}
101
+
102
+ post_install = _parse_hook(ixt.get("post_install"))
103
+ pre_uninstall = _parse_hook(ixt.get("pre_uninstall"))
104
+
105
+ return SetupConfig(
106
+ name=ixt.get("name"),
107
+ expose=expose,
108
+ description=ixt.get("description"),
109
+ asset_pattern=ixt.get("asset_pattern"),
110
+ completions=completions,
111
+ man=man,
112
+ meta=meta,
113
+ post_install=post_install,
114
+ pre_uninstall=pre_uninstall,
115
+ )
116
+
117
+
118
+ def _parse_hook(raw: Any) -> HookConfig:
119
+ """Parse a hook section (dict with optional run/check keys)."""
120
+ if not isinstance(raw, dict):
121
+ return HookConfig()
122
+ return HookConfig(
123
+ run=raw.get("run"),
124
+ check=raw.get("check"),
125
+ )
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Fetch and cache
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ def load_local_setup_toml(path: Path) -> SetupConfig:
134
+ """Load an ``ixt.setup.toml`` from a local file path.
135
+
136
+ Raises FileNotFoundError if *path* does not exist.
137
+ """
138
+ text = path.read_text(encoding="utf-8")
139
+ return parse_setup_toml(text)
140
+
141
+
142
+ def fetch_setup_toml(
143
+ source: ReleaseSource,
144
+ owner: str,
145
+ repo: str,
146
+ *,
147
+ cache_dir: Path | None = None,
148
+ ref: str | None = None,
149
+ ) -> SetupConfig | None:
150
+ """Fetch ``ixt.setup.toml`` from the repo and parse it.
151
+
152
+ Uses the ReleaseSource's ``get_file_content()`` method. When *ref* is
153
+ given, the file is fetched at that specific revision (tag) so the
154
+ config stays coherent with the installed release.
155
+ Caches the result to disk if *cache_dir* is given.
156
+ Returns None if the file doesn't exist (404).
157
+ """
158
+ # Check cache first
159
+ if cache_dir is not None:
160
+ cached = cache_dir / "setup.toml"
161
+ try:
162
+ text = cached.read_text(encoding="utf-8")
163
+ if text:
164
+ return parse_setup_toml(text)
165
+ except FileNotFoundError:
166
+ pass
167
+
168
+ # Fetch from repo
169
+ text = source.get_file_content(owner, repo, "ixt.setup.toml", ref=ref)
170
+ if text is None:
171
+ return None
172
+
173
+ # Cache for later
174
+ if cache_dir is not None:
175
+ cache_dir.mkdir(parents=True, exist_ok=True)
176
+ cached = cache_dir / "setup.toml"
177
+ cached.write_text(text, encoding="utf-8")
178
+
179
+ return parse_setup_toml(text)