dazzlecmd-lib 0.8.55__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 (85) hide show
  1. dazzlecmd_lib/__init__.py +61 -0
  2. dazzlecmd_lib/_version.py +92 -0
  3. dazzlecmd_lib/aggregator_config.py +453 -0
  4. dazzlecmd_lib/cli_helpers.py +180 -0
  5. dazzlecmd_lib/colors.py +228 -0
  6. dazzlecmd_lib/conditions.py +211 -0
  7. dazzlecmd_lib/config.py +194 -0
  8. dazzlecmd_lib/contexts.py +1198 -0
  9. dazzlecmd_lib/continuum.py +22 -0
  10. dazzlecmd_lib/core/__init__.py +124 -0
  11. dazzlecmd_lib/core/links/__init__.py +195 -0
  12. dazzlecmd_lib/core/links/_detect.py +711 -0
  13. dazzlecmd_lib/core/safedel/__init__.py +79 -0
  14. dazzlecmd_lib/core/safedel/_classifier.py +331 -0
  15. dazzlecmd_lib/core/safedel/_platform.py +508 -0
  16. dazzlecmd_lib/core/safedel/_recover.py +658 -0
  17. dazzlecmd_lib/core/safedel/_store.py +565 -0
  18. dazzlecmd_lib/core/safedel/_timepattern.py +390 -0
  19. dazzlecmd_lib/core/safedel/_volumes.py +449 -0
  20. dazzlecmd_lib/core/safedel/_zones.py +350 -0
  21. dazzlecmd_lib/default_meta_commands.py +2359 -0
  22. dazzlecmd_lib/engine.py +2076 -0
  23. dazzlecmd_lib/entity.py +468 -0
  24. dazzlecmd_lib/loader.py +546 -0
  25. dazzlecmd_lib/meta_command_registry.py +265 -0
  26. dazzlecmd_lib/mode.py +1566 -0
  27. dazzlecmd_lib/paths.py +140 -0
  28. dazzlecmd_lib/platform_detect.py +253 -0
  29. dazzlecmd_lib/platform_resolve.py +146 -0
  30. dazzlecmd_lib/registry.py +1379 -0
  31. dazzlecmd_lib/reserved.py +62 -0
  32. dazzlecmd_lib/resolution_context.py +73 -0
  33. dazzlecmd_lib/resolution_trace.py +92 -0
  34. dazzlecmd_lib/schema_version.py +63 -0
  35. dazzlecmd_lib/setup_resolve.py +238 -0
  36. dazzlecmd_lib/states.py +322 -0
  37. dazzlecmd_lib/templates/__with__/docker-deploy/Dockerfile.tmpl +11 -0
  38. dazzlecmd_lib/templates/__with__/docker-test/Dockerfile.test.tmpl +10 -0
  39. dazzlecmd_lib/templates/__with__/docker-test/docker-compose.test.yml.tmpl +7 -0
  40. dazzlecmd_lib/templates/aggregator/.gitignore.tmpl +9 -0
  41. dazzlecmd_lib/templates/aggregator/README.md.tmpl +30 -0
  42. dazzlecmd_lib/templates/aggregator/aggregator.json.tmpl +19 -0
  43. dazzlecmd_lib/templates/aggregator/pyproject.toml.tmpl +29 -0
  44. dazzlecmd_lib/templates/aggregator/src/{name_underscore}/__init__.py.tmpl +3 -0
  45. dazzlecmd_lib/templates/aggregator/src/{name_underscore}/_version.py.tmpl +7 -0
  46. dazzlecmd_lib/templates/aggregator/src/{name_underscore}/cli.py.tmpl +42 -0
  47. dazzlecmd_lib/templates/aggregator/tests/test_cli_smoke.py.tmpl +17 -0
  48. dazzlecmd_lib/templates/bash/.dazzlecmd.json.tmpl +25 -0
  49. dazzlecmd_lib/templates/bash/{name}.sh.tmpl +10 -0
  50. dazzlecmd_lib/templates/binary/.dazzlecmd.json.tmpl +25 -0
  51. dazzlecmd_lib/templates/binary/README.md.tmpl +59 -0
  52. dazzlecmd_lib/templates/c_cpp/.dazzlecmd.json.tmpl +29 -0
  53. dazzlecmd_lib/templates/c_cpp/Makefile.tmpl +11 -0
  54. dazzlecmd_lib/templates/c_cpp/main.c.tmpl +15 -0
  55. dazzlecmd_lib/templates/cmd/.dazzlecmd.json.tmpl +25 -0
  56. dazzlecmd_lib/templates/cmd/{name}.cmd.tmpl +11 -0
  57. dazzlecmd_lib/templates/docker/.dazzlecmd.json.tmpl +29 -0
  58. dazzlecmd_lib/templates/docker/Dockerfile.tmpl +12 -0
  59. dazzlecmd_lib/templates/generic/.dazzlecmd.json.tmpl +25 -0
  60. dazzlecmd_lib/templates/generic/README.md.tmpl +67 -0
  61. dazzlecmd_lib/templates/node/.dazzlecmd.json.tmpl +28 -0
  62. dazzlecmd_lib/templates/node/index.js.tmpl +8 -0
  63. dazzlecmd_lib/templates/node/package.json.tmpl +13 -0
  64. dazzlecmd_lib/templates/powershell/.dazzlecmd.json.tmpl +25 -0
  65. dazzlecmd_lib/templates/powershell/{name}.ps1.tmpl +12 -0
  66. dazzlecmd_lib/templates/python/.dazzlecmd.json.tmpl +25 -0
  67. dazzlecmd_lib/templates/python/__full__/.dazzlecmd.json.tmpl +30 -0
  68. dazzlecmd_lib/templates/python/__full__/README.md.tmpl +32 -0
  69. dazzlecmd_lib/templates/python/__full__/dz_setup.py.tmpl +235 -0
  70. dazzlecmd_lib/templates/python/__full__/requirements.txt.tmpl +7 -0
  71. dazzlecmd_lib/templates/python/__full__/tests/test_{name_underscore}.py.tmpl +18 -0
  72. dazzlecmd_lib/templates/python/{name_underscore}.py.tmpl +19 -0
  73. dazzlecmd_lib/templates/repokit_fallback/CONTRIBUTING.md.tmpl +9 -0
  74. dazzlecmd_lib/templates/repokit_fallback/LICENSE.tmpl +22 -0
  75. dazzlecmd_lib/templates/rust/.dazzlecmd.json.tmpl +29 -0
  76. dazzlecmd_lib/templates/rust/Cargo.toml.tmpl +11 -0
  77. dazzlecmd_lib/templates/rust/src/main.rs.tmpl +9 -0
  78. dazzlecmd_lib/templates.py +197 -0
  79. dazzlecmd_lib/testing.py +82 -0
  80. dazzlecmd_lib/user_overrides.py +131 -0
  81. dazzlecmd_lib-0.8.55.dist-info/METADATA +117 -0
  82. dazzlecmd_lib-0.8.55.dist-info/RECORD +85 -0
  83. dazzlecmd_lib-0.8.55.dist-info/WHEEL +5 -0
  84. dazzlecmd_lib-0.8.55.dist-info/licenses/LICENSE +674 -0
  85. dazzlecmd_lib-0.8.55.dist-info/top_level.txt +1 -0
