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/install.py ADDED
@@ -0,0 +1,509 @@
1
+ """Backend-agnostic tool installation and removal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from ixt.backends.binary import SetupMetadata
12
+
13
+ from ixt.config.models import ToolRecord
14
+ from ixt.config.settings import Settings, get_settings
15
+ from ixt.core.backend import BackendType, detect_backend, get_backend, strip_protocol
16
+ from ixt.core.expose import expose_tool, unexpose_tool
17
+ from ixt.core.identity import (
18
+ display_spec_for_record,
19
+ id_prefixes,
20
+ is_id_selector,
21
+ make_tool_id,
22
+ parse_id_selector,
23
+ validate_slot,
24
+ )
25
+ from ixt.libs.constants import EXPOSE_MAIN
26
+ from ixt.libs.req_spec import parse_requirement
27
+ from ixt.net.source import parse_spec, strip_version_suffix
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class InstallPlan:
32
+ """What ``install_tool`` would do for a given spec, without executing."""
33
+
34
+ backend: BackendType
35
+ original_spec: str
36
+ resolved_spec: str
37
+ pkg_name: str
38
+ tool_name: str
39
+ env_dir: Path
40
+ expose_rules: list[str]
41
+ inject: list[str]
42
+ already_installed: bool
43
+ setup_path: Path | None
44
+
45
+
46
+ def _read_binary_hooks(env_dir: Path) -> SetupMetadata | None:
47
+ """Read hook config from binary backend metadata, if present."""
48
+ meta_path = env_dir / "binary_metadata.json"
49
+ if not meta_path.exists():
50
+ return None
51
+ with meta_path.open(encoding="utf-8") as f:
52
+ meta = json.load(f)
53
+ return meta.get("setup")
54
+
55
+
56
+ def _first_bin_path(exposed_bins: dict[str, str]) -> str | None:
57
+ """Return the path of the first exposed binary (used as ``{bin}``)."""
58
+ if not exposed_bins:
59
+ return None
60
+ return next(iter(exposed_bins.values()))
61
+
62
+
63
+ def _run_hook(env_dir: Path, exposed_bins: dict[str, str], hook_key: str) -> None:
64
+ """Run a lifecycle hook from binary metadata if defined."""
65
+ setup = _read_binary_hooks(env_dir)
66
+ if not setup or hook_key not in setup:
67
+ return
68
+
69
+ from ixt.config.setup_toml import HookConfig
70
+ from ixt.core import hooks as hook_mod
71
+ from ixt.libs.logger import get_logger
72
+
73
+ log = get_logger("hooks")
74
+ hook_data = setup[hook_key]
75
+ hook = HookConfig(run=hook_data.get("run"), check=hook_data.get("check"))
76
+ bin_path = _first_bin_path(exposed_bins)
77
+ if not bin_path:
78
+ return
79
+
80
+ runner = getattr(hook_mod, f"run_{hook_key}")
81
+ try:
82
+ runner(hook, bin_path=bin_path, env_dir=env_dir)
83
+ except hook_mod.HookValidationError as e:
84
+ log.warn(f"{hook_key} hook rejected: {e}")
85
+
86
+
87
+ class ToolAlreadyInstalledError(RuntimeError):
88
+ """Raised when a tool is already installed and ``force`` is False."""
89
+
90
+ def __init__(self, tool_name: str, env_dir: Path):
91
+ self.tool_name = tool_name
92
+ self.env_dir = env_dir
93
+ super().__init__(f"Tool '{tool_name}' is already installed in {env_dir}")
94
+
95
+
96
+ class ToolNotInstalledError(LookupError):
97
+ """Raised when an operation targets a tool that isn't installed."""
98
+
99
+ def __init__(self, name: str):
100
+ self.name = name
101
+ super().__init__(f"Tool '{name}' is not installed")
102
+
103
+
104
+ def _reject_binary_inject(bt: BackendType, inject: list[str]) -> None:
105
+ if bt == BackendType.BINARY and inject:
106
+ raise ValueError("Binary backend does not support injected packages")
107
+
108
+
109
+ def _validate_env_dir(env_dir: Path, envs_root: Path) -> Path:
110
+ """Ensure *env_dir* is safely under *envs_root* (anti-traversal)."""
111
+ resolved = env_dir.expanduser().resolve(strict=False)
112
+ root = envs_root.expanduser().resolve(strict=False)
113
+ if resolved != root and root not in resolved.parents:
114
+ raise ValueError(f"Invalid env path outside {root}: {resolved}")
115
+ return resolved
116
+
117
+
118
+ def _remove_env_dir(env_dir: Path, settings: Settings, *, ignore_errors: bool = False) -> None:
119
+ """Remove a tool env only after revalidating it stays under ``envs_dir``."""
120
+ safe_env_dir = env_dir.expanduser().resolve(strict=False)
121
+ envs_root = settings.envs_dir.expanduser().resolve(strict=False)
122
+ try:
123
+ safe_env_dir.relative_to(envs_root)
124
+ except ValueError as exc:
125
+ raise ValueError(f"Invalid env path outside {envs_root}: {safe_env_dir}") from exc
126
+ if safe_env_dir == envs_root:
127
+ raise ValueError(f"Refusing to remove env root: {envs_root}")
128
+ try:
129
+ _remove_tree_no_follow(safe_env_dir)
130
+ except FileNotFoundError:
131
+ if not ignore_errors:
132
+ raise
133
+ except OSError:
134
+ if not ignore_errors:
135
+ raise
136
+
137
+
138
+ def _remove_tree_no_follow(path: Path) -> None:
139
+ """Recursively remove *path* without following symlinks."""
140
+ if path.is_symlink() or path.is_file():
141
+ path.unlink()
142
+ return
143
+ for child in path.iterdir():
144
+ if child.is_symlink() or child.is_file():
145
+ child.unlink()
146
+ elif child.is_dir():
147
+ _remove_tree_no_follow(child)
148
+ path.rmdir()
149
+
150
+
151
+ def _make_tool_name(bt: BackendType, pkg_name: str, clean_spec: str) -> str:
152
+ """Compute the namespaced tool identifier from backend + spec."""
153
+ return make_tool_id(bt, pkg_name, clean_spec)
154
+
155
+
156
+ def plan_install(
157
+ package_spec: str,
158
+ *,
159
+ inject: list[str] | None = None,
160
+ expose_rules: list[str] | None = None,
161
+ slot: str | None = None,
162
+ backend_type: BackendType | None = None,
163
+ setup_path: Path | None = None,
164
+ settings: Settings | None = None,
165
+ bare: bool = False,
166
+ ) -> InstallPlan:
167
+ """Resolve what ``install_tool`` would do, without touching disk or network.
168
+
169
+ Performs spec parsing, backend detection, registry lookup (local file),
170
+ and env dir resolution. Does not create directories, fetch packages,
171
+ or query any remote registry.
172
+
173
+ ``bare=True`` plans an install without any exposed shim; it is sugar for
174
+ ``expose_rules=[]`` and takes precedence when both are given.
175
+ """
176
+ settings = settings or get_settings()
177
+ if bare:
178
+ expose_rules = []
179
+
180
+ # Strip namespace prefix (e.g. @pypi:, @gh:) before further processing.
181
+ _forced_bt, clean_spec = strip_protocol(package_spec)
182
+ bt = backend_type or _forced_bt or detect_backend(clean_spec)
183
+ resolved_inject = list(inject or [])
184
+
185
+ # Resolve short names via registry (e.g. "ripgrep" -> "BurntSushi/ripgrep")
186
+ if "/" not in clean_spec and not clean_spec.startswith("@"):
187
+ from ixt.config.registry import lookup
188
+
189
+ entry = lookup(clean_spec)
190
+ if entry is not None:
191
+ _reg_bt, resolved = strip_protocol(entry.spec)
192
+ clean_spec = resolved
193
+
194
+ _reject_binary_inject(bt, resolved_inject)
195
+
196
+ # Extract tool name: each backend uses its own spec format.
197
+ if bt == BackendType.BINARY:
198
+ repo_spec = parse_spec(clean_spec)
199
+ pkg_name = repo_spec.repo if repo_spec else clean_spec.split("/")[-1].split("@")[0]
200
+ elif bt == BackendType.NODE:
201
+ from ixt.backends.node import parse_npm_spec
202
+
203
+ pkg_name, _ver = parse_npm_spec(clean_spec)
204
+ else:
205
+ parsed = parse_requirement(clean_spec)
206
+ pkg_name = parsed.name
207
+
208
+ tool_name = make_tool_id(bt, pkg_name, clean_spec, slot=slot)
209
+ env_dir = settings.get_tool_env_dir(tool_name)
210
+ env_dir = _validate_env_dir(env_dir, settings.envs_dir)
211
+
212
+ resolved_expose = [EXPOSE_MAIN] if expose_rules is None else list(expose_rules)
213
+
214
+ return InstallPlan(
215
+ backend=bt,
216
+ original_spec=package_spec,
217
+ resolved_spec=clean_spec,
218
+ pkg_name=pkg_name,
219
+ tool_name=tool_name,
220
+ env_dir=env_dir,
221
+ expose_rules=resolved_expose,
222
+ inject=resolved_inject,
223
+ already_installed=env_dir.exists(),
224
+ setup_path=setup_path,
225
+ )
226
+
227
+
228
+ def display_tool_name(record: ToolRecord, settings: Settings | None = None) -> str:
229
+ """User-facing selector for an installed record."""
230
+ return display_spec_for_record(record)
231
+
232
+
233
+ def resolve_tool_arg(
234
+ spec: str,
235
+ *,
236
+ settings: Settings | None = None,
237
+ ) -> str:
238
+ """Resolve *spec* or ``@id:<id>`` to an installed tool id.
239
+
240
+ Raises ValueError when the spec cannot be resolved.
241
+ """
242
+ return resolve_tool_name(spec, settings=settings)
243
+
244
+
245
+ def _binary_spec_aliases(spec: str) -> set[str]:
246
+ """Return equivalent user-facing forms for a binary install spec."""
247
+ aliases = {spec}
248
+ repo_spec = parse_spec(spec)
249
+ if repo_spec is None:
250
+ aliases.add(strip_version_suffix(spec))
251
+ return aliases
252
+
253
+ hosted = f"{repo_spec.host}/{repo_spec.owner}/{repo_spec.repo}"
254
+ aliases.add(hosted)
255
+ if repo_spec.host == "github.com" and repo_spec.platform == "github":
256
+ aliases.add(f"{repo_spec.owner}/{repo_spec.repo}")
257
+ return aliases
258
+
259
+
260
+ def _record_lookup_aliases(record: ToolRecord) -> set[str]:
261
+ """Names a user may type for an installed record, excluding exact ids."""
262
+ aliases = {record.pkg(), record.spec, display_spec_for_record(record)}
263
+ aliases.update(id_prefixes(record.name))
264
+ if record.backend == BackendType.BINARY.value:
265
+ aliases.update(_binary_spec_aliases(record.spec))
266
+ return {alias for alias in aliases if alias and alias != record.name}
267
+
268
+
269
+ def _input_lookup_aliases(spec: str) -> tuple[BackendType | None, set[str]]:
270
+ """Normalize a user-supplied tool argument for installed-record lookup."""
271
+ forced_backend, clean_spec = strip_protocol(spec)
272
+ aliases = {spec, clean_spec}
273
+ aliases.update(_binary_spec_aliases(clean_spec))
274
+ return forced_backend, {alias for alias in aliases if alias}
275
+
276
+
277
+ def _resolve_from_installed_metadata(spec: str, settings: Settings) -> str | None:
278
+ """Resolve *spec* against installed ``ixt.json`` records, if unambiguous."""
279
+ records = [ToolRecord.load_json(meta) for meta in settings.iter_installed_metadata()]
280
+ if is_id_selector(spec):
281
+ tool_id = parse_id_selector(spec)
282
+ if any(record.name == tool_id for record in records):
283
+ return tool_id
284
+ return None
285
+
286
+ forced_backend, input_aliases = _input_lookup_aliases(spec)
287
+ lookup_records = [
288
+ record
289
+ for record in records
290
+ if forced_backend is None or record.backend == forced_backend.value
291
+ ]
292
+
293
+ for record in records:
294
+ if record.name == spec:
295
+ return record.name
296
+
297
+ candidates: dict[str, ToolRecord] = {}
298
+ ambiguous: set[str] = set()
299
+ for record in lookup_records:
300
+ for alias in _record_lookup_aliases(record):
301
+ if alias in ambiguous:
302
+ continue
303
+ existing = candidates.get(alias)
304
+ if existing is not None and existing.name != record.name:
305
+ del candidates[alias]
306
+ ambiguous.add(alias)
307
+ continue
308
+ candidates[alias] = record
309
+
310
+ for alias in input_aliases:
311
+ if alias in ambiguous:
312
+ matches = sorted(
313
+ record.name for record in lookup_records if alias in _record_lookup_aliases(record)
314
+ )
315
+ joined = ", ".join(matches)
316
+ raise ValueError(
317
+ f"Tool selector '{spec}' is ambiguous: {joined}. "
318
+ f"Use {', '.join('@id:' + name for name in matches)}."
319
+ )
320
+ if alias in candidates:
321
+ return candidates[alias].name
322
+
323
+ return None
324
+
325
+
326
+ def resolve_tool_name(spec: str, *, settings: Settings | None = None) -> str:
327
+ """Resolve a package spec or namespaced tool_name to the installed tool_name.
328
+
329
+ Lookup order:
330
+ 1. If *spec* matches an existing env dir directly, return it as-is.
331
+ 2. If *spec* matches exactly one installed metadata alias, return that record.
332
+ 3. Fall back to ``plan_install`` spec resolution
333
+ (e.g. ``mikefarah/yq`` → ``yq.mikefarah.github``).
334
+ """
335
+ settings = settings or get_settings()
336
+ if is_id_selector(spec):
337
+ tool_id = parse_id_selector(spec)
338
+ env_dir = settings.get_tool_env_dir(tool_id)
339
+ if env_dir.exists():
340
+ return tool_id
341
+ raise ValueError(f"Tool id '{tool_id}' is not installed")
342
+
343
+ env_dir = settings.get_tool_env_dir(spec)
344
+ if env_dir.exists():
345
+ return spec
346
+ installed_name = _resolve_from_installed_metadata(spec, settings)
347
+ if installed_name is not None:
348
+ return installed_name
349
+ return plan_install(spec, settings=settings).tool_name
350
+
351
+
352
+ def install_tool(
353
+ package_spec: str,
354
+ *,
355
+ inject: list[str] | None = None,
356
+ expose_rules: list[str] | None = None,
357
+ slot: str | None = None,
358
+ force: bool = False,
359
+ backend_type: BackendType | None = None,
360
+ runtime: str | None = None,
361
+ setup_path: Path | None = None,
362
+ node_shim: bool | None = None,
363
+ asset_pattern: str | None = None,
364
+ settings: Settings | None = None,
365
+ bare: bool = False,
366
+ ) -> ToolRecord:
367
+ """Install one isolated tool, expose its binaries, and return its record.
368
+
369
+ ``bare=True`` creates the env without exposing any shim; the caller can
370
+ run ``ixt tool config <target> expose ...`` afterwards to wire them up.
371
+ """
372
+ settings = settings or get_settings()
373
+ validate_slot(slot)
374
+ plan = plan_install(
375
+ package_spec,
376
+ inject=inject,
377
+ expose_rules=expose_rules,
378
+ slot=slot,
379
+ backend_type=backend_type,
380
+ setup_path=setup_path,
381
+ settings=settings,
382
+ bare=bare,
383
+ )
384
+ bt = plan.backend
385
+ clean_spec = plan.resolved_spec
386
+ pkg_name = plan.pkg_name
387
+ tool_name = plan.tool_name
388
+ env_dir = plan.env_dir
389
+ setup_path = plan.setup_path
390
+
391
+ backend = get_backend(bt, settings=settings)
392
+
393
+ if env_dir.exists():
394
+ if not force:
395
+ raise ToolAlreadyInstalledError(tool_name, env_dir)
396
+ # Remove old exposed bins before wiping the env.
397
+ meta_file = settings.get_tool_metadata_file(tool_name)
398
+ if meta_file.exists():
399
+ old = ToolRecord.load_json(meta_file)
400
+ unexpose_tool(old.exposed_bins, settings.bin_dir)
401
+ _remove_env_dir(env_dir, settings)
402
+
403
+ rules = plan.expose_rules
404
+ created = False
405
+ try:
406
+ created = backend.create_env(env_dir)
407
+ all_specs = [clean_spec] if bt == BackendType.BINARY else [clean_spec, *plan.inject]
408
+ install_kwargs: dict = {}
409
+ if bt == BackendType.NODE:
410
+ if runtime:
411
+ install_kwargs["runtime"] = runtime
412
+ if node_shim is not None:
413
+ install_kwargs["node_shim"] = node_shim
414
+ if setup_path and bt == BackendType.BINARY:
415
+ install_kwargs["setup_path"] = setup_path
416
+ if asset_pattern and bt == BackendType.BINARY:
417
+ install_kwargs["asset_pattern"] = asset_pattern
418
+ extra = backend.install_packages(env_dir, all_specs, **install_kwargs)
419
+
420
+ version = backend.installed_version(env_dir, pkg_name)
421
+
422
+ if rules:
423
+ result = expose_tool(
424
+ pkg_name,
425
+ backend,
426
+ env_dir,
427
+ settings.bin_dir,
428
+ rules,
429
+ overwrite=force,
430
+ )
431
+
432
+ if result.skipped:
433
+ from ixt.core.expose import warn_if_skipped
434
+ from ixt.libs.logger import get_logger
435
+
436
+ warn_if_skipped(result, tool_name, get_logger("install"))
437
+ linked_bins = result.linked
438
+ else:
439
+ linked_bins = {}
440
+
441
+ record = ToolRecord(
442
+ name=tool_name,
443
+ pkg_name=pkg_name,
444
+ backend=bt.value,
445
+ spec=clean_spec,
446
+ env_dir=str(env_dir),
447
+ version=version,
448
+ injected=list(plan.inject),
449
+ expose_rules=rules,
450
+ exposed_bins=linked_bins,
451
+ node_shim=node_shim,
452
+ asset_pattern=extra.get("asset_pattern") if extra else None,
453
+ asset_pattern_forced=bool(extra.get("asset_pattern_forced")) if extra else False,
454
+ )
455
+ record.save_json(settings.get_tool_metadata_file(tool_name))
456
+
457
+ # Run post-install hook if defined (binary backend only for now).
458
+ if bt == BackendType.BINARY and linked_bins:
459
+ _run_hook(env_dir, linked_bins, "post_install")
460
+
461
+ return record
462
+ except Exception:
463
+ if created:
464
+ _remove_env_dir(env_dir, settings, ignore_errors=True)
465
+ raise
466
+
467
+
468
+ def uninstall_tool(
469
+ tool_name: str,
470
+ *,
471
+ settings: Settings | None = None,
472
+ ) -> Path:
473
+ """Remove exposed binaries and the tool environment from disk."""
474
+ settings = settings or get_settings()
475
+ env_dir = settings.get_tool_env_dir(tool_name)
476
+ if not env_dir.exists():
477
+ raise FileNotFoundError(env_dir)
478
+
479
+ # Clean up exposed bins first.
480
+ meta_file = settings.get_tool_metadata_file(tool_name)
481
+ if meta_file.exists():
482
+ record = ToolRecord.load_json(meta_file)
483
+
484
+ # Run pre-uninstall hook before removing anything.
485
+ if record.exposed_bins:
486
+ _run_hook(env_dir, record.exposed_bins, "pre_uninstall")
487
+
488
+ unexpose_tool(record.exposed_bins, settings.bin_dir)
489
+
490
+ _remove_env_dir(env_dir, settings)
491
+ return env_dir
492
+
493
+
494
+ def uninstall_all(
495
+ *,
496
+ settings: Settings | None = None,
497
+ ) -> dict[str, Path | Exception]:
498
+ """Uninstall every installed tool. Returns a map of tool_name -> removed path or error."""
499
+ settings = settings or get_settings()
500
+ results: dict[str, Path | Exception] = {}
501
+
502
+ for meta_file in settings.iter_installed_metadata():
503
+ name = meta_file.parent.name
504
+ try:
505
+ results[name] = uninstall_tool(name, settings=settings)
506
+ except Exception as exc:
507
+ results[name] = exc
508
+
509
+ return results