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.
- dazzlecmd_lib/__init__.py +61 -0
- dazzlecmd_lib/_version.py +92 -0
- dazzlecmd_lib/aggregator_config.py +453 -0
- dazzlecmd_lib/cli_helpers.py +180 -0
- dazzlecmd_lib/colors.py +228 -0
- dazzlecmd_lib/conditions.py +211 -0
- dazzlecmd_lib/config.py +194 -0
- dazzlecmd_lib/contexts.py +1198 -0
- dazzlecmd_lib/continuum.py +22 -0
- dazzlecmd_lib/core/__init__.py +124 -0
- dazzlecmd_lib/core/links/__init__.py +195 -0
- dazzlecmd_lib/core/links/_detect.py +711 -0
- dazzlecmd_lib/core/safedel/__init__.py +79 -0
- dazzlecmd_lib/core/safedel/_classifier.py +331 -0
- dazzlecmd_lib/core/safedel/_platform.py +508 -0
- dazzlecmd_lib/core/safedel/_recover.py +658 -0
- dazzlecmd_lib/core/safedel/_store.py +565 -0
- dazzlecmd_lib/core/safedel/_timepattern.py +390 -0
- dazzlecmd_lib/core/safedel/_volumes.py +449 -0
- dazzlecmd_lib/core/safedel/_zones.py +350 -0
- dazzlecmd_lib/default_meta_commands.py +2359 -0
- dazzlecmd_lib/engine.py +2076 -0
- dazzlecmd_lib/entity.py +468 -0
- dazzlecmd_lib/loader.py +546 -0
- dazzlecmd_lib/meta_command_registry.py +265 -0
- dazzlecmd_lib/mode.py +1566 -0
- dazzlecmd_lib/paths.py +140 -0
- dazzlecmd_lib/platform_detect.py +253 -0
- dazzlecmd_lib/platform_resolve.py +146 -0
- dazzlecmd_lib/registry.py +1379 -0
- dazzlecmd_lib/reserved.py +62 -0
- dazzlecmd_lib/resolution_context.py +73 -0
- dazzlecmd_lib/resolution_trace.py +92 -0
- dazzlecmd_lib/schema_version.py +63 -0
- dazzlecmd_lib/setup_resolve.py +238 -0
- dazzlecmd_lib/states.py +322 -0
- dazzlecmd_lib/templates/__with__/docker-deploy/Dockerfile.tmpl +11 -0
- dazzlecmd_lib/templates/__with__/docker-test/Dockerfile.test.tmpl +10 -0
- dazzlecmd_lib/templates/__with__/docker-test/docker-compose.test.yml.tmpl +7 -0
- dazzlecmd_lib/templates/aggregator/.gitignore.tmpl +9 -0
- dazzlecmd_lib/templates/aggregator/README.md.tmpl +30 -0
- dazzlecmd_lib/templates/aggregator/aggregator.json.tmpl +19 -0
- dazzlecmd_lib/templates/aggregator/pyproject.toml.tmpl +29 -0
- dazzlecmd_lib/templates/aggregator/src/{name_underscore}/__init__.py.tmpl +3 -0
- dazzlecmd_lib/templates/aggregator/src/{name_underscore}/_version.py.tmpl +7 -0
- dazzlecmd_lib/templates/aggregator/src/{name_underscore}/cli.py.tmpl +42 -0
- dazzlecmd_lib/templates/aggregator/tests/test_cli_smoke.py.tmpl +17 -0
- dazzlecmd_lib/templates/bash/.dazzlecmd.json.tmpl +25 -0
- dazzlecmd_lib/templates/bash/{name}.sh.tmpl +10 -0
- dazzlecmd_lib/templates/binary/.dazzlecmd.json.tmpl +25 -0
- dazzlecmd_lib/templates/binary/README.md.tmpl +59 -0
- dazzlecmd_lib/templates/c_cpp/.dazzlecmd.json.tmpl +29 -0
- dazzlecmd_lib/templates/c_cpp/Makefile.tmpl +11 -0
- dazzlecmd_lib/templates/c_cpp/main.c.tmpl +15 -0
- dazzlecmd_lib/templates/cmd/.dazzlecmd.json.tmpl +25 -0
- dazzlecmd_lib/templates/cmd/{name}.cmd.tmpl +11 -0
- dazzlecmd_lib/templates/docker/.dazzlecmd.json.tmpl +29 -0
- dazzlecmd_lib/templates/docker/Dockerfile.tmpl +12 -0
- dazzlecmd_lib/templates/generic/.dazzlecmd.json.tmpl +25 -0
- dazzlecmd_lib/templates/generic/README.md.tmpl +67 -0
- dazzlecmd_lib/templates/node/.dazzlecmd.json.tmpl +28 -0
- dazzlecmd_lib/templates/node/index.js.tmpl +8 -0
- dazzlecmd_lib/templates/node/package.json.tmpl +13 -0
- dazzlecmd_lib/templates/powershell/.dazzlecmd.json.tmpl +25 -0
- dazzlecmd_lib/templates/powershell/{name}.ps1.tmpl +12 -0
- dazzlecmd_lib/templates/python/.dazzlecmd.json.tmpl +25 -0
- dazzlecmd_lib/templates/python/__full__/.dazzlecmd.json.tmpl +30 -0
- dazzlecmd_lib/templates/python/__full__/README.md.tmpl +32 -0
- dazzlecmd_lib/templates/python/__full__/dz_setup.py.tmpl +235 -0
- dazzlecmd_lib/templates/python/__full__/requirements.txt.tmpl +7 -0
- dazzlecmd_lib/templates/python/__full__/tests/test_{name_underscore}.py.tmpl +18 -0
- dazzlecmd_lib/templates/python/{name_underscore}.py.tmpl +19 -0
- dazzlecmd_lib/templates/repokit_fallback/CONTRIBUTING.md.tmpl +9 -0
- dazzlecmd_lib/templates/repokit_fallback/LICENSE.tmpl +22 -0
- dazzlecmd_lib/templates/rust/.dazzlecmd.json.tmpl +29 -0
- dazzlecmd_lib/templates/rust/Cargo.toml.tmpl +11 -0
- dazzlecmd_lib/templates/rust/src/main.rs.tmpl +9 -0
- dazzlecmd_lib/templates.py +197 -0
- dazzlecmd_lib/testing.py +82 -0
- dazzlecmd_lib/user_overrides.py +131 -0
- dazzlecmd_lib-0.8.55.dist-info/METADATA +117 -0
- dazzlecmd_lib-0.8.55.dist-info/RECORD +85 -0
- dazzlecmd_lib-0.8.55.dist-info/WHEEL +5 -0
- dazzlecmd_lib-0.8.55.dist-info/licenses/LICENSE +674 -0
- 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)
|
dazzlecmd_lib/colors.py
ADDED
|
@@ -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
|