@@ -0,0 +1,180 @@
1
+ """CLI scaffolding helpers for aggregator authors.
2
+
3
+ These are low-level argparse helpers shared across the library's default
4
+ meta-commands and available to downstream aggregators that bypass the
5
+ ``MetaCommandRegistry`` (via the ``parser_builder=`` escape hatch).
6
+
7
+ **When to use**: if you're writing your own ``parser_builder`` callback
8
+ instead of using the registry, import these helpers to avoid duplicating
9
+ the standard subparser-scaffolding boilerplate.
10
+
11
+ **When NOT to use**: if you're using the registry (the default /
12
+ recommended path), don't call these directly — the registry's
13
+ ``build_parsers()`` takes care of parser construction via the
14
+ registered factories.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys as _sys
20
+ from typing import Any, Iterable, Optional
21
+
22
+ from . import colors as _colors
23
+
24
+
25
+ def build_tool_subparsers(
26
+ subparsers,
27
+ projects: Iterable[Any], # DazzleEntity instances or plain manifest dicts
28
+ reserved_commands: Optional[set] = None,
29
+ *,
30
+ add_help: bool = False,
31
+ warn_on_conflict: bool = True,
32
+ exempt_from_warning: Optional[set] = None,
33
+ ) -> list:
34
+ """Register one subparser per discovered tool.
35
+
36
+ This is the "tool dispatch" half of an aggregator's argparse parser —
37
+ complementing the meta-command subparsers (list, info, etc.) that
38
+ the registry or ``default_meta_commands`` factories install.
39
+
40
+ Args:
41
+ subparsers: an ``argparse._SubParsersAction`` obtained from
42
+ ``parser.add_subparsers(...)``.
43
+ projects: iterable of project dicts (each must have a ``name``
44
+ key; ``description`` and ``_fqcn`` are optional).
45
+ reserved_commands: set of names that cannot be used as tool names
46
+ (typically ``engine.reserved_commands``). Tools matching
47
+ reserved names are skipped with a warning to stderr.
48
+ add_help: forwarded to ``add_parser``. Default ``False`` — tools
49
+ handle their own ``--help`` via dispatch.
50
+ warn_on_conflict: if True (default), print a stderr warning for
51
+ tools skipped due to reserved-command collision. Set False
52
+ to silence (test environments, repeated invocations).
53
+ exempt_from_warning: optional set of names that are exempt from
54
+ the conflict warning. Used by the engine to pass
55
+ ``meta_registry.user_overrides()`` so deliberately-overridden
56
+ meta-commands don't fire the warning on every invocation.
57
+ Names in this set still skip parser registration (the meta-
58
+ command's parser wins), but no warning is emitted — the
59
+ override is the acknowledgment.
60
+
61
+ Returns:
62
+ List of the subparsers that were registered. Each has
63
+ ``_project`` set via ``set_defaults`` so the dispatch-side can
64
+ identify which tool was invoked.
65
+ """
66
+ reserved = reserved_commands or set()
67
+ exempt = exempt_from_warning or set()
68
+ registered = []
69
+ seen_names: set = set()
70
+
71
+ for project in projects:
72
+ if isinstance(project, dict):
73
+ name = project.get("name")
74
+ else:
75
+ name = project.name
76
+ if not name:
77
+ continue
78
+
79
+ if name in reserved:
80
+ if warn_on_conflict and name not in exempt:
81
+ # As of issue #67's redesign, shadowed tools WIN short-name
82
+ # dispatch (engine._dispatch_registry_path checks tool
83
+ # lookup before the meta-command path). The argparse
84
+ # subparser is still skipped here because argparse can't
85
+ # carry two subparsers with the same name; the meta-
86
+ # command's subparser stays registered but is unreachable
87
+ # by short name (FQCN access for meta-commands is a
88
+ # planned future enhancement). The warning tells aggregator
89
+ # authors about the collision so they can rename or
90
+ # consciously accept the shadowing.
91
+ print(
92
+ _colors.warn(
93
+ f"Warning: Tool {name!r} shadows reserved meta-command -- tool wins short-name dispatch"
94
+ ),
95
+ file=_sys.stderr,
96
+ )
97
+ continue
98
+
99
+ if name in seen_names:
100
+ # Duplicate short name across kits — skip subsequent ones.
101
+ # The FQCN index handles collision resolution during dispatch;
102
+ # this only affects short-name argparse registration.
103
+ continue
104
+ seen_names.add(name)
105
+
106
+ if isinstance(project, dict):
107
+ description = project.get("description") or ""
108
+ else:
109
+ description = project.description or ""
110
+ sub = subparsers.add_parser(
111
+ name,
112
+ help=description,
113
+ add_help=add_help,
114
+ )
115
+ sub.set_defaults(_project=project)
116
+ registered.append(sub)
117
+
118
+ return registered
119
+
120
+
121
+ def derive_reserved_from_registry(registry, extras: Optional[set] = None) -> set:
122
+ """Combine a registry's registered names with extra reserved names.
123
+
124
+ The result is suitable for passing as ``reserved_commands`` to
125
+ ``build_tool_subparsers``. Engine's ``reserved_commands`` property
126
+ uses this pattern internally.
127
+
128
+ Args:
129
+ registry: a ``MetaCommandRegistry`` instance.
130
+ extras: optional additional names to reserve (for future
131
+ meta-commands not yet registered, or aggregator-specific
132
+ name guards).
133
+
134
+ Returns:
135
+ Set of reserved command names.
136
+ """
137
+ names = set(registry.registered()) if registry is not None else set()
138
+ if extras:
139
+ names = names | set(extras)
140
+ return names
141
+
142
+
143
+ def add_version_flag(parser, version_info=None, app_name: Optional[str] = None):
144
+ """Attach a ``--version`` / ``-V`` flag to the given parser.
145
+
146
+ Produces output like ``wtf-windows 0.1.3 (0.1.3_main_5-20260418-abc123)``
147
+ when ``version_info`` is a ``(display, full)`` tuple, or just the
148
+ app name when ``version_info`` is None.
149
+
150
+ Typically called on the top-level argparse parser during
151
+ aggregator ``main()``. No-op if ``parser`` is None.
152
+ """
153
+ if parser is None:
154
+ return
155
+ if version_info:
156
+ display, full = version_info
157
+ name = app_name or "aggregator"
158
+ version_string = f"{name} {display} ({full})"
159
+ else:
160
+ version_string = app_name or "aggregator"
161
+ parser.add_argument(
162
+ "--version", "-V",
163
+ action="version",
164
+ version=version_string,
165
+ )
166
+
167
+
168
+ def default_epilog_for(app_name: str, tool_count: int, kit_count: int = 0) -> str:
169
+ """Produce a generic epilog string for aggregators without custom epilog.
170
+
171
+ Used by the engine when ``epilog_builder`` isn't set. Aggregators
172
+ with domain-specific help (wtf-style diagnostic badges, dazzlecmd's
173
+ tree-organized categorization) provide their own ``epilog_builder``.
174
+ """
175
+ lines = []
176
+ if tool_count > 0:
177
+ lines.append(f"{tool_count} tool(s)" + (f" across {kit_count} kit(s)" if kit_count else ""))
178
+ lines.append(f"Run '{app_name} list' to see available tools.")
179
+ lines.append(f"Run '{app_name} <tool> --help' for tool-specific options.")
180
+ return "\n".join(lines)
@@ -0,0 +1,228 @@
1
+ """ANSI color helpers for dazzlecmd-lib rendering.
2
+
3
+ Slim by design: 8-color ANSI palette only (broadly supported including PuTTY,
4
+ Windows Terminal, modern conhost.exe with VT processing, bash/zsh, WSL). No
5
+ truecolor escapes (256-color or RGB) because those break older terminals.
6
+
7
+ The lib's render functions (`render_list`, `render_info`, `render_tree`,
8
+ `render_kit_list`, plus stderr warnings) call `should_use_color()` to decide
9
+ whether to emit color, then wrap text via `colorize(text, *codes)`.
10
+
11
+ Detection priority (in `should_use_color`):
12
+ 1. ``NO_COLOR`` env var set (any value) -> False (community standard)
13
+ 2. ``DZ_COLOR=always`` OR ``FORCE_COLOR`` env var set -> True (force on)
14
+ 3. ``DZ_COLOR=never`` -> False (project-specific override)
15
+ 4. ``stream.isatty()`` -> True if the stream is a TTY, else False
16
+
17
+ Windows compatibility:
18
+ - Modern Windows (Win10 1511+, Win11) handles ANSI natively via
19
+ ``ENABLE_VIRTUAL_TERMINAL_PROCESSING`` in conhost.exe.
20
+ - Legacy cmd.exe needs ``colorama`` to translate ANSI -> Windows Console API.
21
+ - This module lazily imports ``colorama`` and calls ``colorama.init()`` on
22
+ Windows when ``should_use_color()`` first returns True. Missing colorama
23
+ is non-fatal -- modern Windows works without it.
24
+
25
+ Install with ``pip install dazzlecmd-lib[color]`` to add colorama as a
26
+ Windows-only optional extra. Adding colorama to required deps is rejected
27
+ by the lib's slim-default constraint.
28
+
29
+ Public API:
30
+ RESET, BOLD, DIM, RED, GREEN, YELLOW, CYAN, BRIGHT_RED -- ANSI constants
31
+ should_use_color(stream=None) -> bool -- TTY+env probe
32
+ colorize(text: str, *codes: str) -> str -- wrap helper
33
+ colorize_for(stream, text, *codes) -> str -- TTY-gated wrap
34
+ warn(text: str, stream=None) -> str -- YELLOW (stderr)
35
+ error(text: str, stream=None) -> str -- BRIGHT_RED (stderr)
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import os
41
+ import sys
42
+
43
+ # 8-color ANSI palette + bold/dim emphasis. Compatible with PuTTY, cmd.exe
44
+ # (post-VT-mode), PowerShell, Windows Terminal, conhost, bash, zsh, WSL.
45
+ RESET = "\033[0m"
46
+ BOLD = "\033[1m"
47
+ DIM = "\033[2m"
48
+ RED = "\033[31m"
49
+ GREEN = "\033[32m"
50
+ YELLOW = "\033[33m"
51
+ CYAN = "\033[36m"
52
+ BRIGHT_RED = "\033[91m"
53
+
54
+ # Track colorama init so we only attempt it once per process. The init is
55
+ # a no-op on non-Windows platforms but the import attempt costs ~1ms.
56
+ _ansi_initialized = False
57
+
58
+
59
+ def _init_windows_ansi(force=False):
60
+ """Lazily import and initialize colorama on Windows.
61
+
62
+ ``force=True`` -> ``colorama.init(strip=False)``. Used when the caller
63
+ explicitly forces color (``DZ_COLOR=always`` / ``FORCE_COLOR``) and
64
+ output is piped: without ``strip=False``, colorama strips ANSI when
65
+ not writing to a real console, which defeats the explicit force.
66
+
67
+ ``force=False`` -> ``colorama.init()`` (default). On a real Windows
68
+ console, colorama translates ANSI to Windows Console API calls. When
69
+ piped, ANSI is stripped (the polite default for ``isatty=True`` paths
70
+ where the user hasn't asked for forced color).
71
+
72
+ Silent no-op if colorama isn't installed (modern Windows 1511+ handles
73
+ ANSI natively via VT processing; only legacy cmd.exe truly needs
74
+ colorama).
75
+ """
76
+ try:
77
+ import colorama # type: ignore[import-not-found]
78
+ if force:
79
+ colorama.init(strip=False)
80
+ else:
81
+ colorama.init()
82
+ except ImportError:
83
+ pass
84
+
85
+
86
+ def should_use_color(stream=None) -> bool:
87
+ """Return True if ANSI color should be emitted to ``stream``.
88
+
89
+ Detection priority:
90
+ 1. ``NO_COLOR`` env var set (any value) -> False (community standard).
91
+ 2. ``DZ_COLOR=always`` OR ``FORCE_COLOR`` set -> True.
92
+ 3. ``DZ_COLOR=never`` -> False.
93
+ 4. ``stream.isatty()`` -> True/False.
94
+
95
+ Default stream: ``sys.stdout``. Pass ``sys.stderr`` for warning paths.
96
+
97
+ Side effect: on the first call that returns True on Windows, attempts to
98
+ initialize colorama (silently no-ops if colorama isn't installed). This
99
+ is idempotent; subsequent calls don't re-init.
100
+ """
101
+ global _ansi_initialized
102
+
103
+ # NO_COLOR (industry standard: https://no-color.org/) -- absolute override
104
+ if os.environ.get("NO_COLOR") is not None:
105
+ return False
106
+
107
+ dz_color = os.environ.get("DZ_COLOR", "").lower()
108
+
109
+ # Force-on cases: user explicitly wants color even when piped.
110
+ # On Windows, pass force=True so colorama keeps the ANSI codes intact
111
+ # (its default strips them when output isn't a real console).
112
+ if dz_color == "always" or os.environ.get("FORCE_COLOR"):
113
+ if not _ansi_initialized and sys.platform == "win32":
114
+ _init_windows_ansi(force=True)
115
+ _ansi_initialized = True
116
+ return True
117
+
118
+ # Force-off case
119
+ if dz_color == "never":
120
+ return False
121
+
122
+ # Default: gate on isatty. On a real Windows console, colorama
123
+ # translates ANSI to Win Console API; when piped it strips, which
124
+ # is the right default for "polite" auto-detected color.
125
+ target = stream if stream is not None else sys.stdout
126
+ is_tty = hasattr(target, "isatty") and target.isatty()
127
+ if is_tty and not _ansi_initialized and sys.platform == "win32":
128
+ _init_windows_ansi(force=False)
129
+ _ansi_initialized = True
130
+ return is_tty
131
+
132
+
133
+ def colorize(text: str, *codes: str) -> str:
134
+ """Wrap ``text`` in ANSI ``codes``, terminated with ``RESET``.
135
+
136
+ Returns ``text`` unchanged when ``codes`` is empty. Designed so the
137
+ caller can do:
138
+
139
+ from dazzlecmd_lib.colors import colorize, BOLD, should_use_color
140
+
141
+ styled = colorize(label, BOLD) if should_use_color() else label
142
+ print(styled)
143
+
144
+ Or for the common pattern where color is conditional:
145
+
146
+ codes = (BOLD,) if should_use_color() else ()
147
+ print(colorize(label, *codes))
148
+
149
+ No automatic TTY check here -- callers control when to apply color so
150
+ that data-shape APIs (like ``build_list_entries``) remain plain.
151
+ """
152
+ if not codes:
153
+ return text
154
+ return "".join(codes) + text + RESET
155
+
156
+
157
+ def colorize_for(stream, text: str, *codes: str) -> str:
158
+ """Convenience wrapper: colorize ``text`` only when ``should_use_color(stream)``.
159
+
160
+ Designed for the common stderr-warning pattern where a single line
161
+ of code must decide both whether to color AND wrap the text:
162
+
163
+ import sys
164
+ from dazzlecmd_lib.colors import colorize_for, YELLOW
165
+
166
+ print(colorize_for(sys.stderr, f"Warning: {msg}", YELLOW),
167
+ file=sys.stderr)
168
+
169
+ Equivalent to:
170
+
171
+ if should_use_color(stream):
172
+ ... colorize(text, *codes) ...
173
+ else:
174
+ ... text ...
175
+
176
+ but collapses the conditional into one expression so the call site
177
+ stays scannable.
178
+ """
179
+ if should_use_color(stream):
180
+ return colorize(text, *codes)
181
+ return text
182
+
183
+
184
+ def warn(text: str, stream=None) -> str:
185
+ """Format ``text`` as a warning. YELLOW + RESET when color is enabled
186
+ on ``stream`` (defaults to ``sys.stderr``); plain ``text`` otherwise.
187
+
188
+ Semantic shortcut for the common pattern:
189
+
190
+ print(colorize_for(sys.stderr, f"Warning: {msg}", YELLOW),
191
+ file=sys.stderr)
192
+
193
+ Usage:
194
+
195
+ import sys
196
+ from dazzlecmd_lib.colors import warn
197
+
198
+ print(warn(f"Tool {tool!r} not found."), file=sys.stderr)
199
+
200
+ Pairs with ``error()`` for the two most common stderr-message classes.
201
+ Callers wanting a non-standard emphasis (e.g. BOLD+YELLOW for a banner)
202
+ should call ``colorize_for`` directly with their own codes.
203
+ """
204
+ target = stream if stream is not None else sys.stderr
205
+ return colorize_for(target, text, YELLOW)
206
+
207
+
208
+ def error(text: str, stream=None) -> str:
209
+ """Format ``text`` as an error. BRIGHT_RED + RESET when color is enabled
210
+ on ``stream`` (defaults to ``sys.stderr``); plain ``text`` otherwise.
211
+
212
+ Semantic shortcut for the common pattern:
213
+
214
+ print(colorize_for(sys.stderr, f"Error: {exc}", BRIGHT_RED),
215
+ file=sys.stderr)
216
+
217
+ Usage:
218
+
219
+ import sys
220
+ from dazzlecmd_lib.colors import error
221
+
222
+ print(error(f"Error: cannot read config: {exc}"), file=sys.stderr)
223
+
224
+ Use BRIGHT_RED rather than RED to stay visible on dark terminals where
225
+ plain RED is hard to read.
226
+ """
227
+ target = stream if stream is not None else sys.stderr
228
+ return colorize_for(target, text, BRIGHT_RED)
@@ -0,0 +1,211 @@
1
+ """detect_when condition evaluator -- shared library for setup and runtime.
2
+
3
+ A `detect_when` block is a JSON object describing a boolean predicate over the
4
+ host environment. Both the setup resolver (is this platform's install branch
5
+ active?) and the runtime resolver (should this `prefer` entry be selected?)
6
+ consume the same evaluator.
7
+
8
+ Supported matcher keys (leaf):
9
+
10
+ file_exists: "/path/to/file"
11
+ True if the path exists and is a regular file.
12
+
13
+ dir_exists: "/path/to/dir"
14
+ True if the path exists and is a directory.
15
+
16
+ env_var: "VAR_NAME"
17
+ True if the env var is set AND non-empty. Values are never logged.
18
+
19
+ env_var_equals: {"name": "VAR_NAME", "value": "expected"}
20
+ True if os.environ[name] == value (strict string equality).
21
+ Values are never logged.
22
+
23
+ command_available: "bun"
24
+ True if shutil.which(name) resolves (PATH + PATHEXT on Windows).
25
+
26
+ uname_contains: "microsoft"
27
+ Case-insensitive substring match against a composite PlatformInfo
28
+ string: "<os> <subtype> <arch> <version> [wsl]".
29
+
30
+ Combinators:
31
+
32
+ all: [<condition>, ...] AND of sub-conditions. `all: []` is vacuously True.
33
+ any: [<condition>, ...] OR of sub-conditions. `any: []` is vacuously False.
34
+
35
+ Composition:
36
+
37
+ - Multiple keys in the SAME dict are AND'd:
38
+ {"file_exists": "/etc/debian_version", "command_available": "apt"}
39
+ passes only if both hold.
40
+
41
+ - Keys beginning with "_" are treated as metadata (e.g. "_schema_version",
42
+ "_comment") and ignored by the evaluator.
43
+
44
+ - An empty condition dict is vacuously True.
45
+
46
+ - Unknown matcher keys raise ConditionSyntaxError. Authors hitting a typo
47
+ see it immediately rather than silently falling through.
48
+
49
+ Security: env var VALUES are never included in error messages or return values,
50
+ only names and presence. A condition checking a secret-bearing env var will
51
+ not leak the secret via a diagnostic trace.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import os
57
+ import shutil
58
+ from typing import Any, List, Optional
59
+
60
+ from dazzlecmd_lib.platform_detect import PlatformInfo
61
+
62
+
63
+ class ConditionSyntaxError(ValueError):
64
+ """Raised when a condition dict contains unknown keys or malformed values."""
65
+
66
+
67
+ _LEAF_MATCHERS = {
68
+ "file_exists",
69
+ "dir_exists",
70
+ "env_var",
71
+ "env_var_equals",
72
+ "command_available",
73
+ "uname_contains",
74
+ }
75
+ _COMBINATORS = {"all", "any"}
76
+
77
+
78
+ def _file_exists(path: str) -> bool:
79
+ if not isinstance(path, str) or not path:
80
+ return False
81
+ return os.path.isfile(path)
82
+
83
+
84
+ def _dir_exists(path: str) -> bool:
85
+ if not isinstance(path, str) or not path:
86
+ return False
87
+ return os.path.isdir(path)
88
+
89
+
90
+ def _env_var(name: str) -> bool:
91
+ if not isinstance(name, str) or not name:
92
+ return False
93
+ value = os.environ.get(name)
94
+ return bool(value)
95
+
96
+
97
+ def _env_var_equals(spec: dict) -> bool:
98
+ if not isinstance(spec, dict):
99
+ raise ConditionSyntaxError(
100
+ f"env_var_equals requires a dict with 'name' and 'value', got {type(spec).__name__}"
101
+ )
102
+ name = spec.get("name")
103
+ expected = spec.get("value")
104
+ if not isinstance(name, str) or not name:
105
+ raise ConditionSyntaxError("env_var_equals.name must be a non-empty string")
106
+ if expected is None:
107
+ raise ConditionSyntaxError("env_var_equals.value is required")
108
+ actual = os.environ.get(name)
109
+ return actual == str(expected)
110
+
111
+
112
+ def _command_available(name: str) -> bool:
113
+ if not isinstance(name, str) or not name:
114
+ return False
115
+ return shutil.which(name) is not None
116
+
117
+
118
+ def _uname_composite(platform_info: PlatformInfo) -> str:
119
+ parts = [platform_info.os]
120
+ if platform_info.subtype:
121
+ parts.append(platform_info.subtype)
122
+ parts.append(platform_info.arch)
123
+ if platform_info.version:
124
+ parts.append(platform_info.version)
125
+ if platform_info.is_wsl:
126
+ parts.append("wsl")
127
+ return " ".join(parts).lower()
128
+
129
+
130
+ def _uname_contains(substring: str, platform_info: PlatformInfo) -> bool:
131
+ if not isinstance(substring, str) or not substring:
132
+ return False
133
+ return substring.lower() in _uname_composite(platform_info)
134
+
135
+
136
+ def _evaluate_leaf(
137
+ key: str, value: Any, platform_info: PlatformInfo
138
+ ) -> bool:
139
+ if key == "file_exists":
140
+ return _file_exists(value)
141
+ if key == "dir_exists":
142
+ return _dir_exists(value)
143
+ if key == "env_var":
144
+ return _env_var(value)
145
+ if key == "env_var_equals":
146
+ return _env_var_equals(value)
147
+ if key == "command_available":
148
+ return _command_available(value)
149
+ if key == "uname_contains":
150
+ return _uname_contains(value, platform_info)
151
+ raise ConditionSyntaxError(f"unknown leaf matcher: {key!r}")
152
+
153
+
154
+ def _evaluate_all(conditions: List[Any], platform_info: PlatformInfo) -> bool:
155
+ if not isinstance(conditions, list):
156
+ raise ConditionSyntaxError(
157
+ f"'all' requires a list of conditions, got {type(conditions).__name__}"
158
+ )
159
+ # Vacuous: empty `all` is True.
160
+ return all(evaluate_condition(c, platform_info) for c in conditions)
161
+
162
+
163
+ def _evaluate_any(conditions: List[Any], platform_info: PlatformInfo) -> bool:
164
+ if not isinstance(conditions, list):
165
+ raise ConditionSyntaxError(
166
+ f"'any' requires a list of conditions, got {type(conditions).__name__}"
167
+ )
168
+ # Vacuous: empty `any` is False.
169
+ return any(evaluate_condition(c, platform_info) for c in conditions)
170
+
171
+
172
+ def evaluate_condition(
173
+ condition: Optional[dict],
174
+ platform_info: PlatformInfo,
175
+ ) -> bool:
176
+ """Evaluate a detect_when dict against the given platform.
177
+
178
+ Returns True if all declared matchers pass. See module docstring for
179
+ schema details.
180
+
181
+ An empty or None condition is vacuously True (no conditions declared ==
182
+ unconditionally active).
183
+ """
184
+ if condition is None:
185
+ return True
186
+ if not isinstance(condition, dict):
187
+ raise ConditionSyntaxError(
188
+ f"condition must be a dict or None, got {type(condition).__name__}"
189
+ )
190
+ if not condition:
191
+ return True
192
+
193
+ results: List[bool] = []
194
+ for key, value in condition.items():
195
+ if key.startswith("_"):
196
+ # Metadata (e.g., _schema_version, _comment) -- ignore.
197
+ continue
198
+ if key in _LEAF_MATCHERS:
199
+ results.append(_evaluate_leaf(key, value, platform_info))
200
+ elif key == "all":
201
+ results.append(_evaluate_all(value, platform_info))
202
+ elif key == "any":
203
+ results.append(_evaluate_any(value, platform_info))
204
+ else:
205
+ raise ConditionSyntaxError(
206
+ f"unknown condition key: {key!r}. "
207
+ f"Valid keys: {sorted(_LEAF_MATCHERS | _COMBINATORS)}"
208
+ )
209
+
210
+ # Multiple keys in same dict are AND'd. Empty results (only metadata keys) -> True.
211
+ return all(results) if results else True