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/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)