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,61 @@
|
|
|
1
|
+
"""dazzlecmd-lib -- Engine library for dazzlecmd-pattern tool aggregators.
|
|
2
|
+
|
|
3
|
+
Build your own dz-pattern CLI in ~10 lines:
|
|
4
|
+
|
|
5
|
+
from dazzlecmd_lib import AggregatorEngine
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
engine = AggregatorEngine(
|
|
9
|
+
name="my-tools",
|
|
10
|
+
command="mt",
|
|
11
|
+
tools_dir="tools",
|
|
12
|
+
manifest=".mt.json",
|
|
13
|
+
version_info=("1.0", "1.0.0_main_1"),
|
|
14
|
+
)
|
|
15
|
+
return engine.run()
|
|
16
|
+
|
|
17
|
+
That gets you default ``mt list``, ``mt info <tool>``, ``mt kit``,
|
|
18
|
+
``mt version``, ``mt tree``, ``mt setup``. Customize via:
|
|
19
|
+
|
|
20
|
+
engine.meta_registry.register("mycmd", parser_factory, handler)
|
|
21
|
+
engine.meta_registry.override("list", handler=my_custom_list)
|
|
22
|
+
engine.meta_registry.unregister("tree")
|
|
23
|
+
|
|
24
|
+
Public API:
|
|
25
|
+
- AggregatorEngine: configurable CLI tool aggregator
|
|
26
|
+
- FQCNIndex: dual-index lookup for Fully Qualified Collection Names
|
|
27
|
+
- RunnerRegistry: extensible runtime dispatch (runtime types)
|
|
28
|
+
- MetaCommandRegistry: per-engine meta-command registry
|
|
29
|
+
- cli_helpers: argparse scaffolding helpers for escape-hatch paths
|
|
30
|
+
- default_meta_commands: stock list/info/kit/version/tree/setup
|
|
31
|
+
- ConfigManager: per-aggregator config reading/writing
|
|
32
|
+
- CircularDependencyError, FQCNCollisionError: exception types
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from dazzlecmd_lib._version import __version__
|
|
36
|
+
|
|
37
|
+
# Core engine + FQCN
|
|
38
|
+
from dazzlecmd_lib.engine import (
|
|
39
|
+
AggregatorEngine,
|
|
40
|
+
FQCNIndex,
|
|
41
|
+
FQCNCollisionError,
|
|
42
|
+
CircularDependencyError,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Runtime dispatch
|
|
46
|
+
from dazzlecmd_lib.registry import RunnerRegistry
|
|
47
|
+
|
|
48
|
+
# Config + meta-command machinery
|
|
49
|
+
from dazzlecmd_lib.config import ConfigManager
|
|
50
|
+
from dazzlecmd_lib.meta_command_registry import (
|
|
51
|
+
MetaCommandRegistry,
|
|
52
|
+
MetaCommandAlreadyRegisteredError,
|
|
53
|
+
MetaCommandNotRegisteredError,
|
|
54
|
+
RegistryLockedError,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# CLI helpers + defaults (available as modules; not re-exported at top level
|
|
58
|
+
# to keep the namespace clean. Import them explicitly:
|
|
59
|
+
# from dazzlecmd_lib import cli_helpers, default_meta_commands)
|
|
60
|
+
from dazzlecmd_lib import cli_helpers
|
|
61
|
+
from dazzlecmd_lib import default_meta_commands
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Version information for dazzlecmd-lib.
|
|
3
|
+
|
|
4
|
+
This file is the canonical source for version numbers.
|
|
5
|
+
The __version__ string is automatically updated by git hooks
|
|
6
|
+
with build metadata (branch, build number, date, commit hash).
|
|
7
|
+
|
|
8
|
+
Format: MAJOR.MINOR.PATCH[-PHASE]_BRANCH_BUILD-YYYYMMDD-COMMITHASH
|
|
9
|
+
Example: 0.1.0_main_1-20260101-a1b2c3d4
|
|
10
|
+
|
|
11
|
+
Version levels:
|
|
12
|
+
PROJECT_PHASE: Global project maturity (prealpha -> alpha -> beta -> stable).
|
|
13
|
+
Changes rarely, when the overall project hits a threshold.
|
|
14
|
+
PHASE: Per-MINOR feature set maturity (alpha -> beta -> "" for stable).
|
|
15
|
+
Drops when a MINOR's feature set is complete.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# Version components - edit these for version bumps
|
|
19
|
+
MAJOR = 0
|
|
20
|
+
MINOR = 8
|
|
21
|
+
PATCH = 55
|
|
22
|
+
PHASE = "" # Per-MINOR feature set: None, "alpha", "beta", "rc1", etc.
|
|
23
|
+
|
|
24
|
+
# Project-level phase (independent of version phase)
|
|
25
|
+
PROJECT_PHASE = "" # "prealpha", "alpha", "beta", "stable", or ""
|
|
26
|
+
|
|
27
|
+
# Auto-updated by git hooks - do not edit manually
|
|
28
|
+
__version__ = "0.8.55_main_5-20260624-f9cdb549"
|
|
29
|
+
__app_name__ = "dazzlecmd-lib"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_version():
|
|
33
|
+
"""Return the full version string including branch and build info."""
|
|
34
|
+
return __version__
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_base_version():
|
|
38
|
+
"""Return the semantic version string (MAJOR.MINOR.PATCH[-PHASE])."""
|
|
39
|
+
if "_" in __version__:
|
|
40
|
+
return __version__.split("_")[0]
|
|
41
|
+
base = f"{MAJOR}.{MINOR}.{PATCH}"
|
|
42
|
+
if PHASE:
|
|
43
|
+
base = f"{base}-{PHASE}"
|
|
44
|
+
return base
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_display_version():
|
|
48
|
+
"""Return a human-friendly version string with project phase.
|
|
49
|
+
|
|
50
|
+
Example: 'PREALPHA 0.1.0-alpha' or 'BETA 0.5.1' or '1.0.0'
|
|
51
|
+
"""
|
|
52
|
+
base = get_base_version()
|
|
53
|
+
if PROJECT_PHASE and PROJECT_PHASE != "stable":
|
|
54
|
+
return f"{PROJECT_PHASE.upper()} {base}"
|
|
55
|
+
return base
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_pip_version():
|
|
59
|
+
"""
|
|
60
|
+
Return PEP 440 compliant version for pip/setuptools.
|
|
61
|
+
|
|
62
|
+
Converts our version format to PEP 440:
|
|
63
|
+
- Main branch: 0.1.0_main_3-20260404-hash -> 0.1.0
|
|
64
|
+
- Dev branch: 0.1.0_dev_3-20260404-hash -> 0.1.0.dev3
|
|
65
|
+
- Alpha: 0.1.0-alpha_main_3 -> 0.1.0a0
|
|
66
|
+
"""
|
|
67
|
+
base = f"{MAJOR}.{MINOR}.{PATCH}"
|
|
68
|
+
|
|
69
|
+
# Map phase to PEP 440 pre-release segment
|
|
70
|
+
phase_map = {"alpha": "a0", "beta": "b0"}
|
|
71
|
+
if PHASE:
|
|
72
|
+
base += phase_map.get(PHASE, PHASE)
|
|
73
|
+
|
|
74
|
+
if "_" not in __version__:
|
|
75
|
+
return base
|
|
76
|
+
|
|
77
|
+
parts = __version__.split("_")
|
|
78
|
+
branch = parts[1] if len(parts) > 1 else "unknown"
|
|
79
|
+
|
|
80
|
+
if branch == "main":
|
|
81
|
+
return base
|
|
82
|
+
else:
|
|
83
|
+
build_info = "_".join(parts[2:]) if len(parts) > 2 else ""
|
|
84
|
+
build_num = build_info.split("-")[0] if "-" in build_info else "0"
|
|
85
|
+
return f"{base}.dev{build_num}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# For convenience in imports
|
|
89
|
+
VERSION = get_version()
|
|
90
|
+
BASE_VERSION = get_base_version()
|
|
91
|
+
PIP_VERSION = get_pip_version()
|
|
92
|
+
DISPLAY_VERSION = get_display_version()
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Declarative aggregator configuration via ``aggregator.json``.
|
|
2
|
+
|
|
3
|
+
Every dazzlecmd-lib consumer (dazzlecmd, wtf-windows, amdead, etc.) declares
|
|
4
|
+
its identity and layout in an ``aggregator.json`` file at its project root.
|
|
5
|
+
The library reads this file to construct an ``AggregatorEngine`` with the
|
|
6
|
+
right parameters, replacing the previous pattern of hand-coded
|
|
7
|
+
``AggregatorEngine(name=..., command=..., tools_dir=..., ...)`` calls in
|
|
8
|
+
each aggregator's main module.
|
|
9
|
+
|
|
10
|
+
The file is **required** -- no backward-compat fallback. An aggregator
|
|
11
|
+
without ``aggregator.json`` at its project root cannot construct an engine
|
|
12
|
+
via the canonical ``AggregatorEngine.from_project(project_root)`` path.
|
|
13
|
+
|
|
14
|
+
Schema (v1)::
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
"_schema_version": 1,
|
|
18
|
+
"name": "dazzlecmd",
|
|
19
|
+
"command": "dz",
|
|
20
|
+
"description": "one-line description",
|
|
21
|
+
"tools_dir": "projects",
|
|
22
|
+
"kits_dir": "kits",
|
|
23
|
+
"manifest_name": ".dazzlecmd.json",
|
|
24
|
+
"enabled_meta_commands": ["list", "info", "kit", "tree", "setup",
|
|
25
|
+
"version", "add", "mode", "new"],
|
|
26
|
+
"extra_reserved_commands": ["find", "git", ...],
|
|
27
|
+
"schema": {
|
|
28
|
+
"remote_url_paths": ["source.url", "lifecycle.remote"],
|
|
29
|
+
"lifecycle_path": "lifecycle"
|
|
30
|
+
},
|
|
31
|
+
"discovery": {
|
|
32
|
+
"tool_patterns": ["${tools_dir}/*/*"],
|
|
33
|
+
"scan_hidden": false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
Field semantics:
|
|
38
|
+
|
|
39
|
+
- ``_schema_version``: integer; ``1`` for this format. Forward-compat
|
|
40
|
+
hook -- future library versions can migrate older files.
|
|
41
|
+
- ``name``: human-readable aggregator name (appears in ``--help``).
|
|
42
|
+
- ``command``: CLI command name (``dz``, ``wtf``, ``amdead``). Substituted
|
|
43
|
+
into user-facing strings (no more hardcoded ``"dz"``).
|
|
44
|
+
- ``description``: one-line description for ``--help``.
|
|
45
|
+
- ``tools_dir``: relative directory name where tool projects live. Replaces
|
|
46
|
+
the hardcoded ``"projects/"`` literals throughout the codebase
|
|
47
|
+
(issue #37 BLOCKERs F2/F3/F4/F8).
|
|
48
|
+
- ``kits_dir``: relative directory name for kit registry pointers.
|
|
49
|
+
- ``manifest_name``: per-tool manifest filename
|
|
50
|
+
(``.dazzlecmd.json`` / ``.wtf.json`` / ``.amdead.json`` / ...).
|
|
51
|
+
- ``enabled_meta_commands``: list of meta-command names this aggregator
|
|
52
|
+
registers as CLI subcommands. Subset of ``DEFAULT_RESERVED_COMMANDS``.
|
|
53
|
+
Defaults to ``DEFAULT_META_COMMANDS_USER`` when omitted.
|
|
54
|
+
- ``extra_reserved_commands``: additional names reserved beyond
|
|
55
|
+
``DEFAULT_RESERVED_COMMANDS``. Use sparingly -- only for names that
|
|
56
|
+
the aggregator wants to keep available as future meta-commands but
|
|
57
|
+
isn't using yet. Existing tools' names should NOT appear here (the
|
|
58
|
+
library would silently skip them during discovery; pre-v0.7.51 had
|
|
59
|
+
this regression in the dazzlecmd fixture).
|
|
60
|
+
- ``schema.remote_url_paths``: ordered list of dotted paths the library
|
|
61
|
+
tries when resolving a tool's remote URL. Each entry is a fallback.
|
|
62
|
+
Replaces hardcoded ``project["source"]["url"]`` / ``project["lifecycle"]["remote"]``
|
|
63
|
+
(BLOCKER F7 schema decoupling).
|
|
64
|
+
- ``schema.lifecycle_path``: dotted path for the lifecycle metadata block.
|
|
65
|
+
- ``discovery.tool_patterns``: list of glob patterns for finding tools
|
|
66
|
+
beyond the standard ``<tools_dir>/<ns>/<tool>`` layout. ``${tools_dir}``
|
|
67
|
+
is interpolated from the same JSON.
|
|
68
|
+
- ``discovery.scan_hidden``: whether ``.dotdirs`` are scanned (default
|
|
69
|
+
``false``).
|
|
70
|
+
|
|
71
|
+
Subprocess environment contract
|
|
72
|
+
-------------------------------
|
|
73
|
+
|
|
74
|
+
The library injects the following environment variables before invoking
|
|
75
|
+
any tool subprocess. Tool scripts (PowerShell, bash, Python, etc.) can
|
|
76
|
+
read these to adapt branding strings, log paths, and behavior to the
|
|
77
|
+
host aggregator without each aggregator hand-rolling its own bridge:
|
|
78
|
+
|
|
79
|
+
- ``DZ_APP_NAME``: the engine's ``name`` field (e.g., ``"dazzlecmd"``,
|
|
80
|
+
``"wtf-windows"``, ``"amdead"``). Reflects engine IDENTITY, not
|
|
81
|
+
per-invocation context. Set at every dispatch site.
|
|
82
|
+
- ``DZ_COMMAND``: the engine's ``command`` field (e.g., ``"dz"``,
|
|
83
|
+
``"wtf"``, ``"amdead"``). Same semantics as ``DZ_APP_NAME``.
|
|
84
|
+
- ``DZ_CANONICAL_FQCN``: canonical FQCN of the dispatched tool (e.g.,
|
|
85
|
+
``"core:rn"``). Set only when a ``ResolutionContext`` is supplied to
|
|
86
|
+
the dispatcher. Tools writing persistent state (caches, logs,
|
|
87
|
+
checkpoints) MUST key on this to avoid divergent state across
|
|
88
|
+
alias-vs-canonical-vs-short-name invocation paths (v0.7.28).
|
|
89
|
+
- ``DZ_INVOKED_FQCN``: what the user actually typed (alias, short name,
|
|
90
|
+
or canonical). Equal to ``DZ_CANONICAL_FQCN`` for canonical
|
|
91
|
+
invocations. Same gating as ``DZ_CANONICAL_FQCN``.
|
|
92
|
+
|
|
93
|
+
All four vars are restored to their pre-dispatch values in a
|
|
94
|
+
``finally`` block after the subprocess completes, so dz's own process
|
|
95
|
+
environment is not permanently modified.
|
|
96
|
+
|
|
97
|
+
Nested-aggregator behavior: when one aggregator embeds another as a kit
|
|
98
|
+
(e.g., dazzlecmd embeds wtf-windows so ``dz wtf:locked`` dispatches a
|
|
99
|
+
wtf tool), ``DZ_APP_NAME`` and ``DZ_COMMAND`` reflect the **ROOT
|
|
100
|
+
ENGINE** (``"dazzlecmd"`` / ``"dz"``), not the kit's owning aggregator.
|
|
101
|
+
This matches the user's experience (they typed ``dz``, not ``wtf``) but
|
|
102
|
+
tool authors should not assume their original aggregator's identity is
|
|
103
|
+
present in these vars at runtime.
|
|
104
|
+
|
|
105
|
+
A ``DZ_PROJECT_ROOT`` env var was considered (would expose the
|
|
106
|
+
aggregator's project_root absolute path so tools can resolve log
|
|
107
|
+
destinations etc. relative to it) but is deferred to a follow-up issue.
|
|
108
|
+
It introduces tool-to-aggregator filesystem coupling that warrants a
|
|
109
|
+
separate design pass. Tools needing the project root today can read
|
|
110
|
+
``DZ_APP_NAME`` and look the project up via ``find_aggregator_root()``
|
|
111
|
+
or via configuration.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
from __future__ import annotations
|
|
115
|
+
|
|
116
|
+
import json
|
|
117
|
+
import os
|
|
118
|
+
import warnings
|
|
119
|
+
from typing import List, Optional, Set, Tuple
|
|
120
|
+
|
|
121
|
+
from pydantic import BaseModel, ConfigDict
|
|
122
|
+
|
|
123
|
+
from dazzlecmd_lib.reserved import (
|
|
124
|
+
DEFAULT_META_COMMANDS_USER,
|
|
125
|
+
DEFAULT_RESERVED_COMMANDS,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
AGGREGATOR_CONFIG_FILENAME = "aggregator.json"
|
|
130
|
+
CURRENT_SCHEMA_VERSION = 1
|
|
131
|
+
|
|
132
|
+
# Defaults referenced by BOTH the model fields and the parse helpers, so the
|
|
133
|
+
# parse helpers don't reach into model internals (the old code read
|
|
134
|
+
# ``__dataclass_fields__[...].default``, which Pydantic models don't expose).
|
|
135
|
+
_DEFAULT_REMOTE_URL_PATHS = ("source.url", "lifecycle.remote")
|
|
136
|
+
_DEFAULT_LIFECYCLE_PATH = "lifecycle"
|
|
137
|
+
_DEFAULT_TOOL_PATTERNS = ("${tools_dir}/*/*",)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class AggregatorConfigError(Exception):
|
|
141
|
+
"""Raised when ``aggregator.json`` is missing, malformed, or invalid."""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def find_aggregator_root(start_path=None, max_depth=12):
|
|
145
|
+
"""Walk up from ``start_path`` to find a directory with ``aggregator.json``.
|
|
146
|
+
|
|
147
|
+
The first ancestor (including ``start_path`` itself) containing the
|
|
148
|
+
canonical marker file is the project root. This is the new
|
|
149
|
+
discovery strategy (Phase 3.5 T1-M): the presence of
|
|
150
|
+
``aggregator.json`` itself defines the project root, instead of
|
|
151
|
+
requiring tools_dir + kits_dir hardcoded knowledge to find it.
|
|
152
|
+
|
|
153
|
+
**Entry points MUST pass an explicit ``start_path`` anchored to their
|
|
154
|
+
own package location** -- typically
|
|
155
|
+
``os.path.dirname(os.path.abspath(__file__))`` of the aggregator's
|
|
156
|
+
``cli`` module. This pins the aggregator's identity to *which package
|
|
157
|
+
it is*, not *where it is invoked from*. An aggregator that calls this
|
|
158
|
+
bare (relying on the cwd default) will impersonate whatever other
|
|
159
|
+
aggregator the user happens to be standing in: running ``dz`` from
|
|
160
|
+
inside a ``wtf-windows`` checkout would load wtf's ``aggregator.json``
|
|
161
|
+
and ``dz`` would become ``wtf``. The package anchor avoids that
|
|
162
|
+
because the entry point's ``__file__`` is fixed at install time.
|
|
163
|
+
|
|
164
|
+
When ``start_path`` is ``None`` the walk starts from ``os.getcwd()``.
|
|
165
|
+
This "find from the current directory" behavior is for tests and
|
|
166
|
+
ad-hoc tooling that genuinely want cwd-relative discovery -- NOT for
|
|
167
|
+
production entry points. (Earlier revisions also fell back to this
|
|
168
|
+
module's own ``__file__``; that was removed because the library lives
|
|
169
|
+
co-located with dazzlecmd in dev mode, so the fallback made every
|
|
170
|
+
aggregator that called this bare resolve to dazzlecmd.)
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
start_path: Directory to start the walk from. Production entry
|
|
174
|
+
points pass their package's ``__file__`` directory. Defaults
|
|
175
|
+
to ``os.getcwd()`` (tests / ad-hoc cwd-relative discovery).
|
|
176
|
+
max_depth: Maximum number of parent directories to walk before
|
|
177
|
+
giving up. Defaults to 12 (deep enough for any sane layout,
|
|
178
|
+
shallow enough to terminate quickly when the marker is
|
|
179
|
+
absent).
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Absolute path to the project root, or ``None`` if no
|
|
183
|
+
``aggregator.json`` was found within ``max_depth`` ancestors of
|
|
184
|
+
the starting point.
|
|
185
|
+
"""
|
|
186
|
+
if start_path is None:
|
|
187
|
+
start_path = os.getcwd()
|
|
188
|
+
current = os.path.abspath(start_path)
|
|
189
|
+
for _ in range(max_depth + 1):
|
|
190
|
+
if os.path.isfile(os.path.join(current, AGGREGATOR_CONFIG_FILENAME)):
|
|
191
|
+
return current
|
|
192
|
+
parent = os.path.dirname(current)
|
|
193
|
+
if parent == current:
|
|
194
|
+
return None
|
|
195
|
+
current = parent
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class AggregatorSchema(BaseModel):
|
|
200
|
+
"""How the engine reads tool-manifest values.
|
|
201
|
+
|
|
202
|
+
Decouples library code from any single manifest format
|
|
203
|
+
(.dazzlecmd.json vs .wtf.json vs .amdead.json).
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
model_config = ConfigDict(frozen=True)
|
|
207
|
+
|
|
208
|
+
remote_url_paths: Tuple[str, ...] = _DEFAULT_REMOTE_URL_PATHS
|
|
209
|
+
lifecycle_path: str = _DEFAULT_LIFECYCLE_PATH
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class AggregatorDiscovery(BaseModel):
|
|
213
|
+
"""Glob patterns + flags for finding tools beyond the standard layout."""
|
|
214
|
+
|
|
215
|
+
model_config = ConfigDict(frozen=True)
|
|
216
|
+
|
|
217
|
+
tool_patterns: Tuple[str, ...] = _DEFAULT_TOOL_PATTERNS
|
|
218
|
+
scan_hidden: bool = False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# The ``schema`` field name deliberately shadows Pydantic's deprecated
|
|
222
|
+
# ``BaseModel.schema()`` (callers use ``config.schema`` throughout). Suppress
|
|
223
|
+
# the one benign class-definition UserWarning so it never reaches a ``dz``
|
|
224
|
+
# user's stderr (which would also break the byte-identical output baseline).
|
|
225
|
+
# The model functions correctly -- verified empirically on pydantic 2.11.7.
|
|
226
|
+
with warnings.catch_warnings():
|
|
227
|
+
warnings.filterwarnings(
|
|
228
|
+
"ignore",
|
|
229
|
+
message=r'Field name "schema".*shadows an attribute.*',
|
|
230
|
+
category=UserWarning,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
class AggregatorConfig(BaseModel):
|
|
234
|
+
"""Parsed ``aggregator.json``.
|
|
235
|
+
|
|
236
|
+
Constructed by ``load_aggregator_config(project_root)``. Frozen so it
|
|
237
|
+
can be safely passed around as engine state.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
model_config = ConfigDict(frozen=True)
|
|
241
|
+
|
|
242
|
+
project_root: str
|
|
243
|
+
schema_version: int
|
|
244
|
+
name: str
|
|
245
|
+
command: str
|
|
246
|
+
description: str
|
|
247
|
+
tools_dir: str
|
|
248
|
+
kits_dir: str
|
|
249
|
+
manifest_name: str
|
|
250
|
+
enabled_meta_commands: frozenset
|
|
251
|
+
reserved_commands: frozenset
|
|
252
|
+
schema: AggregatorSchema
|
|
253
|
+
discovery: AggregatorDiscovery
|
|
254
|
+
# Reserved slot (v0.8.0): the "skin" half of same-bones -- per-aggregator
|
|
255
|
+
# presentation/projection config (visibility frames, render hints). Read
|
|
256
|
+
# but not yet consumed; the grouping/ungrouping projection layer that
|
|
257
|
+
# interprets it lands in a later release. Kept as a plain dict so the
|
|
258
|
+
# eventual schema can evolve without a config-format migration.
|
|
259
|
+
presentation: Optional[dict] = None
|
|
260
|
+
|
|
261
|
+
def resolved_discovery_patterns(self) -> Tuple[str, ...]:
|
|
262
|
+
"""Return ``discovery.tool_patterns`` with ``${tools_dir}`` expanded."""
|
|
263
|
+
return tuple(
|
|
264
|
+
pattern.replace("${tools_dir}", self.tools_dir)
|
|
265
|
+
for pattern in self.discovery.tool_patterns
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _require(data: dict, key: str, source: str) -> object:
|
|
270
|
+
if key not in data:
|
|
271
|
+
raise AggregatorConfigError(
|
|
272
|
+
f"{source}: required key '{key}' missing"
|
|
273
|
+
)
|
|
274
|
+
return data[key]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _str_field(data: dict, key: str, source: str) -> str:
|
|
278
|
+
value = _require(data, key, source)
|
|
279
|
+
if not isinstance(value, str) or not value.strip():
|
|
280
|
+
raise AggregatorConfigError(
|
|
281
|
+
f"{source}: '{key}' must be a non-empty string (got {value!r})"
|
|
282
|
+
)
|
|
283
|
+
return value
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _optional_str(data: dict, key: str, default: str) -> str:
|
|
287
|
+
value = data.get(key, default)
|
|
288
|
+
if not isinstance(value, str):
|
|
289
|
+
raise AggregatorConfigError(
|
|
290
|
+
f"'{key}' must be a string (got {value!r})"
|
|
291
|
+
)
|
|
292
|
+
return value
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _optional_list_of_str(data: dict, key: str, default: List[str],
|
|
296
|
+
source: str) -> List[str]:
|
|
297
|
+
value = data.get(key, default)
|
|
298
|
+
if not isinstance(value, list):
|
|
299
|
+
raise AggregatorConfigError(
|
|
300
|
+
f"{source}: '{key}' must be a list (got {type(value).__name__})"
|
|
301
|
+
)
|
|
302
|
+
for item in value:
|
|
303
|
+
if not isinstance(item, str):
|
|
304
|
+
raise AggregatorConfigError(
|
|
305
|
+
f"{source}: '{key}' entries must be strings (got {item!r})"
|
|
306
|
+
)
|
|
307
|
+
return list(value)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _parse_schema(data: dict, source: str) -> AggregatorSchema:
|
|
311
|
+
block = data.get("schema") or {}
|
|
312
|
+
if not isinstance(block, dict):
|
|
313
|
+
raise AggregatorConfigError(
|
|
314
|
+
f"{source}: 'schema' must be an object (got {type(block).__name__})"
|
|
315
|
+
)
|
|
316
|
+
remote_paths = _optional_list_of_str(
|
|
317
|
+
block, "remote_url_paths",
|
|
318
|
+
list(_DEFAULT_REMOTE_URL_PATHS),
|
|
319
|
+
f"{source}: schema",
|
|
320
|
+
)
|
|
321
|
+
lifecycle_path = _optional_str(
|
|
322
|
+
block, "lifecycle_path",
|
|
323
|
+
_DEFAULT_LIFECYCLE_PATH,
|
|
324
|
+
)
|
|
325
|
+
return AggregatorSchema(
|
|
326
|
+
remote_url_paths=tuple(remote_paths),
|
|
327
|
+
lifecycle_path=lifecycle_path,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _parse_discovery(data: dict, source: str) -> AggregatorDiscovery:
|
|
332
|
+
block = data.get("discovery") or {}
|
|
333
|
+
if not isinstance(block, dict):
|
|
334
|
+
raise AggregatorConfigError(
|
|
335
|
+
f"{source}: 'discovery' must be an object (got {type(block).__name__})"
|
|
336
|
+
)
|
|
337
|
+
patterns = _optional_list_of_str(
|
|
338
|
+
block, "tool_patterns",
|
|
339
|
+
list(_DEFAULT_TOOL_PATTERNS),
|
|
340
|
+
f"{source}: discovery",
|
|
341
|
+
)
|
|
342
|
+
scan_hidden = block.get("scan_hidden", False)
|
|
343
|
+
if not isinstance(scan_hidden, bool):
|
|
344
|
+
raise AggregatorConfigError(
|
|
345
|
+
f"{source}: discovery.scan_hidden must be a boolean "
|
|
346
|
+
f"(got {scan_hidden!r})"
|
|
347
|
+
)
|
|
348
|
+
return AggregatorDiscovery(
|
|
349
|
+
tool_patterns=tuple(patterns),
|
|
350
|
+
scan_hidden=scan_hidden,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def load_aggregator_config(project_root: str) -> AggregatorConfig:
|
|
355
|
+
"""Load and validate ``aggregator.json`` from ``project_root``.
|
|
356
|
+
|
|
357
|
+
Raises ``AggregatorConfigError`` if the file is missing, unreadable,
|
|
358
|
+
not valid JSON, has an unknown ``_schema_version``, or fails field
|
|
359
|
+
validation.
|
|
360
|
+
"""
|
|
361
|
+
project_root = os.path.abspath(project_root)
|
|
362
|
+
config_path = os.path.join(project_root, AGGREGATOR_CONFIG_FILENAME)
|
|
363
|
+
source = f"{config_path}"
|
|
364
|
+
|
|
365
|
+
if not os.path.isfile(config_path):
|
|
366
|
+
raise AggregatorConfigError(
|
|
367
|
+
f"aggregator.json not found at {config_path}. "
|
|
368
|
+
f"Every dazzlecmd-lib aggregator must declare an aggregator.json "
|
|
369
|
+
f"at its project root. See docs/guides/aggregator-config.md for "
|
|
370
|
+
f"the schema."
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
375
|
+
data = json.load(f)
|
|
376
|
+
except OSError as exc:
|
|
377
|
+
raise AggregatorConfigError(
|
|
378
|
+
f"Could not read {source}: {exc}"
|
|
379
|
+
) from exc
|
|
380
|
+
except json.JSONDecodeError as exc:
|
|
381
|
+
raise AggregatorConfigError(
|
|
382
|
+
f"{source} is not valid JSON: {exc}"
|
|
383
|
+
) from exc
|
|
384
|
+
|
|
385
|
+
if not isinstance(data, dict):
|
|
386
|
+
raise AggregatorConfigError(
|
|
387
|
+
f"{source}: top-level value must be a JSON object "
|
|
388
|
+
f"(got {type(data).__name__})"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
schema_version = data.get("_schema_version", 1)
|
|
392
|
+
if not isinstance(schema_version, int):
|
|
393
|
+
raise AggregatorConfigError(
|
|
394
|
+
f"{source}: '_schema_version' must be an integer "
|
|
395
|
+
f"(got {schema_version!r})"
|
|
396
|
+
)
|
|
397
|
+
if schema_version != CURRENT_SCHEMA_VERSION:
|
|
398
|
+
raise AggregatorConfigError(
|
|
399
|
+
f"{source}: unsupported _schema_version {schema_version}; "
|
|
400
|
+
f"this library supports version {CURRENT_SCHEMA_VERSION}"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
name = _str_field(data, "name", source)
|
|
404
|
+
command = _str_field(data, "command", source)
|
|
405
|
+
tools_dir = _str_field(data, "tools_dir", source)
|
|
406
|
+
kits_dir = _str_field(data, "kits_dir", source)
|
|
407
|
+
manifest_name = _str_field(data, "manifest_name", source)
|
|
408
|
+
description = _optional_str(data, "description", f"{name} - tool aggregator")
|
|
409
|
+
|
|
410
|
+
enabled_list = _optional_list_of_str(
|
|
411
|
+
data, "enabled_meta_commands",
|
|
412
|
+
list(DEFAULT_META_COMMANDS_USER),
|
|
413
|
+
source,
|
|
414
|
+
)
|
|
415
|
+
enabled_meta_commands = frozenset(enabled_list)
|
|
416
|
+
unknown = enabled_meta_commands - DEFAULT_RESERVED_COMMANDS
|
|
417
|
+
if unknown:
|
|
418
|
+
raise AggregatorConfigError(
|
|
419
|
+
f"{source}: 'enabled_meta_commands' contains names not in the "
|
|
420
|
+
f"reserved set: {sorted(unknown)}. Allowed: "
|
|
421
|
+
f"{sorted(DEFAULT_RESERVED_COMMANDS)}"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
extra_reserved_list = _optional_list_of_str(
|
|
425
|
+
data, "extra_reserved_commands", [], source,
|
|
426
|
+
)
|
|
427
|
+
reserved_commands = DEFAULT_RESERVED_COMMANDS | frozenset(extra_reserved_list)
|
|
428
|
+
|
|
429
|
+
schema = _parse_schema(data, source)
|
|
430
|
+
discovery = _parse_discovery(data, source)
|
|
431
|
+
|
|
432
|
+
presentation = data.get("presentation")
|
|
433
|
+
if presentation is not None and not isinstance(presentation, dict):
|
|
434
|
+
raise AggregatorConfigError(
|
|
435
|
+
f"{source}: 'presentation' must be an object "
|
|
436
|
+
f"(got {type(presentation).__name__})"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
return AggregatorConfig(
|
|
440
|
+
project_root=project_root,
|
|
441
|
+
schema_version=schema_version,
|
|
442
|
+
name=name,
|
|
443
|
+
command=command,
|
|
444
|
+
description=description,
|
|
445
|
+
tools_dir=tools_dir,
|
|
446
|
+
kits_dir=kits_dir,
|
|
447
|
+
manifest_name=manifest_name,
|
|
448
|
+
enabled_meta_commands=enabled_meta_commands,
|
|
449
|
+
reserved_commands=reserved_commands,
|
|
450
|
+
schema=schema,
|
|
451
|
+
discovery=discovery,
|
|
452
|
+
presentation=presentation,
|
|
453
|
+
)
|