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/core/apply.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""Apply ixt.toml configuration — sync installed tools with declared config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
from ixt.config.models import ToolRecord
|
|
10
|
+
from ixt.config.settings import Settings, get_settings
|
|
11
|
+
from ixt.config.toml import IxtConfig, ToolSpec, find_config_file, load_config
|
|
12
|
+
from ixt.core.apply_actions import (
|
|
13
|
+
ApplyPlan,
|
|
14
|
+
ApplyResult,
|
|
15
|
+
InjectAction,
|
|
16
|
+
InstallAction,
|
|
17
|
+
ReexposeAction,
|
|
18
|
+
ReinstallAction,
|
|
19
|
+
RepolicyAction,
|
|
20
|
+
UninjectAction,
|
|
21
|
+
)
|
|
22
|
+
from ixt.core.backend import BackendType
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ApplyPlan",
|
|
26
|
+
"ApplyResult",
|
|
27
|
+
"InjectAction",
|
|
28
|
+
"InstallAction",
|
|
29
|
+
"ReexposeAction",
|
|
30
|
+
"ReinstallAction",
|
|
31
|
+
"RemovalsRefused",
|
|
32
|
+
"RepolicyAction",
|
|
33
|
+
"UninjectAction",
|
|
34
|
+
"apply_config",
|
|
35
|
+
"plan_apply",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
ApplyAction = (
|
|
39
|
+
InstallAction
|
|
40
|
+
| ReinstallAction
|
|
41
|
+
| InjectAction
|
|
42
|
+
| UninjectAction
|
|
43
|
+
| ReexposeAction
|
|
44
|
+
| RepolicyAction
|
|
45
|
+
| str
|
|
46
|
+
)
|
|
47
|
+
ApplyProgress = Callable[[str, str, ApplyAction, int, int, ApplyResult, Exception | None], None]
|
|
48
|
+
_T = TypeVar("_T")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RemovalsRefused(Exception):
|
|
52
|
+
"""Raised when a ``confirm_removals`` callback declines the removals."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _spec_to_install_spec(spec: ToolSpec) -> str:
|
|
56
|
+
"""Build the package spec string from a ToolSpec (name + optional version).
|
|
57
|
+
|
|
58
|
+
The ``[tools]`` key is the install spec unless ``install`` is set. Version
|
|
59
|
+
constraints stay as a separate field so exported TOML remains easy to scan.
|
|
60
|
+
"""
|
|
61
|
+
install = spec.install or spec.name
|
|
62
|
+
if not spec.version:
|
|
63
|
+
return install
|
|
64
|
+
|
|
65
|
+
from ixt.core.backend import detect_backend
|
|
66
|
+
|
|
67
|
+
backend = detect_backend(install)
|
|
68
|
+
version = spec.version.strip()
|
|
69
|
+
if backend == BackendType.BINARY:
|
|
70
|
+
return f"{install}@{version.lstrip('=')}"
|
|
71
|
+
if backend == BackendType.NODE:
|
|
72
|
+
npm_version = version[2:] if version.startswith("==") else version
|
|
73
|
+
return f"{install}@{npm_version}"
|
|
74
|
+
return f"{install}{version}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _pinned_version(spec_version: str | None) -> str | None:
|
|
78
|
+
"""Return the concrete pinned version if the spec pins exactly (``==X.Y.Z``).
|
|
79
|
+
|
|
80
|
+
Ranges (``>=``, ``~=``, ``<``, etc.) never return a pinned version — they
|
|
81
|
+
describe a set of acceptable versions, not one.
|
|
82
|
+
"""
|
|
83
|
+
if not spec_version:
|
|
84
|
+
return None
|
|
85
|
+
if spec_version.startswith("=="):
|
|
86
|
+
return spec_version[2:].strip() or None
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _policy_drifted(spec: ToolSpec, record: ToolRecord) -> bool:
|
|
91
|
+
return (
|
|
92
|
+
spec.env_base != record.env_base
|
|
93
|
+
or spec.env_allow != record.env_allow
|
|
94
|
+
or spec.env_deny != record.env_deny
|
|
95
|
+
or spec.fs_base != record.fs_base
|
|
96
|
+
or spec.fs_ro != record.fs_ro
|
|
97
|
+
or spec.fs_rw != record.fs_rw
|
|
98
|
+
or spec.fs_scratch != record.fs_scratch
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _version_drifted(spec: ToolSpec, record: ToolRecord) -> bool:
|
|
103
|
+
"""True if the declared constraint is not satisfied by the installed version.
|
|
104
|
+
|
|
105
|
+
- No version constraint → never drifts.
|
|
106
|
+
- Exact pin (``==X.Y.Z``) → drift if installed differs.
|
|
107
|
+
- Range (``>=``, ``<``, ``~=``, ``!=``, …) → drift if installed is outside.
|
|
108
|
+
"""
|
|
109
|
+
from ixt.libs.semver import version_satisfies
|
|
110
|
+
|
|
111
|
+
if not spec.version:
|
|
112
|
+
return False
|
|
113
|
+
installed = record.version or ""
|
|
114
|
+
if not installed:
|
|
115
|
+
return True
|
|
116
|
+
return not version_satisfies(installed, spec.version)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _inject_drifted(spec: ToolSpec, record: ToolRecord) -> bool:
|
|
120
|
+
"""True if the declared inject set differs from what's installed."""
|
|
121
|
+
return sorted(spec.inject) != sorted(record.injected)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _expose_drifted(spec: ToolSpec, record: ToolRecord) -> bool:
|
|
125
|
+
"""True if expose rules differ."""
|
|
126
|
+
return sorted(spec.expose) != sorted(record.expose_rules)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _get_installed_tools(settings: Settings) -> dict[str, ToolRecord]:
|
|
130
|
+
"""Return a dict of tool_name → ToolRecord for all installed tools."""
|
|
131
|
+
result: dict[str, ToolRecord] = {}
|
|
132
|
+
for meta_file in settings.iter_installed_metadata():
|
|
133
|
+
record = ToolRecord.load_json(meta_file)
|
|
134
|
+
result[record.name] = record
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _append_install(plan: ApplyPlan, name: str, spec: ToolSpec) -> None:
|
|
139
|
+
plan.to_install.append(
|
|
140
|
+
InstallAction(
|
|
141
|
+
name=name,
|
|
142
|
+
install_spec=_spec_to_install_spec(spec),
|
|
143
|
+
backend=None,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _append_reinstall(
|
|
149
|
+
plan: ApplyPlan,
|
|
150
|
+
name: str,
|
|
151
|
+
spec: ToolSpec,
|
|
152
|
+
record: ToolRecord,
|
|
153
|
+
*,
|
|
154
|
+
inject_drift: bool,
|
|
155
|
+
) -> None:
|
|
156
|
+
reasons = ["version"]
|
|
157
|
+
if inject_drift:
|
|
158
|
+
reasons.append("inject")
|
|
159
|
+
plan.to_reinstall.append(
|
|
160
|
+
ReinstallAction(
|
|
161
|
+
name=name,
|
|
162
|
+
install_spec=_spec_to_install_spec(spec),
|
|
163
|
+
backend=None,
|
|
164
|
+
old_version=record.version,
|
|
165
|
+
pinned_version=_pinned_version(spec.version),
|
|
166
|
+
old_inject=list(record.injected),
|
|
167
|
+
new_inject=list(spec.inject),
|
|
168
|
+
expose_rules=list(spec.expose),
|
|
169
|
+
reasons=reasons,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _append_inject_diff(plan: ApplyPlan, name: str, spec: ToolSpec, record: ToolRecord) -> None:
|
|
175
|
+
old_set = set(record.injected)
|
|
176
|
+
new_set = set(spec.inject)
|
|
177
|
+
to_add = sorted(new_set - old_set)
|
|
178
|
+
to_remove = sorted(old_set - new_set)
|
|
179
|
+
if to_add:
|
|
180
|
+
plan.to_inject.append(InjectAction(name=name, packages=to_add))
|
|
181
|
+
if to_remove:
|
|
182
|
+
plan.to_uninject.append(UninjectAction(name=name, packages=to_remove))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _plan_one_tool(
|
|
186
|
+
plan: ApplyPlan,
|
|
187
|
+
name: str,
|
|
188
|
+
spec: ToolSpec,
|
|
189
|
+
installed: dict[str, ToolRecord],
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Classify one tool into the appropriate plan bucket."""
|
|
192
|
+
if name not in installed:
|
|
193
|
+
_append_install(plan, name, spec)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
record = installed[name]
|
|
197
|
+
version_drift = _version_drifted(spec, record)
|
|
198
|
+
inject_drift = _inject_drifted(spec, record)
|
|
199
|
+
expose_drift = _expose_drifted(spec, record)
|
|
200
|
+
|
|
201
|
+
if version_drift:
|
|
202
|
+
# A full reinstall atomically covers any concurrent inject/expose
|
|
203
|
+
# drift — simpler than coordinating multiple sub-operations.
|
|
204
|
+
_append_reinstall(plan, name, spec, record, inject_drift=inject_drift)
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
if inject_drift:
|
|
208
|
+
_append_inject_diff(plan, name, spec, record)
|
|
209
|
+
|
|
210
|
+
if expose_drift:
|
|
211
|
+
plan.to_reexpose.append(
|
|
212
|
+
ReexposeAction(
|
|
213
|
+
name=name,
|
|
214
|
+
old_rules=list(record.expose_rules),
|
|
215
|
+
new_rules=list(spec.expose),
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if _policy_drifted(spec, record):
|
|
220
|
+
plan.to_repolicy.append(RepolicyAction(name=name))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def plan_apply(
|
|
224
|
+
config: IxtConfig,
|
|
225
|
+
installed: dict[str, ToolRecord],
|
|
226
|
+
*,
|
|
227
|
+
remove_unlisted: bool = False,
|
|
228
|
+
) -> ApplyPlan:
|
|
229
|
+
"""Compute the diff between config and installed state.
|
|
230
|
+
|
|
231
|
+
Pure function: reads no filesystem, performs no network, no mutation.
|
|
232
|
+
The caller provides already-loaded *config* and *installed* records.
|
|
233
|
+
"""
|
|
234
|
+
plan = ApplyPlan()
|
|
235
|
+
|
|
236
|
+
for name, spec in config.tools.items():
|
|
237
|
+
_plan_one_tool(plan, name, spec, installed)
|
|
238
|
+
|
|
239
|
+
if remove_unlisted:
|
|
240
|
+
for name in installed:
|
|
241
|
+
if name not in config.tools:
|
|
242
|
+
plan.to_remove.append(name)
|
|
243
|
+
|
|
244
|
+
return plan
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _normalize_config_for_apply(config: IxtConfig, settings: Settings) -> IxtConfig:
|
|
248
|
+
"""Resolve user-facing TOML keys to installed ids before planning."""
|
|
249
|
+
tools: dict[str, ToolSpec] = {}
|
|
250
|
+
source_keys: dict[str, str] = {}
|
|
251
|
+
for name, spec in config.tools.items():
|
|
252
|
+
tool_id, normalized = _normalize_tool_entry(name, spec, settings)
|
|
253
|
+
existing_key = source_keys.get(tool_id)
|
|
254
|
+
if existing_key is not None:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"Config entries '{existing_key}' and '{name}' resolve to the same id '{tool_id}'"
|
|
257
|
+
)
|
|
258
|
+
tools[tool_id] = normalized
|
|
259
|
+
source_keys[tool_id] = name
|
|
260
|
+
return IxtConfig(tools=tools, settings=dict(config.settings), source_path=config.source_path)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _normalize_tool_entry(
|
|
264
|
+
name: str,
|
|
265
|
+
spec: ToolSpec,
|
|
266
|
+
settings: Settings,
|
|
267
|
+
) -> tuple[str, ToolSpec]:
|
|
268
|
+
"""Return the target installed id and an executable ToolSpec.
|
|
269
|
+
|
|
270
|
+
Human-written entries use the package spec as key (``ruff = {}``,
|
|
271
|
+
``"@gh:owner/repo" = {}``). Entries with ``install`` use the key as
|
|
272
|
+
a slot, e.g. ``ruff-old = { install = "@pypi:ruff" }``.
|
|
273
|
+
"""
|
|
274
|
+
from ixt.core.identity import apply_slot, validate_slot
|
|
275
|
+
from ixt.core.install import plan_install
|
|
276
|
+
|
|
277
|
+
install_spec = _spec_to_install_spec(spec)
|
|
278
|
+
base_id = plan_install(install_spec, settings=settings).tool_name
|
|
279
|
+
if spec.install:
|
|
280
|
+
tool_id = apply_slot(base_id, validate_slot(name))
|
|
281
|
+
return tool_id, spec
|
|
282
|
+
return base_id, spec
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def apply_config(
|
|
286
|
+
config: IxtConfig | None = None,
|
|
287
|
+
config_path: Path | None = None,
|
|
288
|
+
*,
|
|
289
|
+
remove_unlisted: bool = False,
|
|
290
|
+
settings: Settings | None = None,
|
|
291
|
+
confirm_removals: Callable[[list[str]], bool] | None = None,
|
|
292
|
+
progress: ApplyProgress | None = None,
|
|
293
|
+
) -> ApplyResult:
|
|
294
|
+
"""Sync installed tools to match ixt.toml.
|
|
295
|
+
|
|
296
|
+
Parameters
|
|
297
|
+
----------
|
|
298
|
+
config:
|
|
299
|
+
Parsed config. If None, loaded from *config_path* or auto-discovered.
|
|
300
|
+
config_path:
|
|
301
|
+
Explicit path to ixt.toml. Ignored if *config* is given.
|
|
302
|
+
remove_unlisted:
|
|
303
|
+
If True, uninstall tools not listed in config.
|
|
304
|
+
settings:
|
|
305
|
+
Custom settings (for testing).
|
|
306
|
+
confirm_removals:
|
|
307
|
+
Optional callback invoked with the list of tools about to be
|
|
308
|
+
uninstalled. If it returns ``False``, ``RemovalsRefused`` is raised
|
|
309
|
+
and no mutation occurs.
|
|
310
|
+
progress:
|
|
311
|
+
Optional callback invoked before and after every planned action.
|
|
312
|
+
The first argument is ``"start"`` or ``"done"``.
|
|
313
|
+
"""
|
|
314
|
+
settings = settings or get_settings()
|
|
315
|
+
result = ApplyResult()
|
|
316
|
+
|
|
317
|
+
if config is None:
|
|
318
|
+
path = config_path or find_config_file()
|
|
319
|
+
if path is None:
|
|
320
|
+
raise FileNotFoundError("No ixt.toml found")
|
|
321
|
+
config = load_config(path)
|
|
322
|
+
|
|
323
|
+
installed = _get_installed_tools(settings)
|
|
324
|
+
config = _normalize_config_for_apply(config, settings)
|
|
325
|
+
plan = plan_apply(config, installed, remove_unlisted=remove_unlisted)
|
|
326
|
+
|
|
327
|
+
if (
|
|
328
|
+
plan.to_remove
|
|
329
|
+
and confirm_removals is not None
|
|
330
|
+
and not confirm_removals(list(plan.to_remove))
|
|
331
|
+
):
|
|
332
|
+
raise RemovalsRefused()
|
|
333
|
+
|
|
334
|
+
actions = _iter_plan_actions(plan)
|
|
335
|
+
total = len(actions)
|
|
336
|
+
for index, (kind, action) in enumerate(actions, 1):
|
|
337
|
+
if progress is not None:
|
|
338
|
+
progress("start", kind, action, index, total, result, None)
|
|
339
|
+
error = _execute_action(kind, action, config, installed, settings, result)
|
|
340
|
+
if progress is not None:
|
|
341
|
+
progress("done", kind, action, index, total, result, error)
|
|
342
|
+
|
|
343
|
+
return result
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _iter_plan_actions(plan: ApplyPlan) -> list[tuple[str, ApplyAction]]:
|
|
347
|
+
actions: list[tuple[str, ApplyAction]] = []
|
|
348
|
+
actions.extend(("install", action) for action in plan.to_install)
|
|
349
|
+
actions.extend(("reinstall", action) for action in plan.to_reinstall)
|
|
350
|
+
actions.extend(("inject", action) for action in plan.to_inject)
|
|
351
|
+
actions.extend(("uninject", action) for action in plan.to_uninject)
|
|
352
|
+
actions.extend(("re-expose", action) for action in plan.to_reexpose)
|
|
353
|
+
actions.extend(("policy", action) for action in plan.to_repolicy)
|
|
354
|
+
actions.extend(("remove", name) for name in plan.to_remove)
|
|
355
|
+
return actions
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _expect_action(kind: str, action: ApplyAction, expected: type[_T]) -> _T:
|
|
359
|
+
if not isinstance(action, expected):
|
|
360
|
+
raise TypeError(
|
|
361
|
+
f"apply action {kind!r} expected {expected.__name__}, got {type(action).__name__}"
|
|
362
|
+
)
|
|
363
|
+
return action
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _execute_action(
|
|
367
|
+
kind: str,
|
|
368
|
+
action: ApplyAction,
|
|
369
|
+
config: IxtConfig,
|
|
370
|
+
installed: dict[str, ToolRecord],
|
|
371
|
+
settings: Settings,
|
|
372
|
+
result: ApplyResult,
|
|
373
|
+
) -> Exception | None:
|
|
374
|
+
if kind == "install":
|
|
375
|
+
action = _expect_action(kind, action, InstallAction)
|
|
376
|
+
return _execute_install(action, config.tools[action.name], settings, result)
|
|
377
|
+
if kind == "reinstall":
|
|
378
|
+
action = _expect_action(kind, action, ReinstallAction)
|
|
379
|
+
return _execute_reinstall(action, config.tools[action.name], settings, result)
|
|
380
|
+
if kind == "inject":
|
|
381
|
+
action = _expect_action(kind, action, InjectAction)
|
|
382
|
+
return _execute_inject(action, settings, result)
|
|
383
|
+
if kind == "uninject":
|
|
384
|
+
action = _expect_action(kind, action, UninjectAction)
|
|
385
|
+
return _execute_uninject(action, settings, result)
|
|
386
|
+
if kind == "re-expose":
|
|
387
|
+
action = _expect_action(kind, action, ReexposeAction)
|
|
388
|
+
return _execute_reexpose(action, installed[action.name], settings, result)
|
|
389
|
+
if kind == "policy":
|
|
390
|
+
action = _expect_action(kind, action, RepolicyAction)
|
|
391
|
+
return _execute_repolicy(
|
|
392
|
+
action, config.tools[action.name], installed[action.name], settings, result
|
|
393
|
+
)
|
|
394
|
+
if kind == "remove":
|
|
395
|
+
action = _expect_action(kind, action, str)
|
|
396
|
+
return _execute_remove(action, settings, result)
|
|
397
|
+
raise ValueError(f"unknown apply action kind: {kind!r}")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _apply_spec_policy(name: str, spec: ToolSpec, settings: Settings) -> None:
|
|
401
|
+
"""Write policy fields from spec into the installed record and regenerate shim."""
|
|
402
|
+
from ixt.config.env_policy import apply_policy
|
|
403
|
+
|
|
404
|
+
meta_file = settings.get_tool_metadata_file(name)
|
|
405
|
+
if not meta_file.exists():
|
|
406
|
+
return
|
|
407
|
+
record = ToolRecord.load_json(meta_file)
|
|
408
|
+
record.env_base = spec.env_base
|
|
409
|
+
record.env_allow = list(spec.env_allow)
|
|
410
|
+
record.env_deny = dict(spec.env_deny)
|
|
411
|
+
record.fs_base = spec.fs_base
|
|
412
|
+
record.fs_ro = list(spec.fs_ro)
|
|
413
|
+
record.fs_rw = list(spec.fs_rw)
|
|
414
|
+
record.fs_scratch = list(spec.fs_scratch)
|
|
415
|
+
record.save_json(meta_file)
|
|
416
|
+
if record.exposed_bins:
|
|
417
|
+
apply_policy(record, settings.bin_dir)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _execute_install(
|
|
421
|
+
action: InstallAction, spec: ToolSpec, settings: Settings, result: ApplyResult
|
|
422
|
+
) -> Exception | None:
|
|
423
|
+
from ixt.core.install import install_tool
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
install_tool(
|
|
427
|
+
action.install_spec,
|
|
428
|
+
inject=spec.inject or None,
|
|
429
|
+
expose_rules=spec.expose,
|
|
430
|
+
slot=_slot_for_config_key(action.name, action.install_spec, spec, settings),
|
|
431
|
+
runtime=spec.runtime,
|
|
432
|
+
node_shim=spec.node_shim,
|
|
433
|
+
asset_pattern=spec.asset_pattern,
|
|
434
|
+
settings=settings,
|
|
435
|
+
)
|
|
436
|
+
_apply_spec_policy(action.name, spec, settings)
|
|
437
|
+
result.installed.append(action.name)
|
|
438
|
+
return None
|
|
439
|
+
except Exception as exc:
|
|
440
|
+
result.errors[action.name] = exc
|
|
441
|
+
return exc
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _execute_reinstall(
|
|
445
|
+
action: ReinstallAction, spec: ToolSpec, settings: Settings, result: ApplyResult
|
|
446
|
+
) -> Exception | None:
|
|
447
|
+
from ixt.core.install import install_tool
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
install_tool(
|
|
451
|
+
action.install_spec,
|
|
452
|
+
inject=spec.inject or None,
|
|
453
|
+
expose_rules=spec.expose,
|
|
454
|
+
slot=_slot_for_config_key(action.name, action.install_spec, spec, settings),
|
|
455
|
+
force=True,
|
|
456
|
+
runtime=spec.runtime,
|
|
457
|
+
node_shim=spec.node_shim,
|
|
458
|
+
asset_pattern=spec.asset_pattern,
|
|
459
|
+
settings=settings,
|
|
460
|
+
)
|
|
461
|
+
_apply_spec_policy(action.name, spec, settings)
|
|
462
|
+
result.updated.append(action.name)
|
|
463
|
+
return None
|
|
464
|
+
except Exception as exc:
|
|
465
|
+
result.errors[action.name] = exc
|
|
466
|
+
return exc
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _slot_for_config_key(
|
|
470
|
+
name: str,
|
|
471
|
+
install_spec: str,
|
|
472
|
+
spec: ToolSpec,
|
|
473
|
+
settings: Settings,
|
|
474
|
+
) -> str | None:
|
|
475
|
+
"""Return the slot needed to materialize a configured slotted entry."""
|
|
476
|
+
if not spec.install:
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
from ixt.core.identity import slot_from_id
|
|
480
|
+
from ixt.core.install import plan_install
|
|
481
|
+
|
|
482
|
+
base_id = plan_install(install_spec, settings=settings).tool_name
|
|
483
|
+
if name == base_id:
|
|
484
|
+
return None
|
|
485
|
+
slot = slot_from_id(name, base_id)
|
|
486
|
+
if slot is None:
|
|
487
|
+
configured_spec = spec.install or spec.name
|
|
488
|
+
raise ValueError(
|
|
489
|
+
f"Config id '{name}' does not match install spec '{configured_spec}' "
|
|
490
|
+
f"(expected '{base_id}' or a slotted id ending with '.{base_id}')"
|
|
491
|
+
)
|
|
492
|
+
return slot
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _execute_repolicy(
|
|
496
|
+
action: RepolicyAction,
|
|
497
|
+
spec: ToolSpec,
|
|
498
|
+
record: ToolRecord,
|
|
499
|
+
settings: Settings,
|
|
500
|
+
result: ApplyResult,
|
|
501
|
+
) -> Exception | None:
|
|
502
|
+
try:
|
|
503
|
+
_apply_spec_policy(action.name, spec, settings)
|
|
504
|
+
result.updated.append(action.name)
|
|
505
|
+
return None
|
|
506
|
+
except Exception as exc:
|
|
507
|
+
result.errors[action.name] = exc
|
|
508
|
+
return exc
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _execute_inject(
|
|
512
|
+
action: InjectAction, settings: Settings, result: ApplyResult
|
|
513
|
+
) -> Exception | None:
|
|
514
|
+
from ixt.core.inject import inject_packages
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
inject_packages(action.name, action.packages, settings=settings)
|
|
518
|
+
result.updated.append(action.name)
|
|
519
|
+
return None
|
|
520
|
+
except Exception as exc:
|
|
521
|
+
result.errors[action.name] = exc
|
|
522
|
+
return exc
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _execute_uninject(
|
|
526
|
+
action: UninjectAction, settings: Settings, result: ApplyResult
|
|
527
|
+
) -> Exception | None:
|
|
528
|
+
from ixt.core.inject import uninject_packages
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
uninject_packages(action.name, action.packages, settings=settings)
|
|
532
|
+
if action.name not in result.updated:
|
|
533
|
+
result.updated.append(action.name)
|
|
534
|
+
return None
|
|
535
|
+
except Exception as exc:
|
|
536
|
+
result.errors[action.name] = exc
|
|
537
|
+
return exc
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _execute_reexpose(
|
|
541
|
+
action: ReexposeAction, record: ToolRecord, settings: Settings, result: ApplyResult
|
|
542
|
+
) -> Exception | None:
|
|
543
|
+
from ixt.core.expose import reexpose_tool
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
reexpose_tool(record, action.new_rules, settings)
|
|
547
|
+
if action.name not in result.updated:
|
|
548
|
+
result.updated.append(action.name)
|
|
549
|
+
return None
|
|
550
|
+
except Exception as exc:
|
|
551
|
+
result.errors[action.name] = exc
|
|
552
|
+
return exc
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _execute_remove(name: str, settings: Settings, result: ApplyResult) -> Exception | None:
|
|
556
|
+
from ixt.core.install import uninstall_tool
|
|
557
|
+
|
|
558
|
+
try:
|
|
559
|
+
uninstall_tool(name, settings=settings)
|
|
560
|
+
result.removed.append(name)
|
|
561
|
+
return None
|
|
562
|
+
except Exception as exc:
|
|
563
|
+
result.errors[name] = exc
|
|
564
|
+
return exc
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Action dataclasses produced by ``plan_apply`` and consumed by ``apply_config``.
|
|
2
|
+
|
|
3
|
+
Pure data — no behavior, no I/O. Kept apart from ``apply.py`` so the planner
|
|
4
|
+
and executor stay below the file-size threshold and the dataclasses can be
|
|
5
|
+
imported without dragging in the apply pipeline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class InstallAction:
|
|
15
|
+
"""A tool in ixt.toml that is not yet installed."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
install_spec: str
|
|
19
|
+
backend: str | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class ReinstallAction:
|
|
24
|
+
"""A tool whose version and/or inject set drifted from ixt.toml.
|
|
25
|
+
|
|
26
|
+
A full reinstall is triggered because it covers version, inject add,
|
|
27
|
+
inject remove, and expose changes in one go — simpler than coordinating
|
|
28
|
+
upgrade/inject/uninject separately.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
install_spec: str
|
|
33
|
+
backend: str | None
|
|
34
|
+
old_version: str | None
|
|
35
|
+
pinned_version: str | None
|
|
36
|
+
old_inject: list[str]
|
|
37
|
+
new_inject: list[str]
|
|
38
|
+
expose_rules: list[str]
|
|
39
|
+
reasons: list[str] # subset of {"version", "inject"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class ReexposeAction:
|
|
44
|
+
"""A tool whose only drift is its expose rules (cheaper than reinstall)."""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
old_rules: list[str]
|
|
48
|
+
new_rules: list[str]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class InjectAction:
|
|
53
|
+
"""Add packages to an installed tool's inject set (no version drift)."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
packages: list[str]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class UninjectAction:
|
|
61
|
+
"""Remove packages from an installed tool's inject set (no version drift)."""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
packages: list[str]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True)
|
|
68
|
+
class RepolicyAction:
|
|
69
|
+
"""A tool whose policy drifted from ixt.toml (no reinstall needed)."""
|
|
70
|
+
|
|
71
|
+
name: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(slots=True)
|
|
75
|
+
class ApplyPlan:
|
|
76
|
+
"""Diff between ixt.toml and installed tools — zero side effects."""
|
|
77
|
+
|
|
78
|
+
to_install: list[InstallAction] = field(default_factory=list)
|
|
79
|
+
to_reinstall: list[ReinstallAction] = field(default_factory=list)
|
|
80
|
+
to_inject: list[InjectAction] = field(default_factory=list)
|
|
81
|
+
to_uninject: list[UninjectAction] = field(default_factory=list)
|
|
82
|
+
to_reexpose: list[ReexposeAction] = field(default_factory=list)
|
|
83
|
+
to_repolicy: list[RepolicyAction] = field(default_factory=list)
|
|
84
|
+
to_remove: list[str] = field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def is_empty(self) -> bool:
|
|
88
|
+
return not (
|
|
89
|
+
self.to_install
|
|
90
|
+
or self.to_reinstall
|
|
91
|
+
or self.to_inject
|
|
92
|
+
or self.to_uninject
|
|
93
|
+
or self.to_reexpose
|
|
94
|
+
or self.to_repolicy
|
|
95
|
+
or self.to_remove
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(slots=True)
|
|
100
|
+
class ApplyResult:
|
|
101
|
+
"""Summary of an executed apply operation."""
|
|
102
|
+
|
|
103
|
+
installed: list[str] = field(default_factory=list)
|
|
104
|
+
removed: list[str] = field(default_factory=list)
|
|
105
|
+
updated: list[str] = field(default_factory=list)
|
|
106
|
+
errors: dict[str, Exception] = field(default_factory=dict)
|