mammoth-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mammoth_cli/__init__.py +14 -0
- mammoth_cli/__main__.py +27 -0
- mammoth_cli/app.py +565 -0
- mammoth_cli/bundled_skill/mammoth-cli/SKILL.md +109 -0
- mammoth_cli/bundled_skill/mammoth-cli/references/auth.md +27 -0
- mammoth_cli/bundled_skill/mammoth-cli/references/input.md +24 -0
- mammoth_cli/bundled_skill/mammoth-cli/references/jobs-drafts.md +35 -0
- mammoth_cli/bundled_skill/mammoth-cli/references/machine-output.md +30 -0
- mammoth_cli/bundled_skill/mammoth-cli/references/recovery.md +28 -0
- mammoth_cli/bundled_skill/mammoth-cli/references/safety.md +26 -0
- mammoth_cli/commands/__init__.py +29 -0
- mammoth_cli/commands/activity.py +123 -0
- mammoth_cli/commands/addon.py +173 -0
- mammoth_cli/commands/agent.py +199 -0
- mammoth_cli/commands/ai.py +274 -0
- mammoth_cli/commands/annotation.py +166 -0
- mammoth_cli/commands/auth.py +567 -0
- mammoth_cli/commands/automation.py +245 -0
- mammoth_cli/commands/batch.py +204 -0
- mammoth_cli/commands/billing.py +462 -0
- mammoth_cli/commands/browse.py +151 -0
- mammoth_cli/commands/capability.py +43 -0
- mammoth_cli/commands/client_app.py +233 -0
- mammoth_cli/commands/completion.py +82 -0
- mammoth_cli/commands/config.py +309 -0
- mammoth_cli/commands/connector.py +464 -0
- mammoth_cli/commands/context.py +205 -0
- mammoth_cli/commands/dashboard.py +419 -0
- mammoth_cli/commands/data_app.py +239 -0
- mammoth_cli/commands/dataset.py +332 -0
- mammoth_cli/commands/doctor.py +90 -0
- mammoth_cli/commands/external_key.py +163 -0
- mammoth_cli/commands/file.py +243 -0
- mammoth_cli/commands/folder.py +224 -0
- mammoth_cli/commands/job.py +147 -0
- mammoth_cli/commands/notification.py +201 -0
- mammoth_cli/commands/parameter.py +284 -0
- mammoth_cli/commands/project.py +327 -0
- mammoth_cli/commands/registry.py +587 -0
- mammoth_cli/commands/report.py +61 -0
- mammoth_cli/commands/schedule.py +214 -0
- mammoth_cli/commands/schema.py +271 -0
- mammoth_cli/commands/skill.py +59 -0
- mammoth_cli/commands/snippet.py +200 -0
- mammoth_cli/commands/support.py +800 -0
- mammoth_cli/commands/template.py +134 -0
- mammoth_cli/commands/trash.py +115 -0
- mammoth_cli/commands/user.py +190 -0
- mammoth_cli/commands/view.py +1143 -0
- mammoth_cli/commands/view_ops.py +596 -0
- mammoth_cli/commands/webhook.py +235 -0
- mammoth_cli/commands/workflow.py +340 -0
- mammoth_cli/commands/workspace.py +359 -0
- mammoth_cli/context/__init__.py +1 -0
- mammoth_cli/context/credentials.py +223 -0
- mammoth_cli/context/endpoint.py +57 -0
- mammoth_cli/context/profiles.py +370 -0
- mammoth_cli/context/resolver.py +240 -0
- mammoth_cli/contracts/__init__.py +1 -0
- mammoth_cli/contracts/auth.py +32 -0
- mammoth_cli/errors/__init__.py +1 -0
- mammoth_cli/errors/envelope.py +144 -0
- mammoth_cli/manifest/__init__.py +1 -0
- mammoth_cli/manifest/loader.py +96 -0
- mammoth_cli/messages/__init__.py +1 -0
- mammoth_cli/output/__init__.py +1 -0
- mammoth_cli/output/envelope.py +42 -0
- mammoth_cli/output/normalize.py +84 -0
- mammoth_cli/output/policy.py +71 -0
- mammoth_cli/output/render.py +83 -0
- mammoth_cli/py.typed +0 -0
- mammoth_cli/runtime/__init__.py +0 -0
- mammoth_cli/runtime/confirm.py +124 -0
- mammoth_cli/runtime/executor.py +126 -0
- mammoth_cli/runtime/input_loader.py +140 -0
- mammoth_cli/runtime/invocation.py +87 -0
- mammoth_cli/runtime/options.py +172 -0
- mammoth_cli/runtime/session.py +82 -0
- mammoth_cli/runtime/strict.py +240 -0
- mammoth_cli/runtime/validate.py +120 -0
- mammoth_cli/services/__init__.py +1 -0
- mammoth_cli/services/argspec.py +301 -0
- mammoth_cli/services/coerce.py +172 -0
- mammoth_cli/services/conditions.py +85 -0
- mammoth_cli/services/dispatch.py +63 -0
- mammoth_cli/services/factory.py +45 -0
- mammoth_cli/services/input_fields.py +44 -0
- mammoth_cli/services/mapping.py +74 -0
- mammoth_cli/services/openapi_types.py +166 -0
- mammoth_cli/services/positionals.py +408 -0
- mammoth_cli/services/protocol.py +123 -0
- mammoth_cli/services/sdk_service.py +274 -0
- mammoth_cli/services/testing.py +153 -0
- mammoth_cli/services/type_system.py +427 -0
- mammoth_cli/skills/__init__.py +1 -0
- mammoth_cli/skills/installer.py +321 -0
- mammoth_cli/testing.py +55 -0
- mammoth_cli-1.0.0.dist-info/METADATA +92 -0
- mammoth_cli-1.0.0.dist-info/RECORD +150 -0
- mammoth_cli-1.0.0.dist-info/WHEEL +4 -0
- mammoth_cli-1.0.0.dist-info/entry_points.txt +3 -0
- mammoth_cli-1.0.0.dist-info/licenses/LICENSE +23 -0
- spec/manifests/_sdk_introspection.json +1459 -0
- spec/manifests/commands/activity.yaml +72 -0
- spec/manifests/commands/addon.yaml +239 -0
- spec/manifests/commands/agent.yaml +192 -0
- spec/manifests/commands/ai.yaml +152 -0
- spec/manifests/commands/annotation.yaml +191 -0
- spec/manifests/commands/auth.yaml +101 -0
- spec/manifests/commands/automation.yaml +273 -0
- spec/manifests/commands/batch.yaml +262 -0
- spec/manifests/commands/billing.yaml +813 -0
- spec/manifests/commands/browse.yaml +145 -0
- spec/manifests/commands/capability.yaml +74 -0
- spec/manifests/commands/client-app.yaml +193 -0
- spec/manifests/commands/completion.yaml +68 -0
- spec/manifests/commands/config.yaml +134 -0
- spec/manifests/commands/connector.yaml +944 -0
- spec/manifests/commands/context.yaml +101 -0
- spec/manifests/commands/dashboard.yaml +3595 -0
- spec/manifests/commands/data-app.yaml +485 -0
- spec/manifests/commands/dataset.yaml +580 -0
- spec/manifests/commands/doctor.yaml +35 -0
- spec/manifests/commands/external-key.yaml +151 -0
- spec/manifests/commands/file.yaml +341 -0
- spec/manifests/commands/folder.yaml +334 -0
- spec/manifests/commands/job.yaml +151 -0
- spec/manifests/commands/notification.yaml +188 -0
- spec/manifests/commands/parameter.yaml +527 -0
- spec/manifests/commands/project.yaml +669 -0
- spec/manifests/commands/report.yaml +36 -0
- spec/manifests/commands/schedule.yaml +194 -0
- spec/manifests/commands/schema.yaml +74 -0
- spec/manifests/commands/skill.yaml +167 -0
- spec/manifests/commands/snippet.yaml +311 -0
- spec/manifests/commands/support.yaml +1805 -0
- spec/manifests/commands/template.yaml +191 -0
- spec/manifests/commands/trash.yaml +105 -0
- spec/manifests/commands/user.yaml +280 -0
- spec/manifests/commands/version.yaml +35 -0
- spec/manifests/commands/view.yaml +4232 -0
- spec/manifests/commands/webhook.yaml +261 -0
- spec/manifests/commands/workflow.yaml +622 -0
- spec/manifests/commands/workspace.yaml +705 -0
- spec/manifests/openapi-operations.yaml +8853 -0
- spec/manifests/schema-v1.json +212 -0
- spec/manifests/sdk-catalog.source.yaml +4069 -0
- spec/manifests/sdk-methods.yaml +6971 -0
- spec/openapi/metadata.json +10 -0
- spec/openapi/openapi.json +1 -0
mammoth_cli/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Mammoth CLI — a typed command-line interface for the Mammoth Analytics platform.
|
|
2
|
+
|
|
3
|
+
All Mammoth transport goes through the public ``mammoth-io`` SDK. The CLI does
|
|
4
|
+
not implement a second HTTP client and does not call private SDK members.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__version__ = "1.0.0"
|
|
10
|
+
|
|
11
|
+
# The versioned machine output envelope contract.
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
__all__ = ["__version__", "SCHEMA_VERSION"]
|
mammoth_cli/__main__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Module and console entry point for the Mammoth CLI.
|
|
2
|
+
|
|
3
|
+
This module exposes ``main`` so that both the ``mammoth`` console script and
|
|
4
|
+
``python -m mammoth_cli`` start the same Typer application. Keeping the entry
|
|
5
|
+
point here (rather than in ``app``) gives the package a stable, importable
|
|
6
|
+
launch symbol that does not change when the application wiring moves.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mammoth_cli.app import app
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> Any:
|
|
17
|
+
"""Run the Mammoth CLI application.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The value returned by the Typer application, which the runtime uses as
|
|
21
|
+
the process exit code.
|
|
22
|
+
"""
|
|
23
|
+
return app()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__": # pragma: no cover
|
|
27
|
+
main()
|
mammoth_cli/app.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""Root Typer application and manifest-driven command registration.
|
|
2
|
+
|
|
3
|
+
The command tree is built from the reviewed command manifests so the registered
|
|
4
|
+
surface can never drift from the parity records: every non-alias command record
|
|
5
|
+
becomes exactly one Typer command at its manifest ``command_path``, and nothing
|
|
6
|
+
else is registered.
|
|
7
|
+
|
|
8
|
+
Every command exposes the global machine-output and agent-mode options
|
|
9
|
+
(``--output``, ``--no-input``, ``--no-progress``, ``--color``, ``--profile``,
|
|
10
|
+
``--project`` and the timeout family) so an autonomous agent gets deterministic,
|
|
11
|
+
promptless behavior on any command without reading source. Command-specific
|
|
12
|
+
positionals and options are added per family as each handler is implemented.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import inspect
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Callable, Sequence
|
|
20
|
+
from functools import cache
|
|
21
|
+
from typing import Annotated, Any
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
from typer._click import exceptions as _typer_click_exceptions
|
|
25
|
+
from typer.core import TyperGroup
|
|
26
|
+
|
|
27
|
+
from mammoth_cli import __version__
|
|
28
|
+
from mammoth_cli.commands import BESPOKE
|
|
29
|
+
from mammoth_cli.commands.registry import HANDLERS
|
|
30
|
+
from mammoth_cli.errors.envelope import EXIT_USAGE, CliError, not_implemented_error
|
|
31
|
+
from mammoth_cli.manifest.loader import command_by_id, load_commands
|
|
32
|
+
from mammoth_cli.output.policy import COLOR_MODES, MACHINE_OUTPUTS, VALID_OUTPUTS
|
|
33
|
+
from mammoth_cli.runtime import executor, validate
|
|
34
|
+
from mammoth_cli.runtime.invocation import Invocation
|
|
35
|
+
from mammoth_cli.runtime.strict import validate_extra_args
|
|
36
|
+
from mammoth_cli.services.positionals import PositionalSpec, resolve_positionals
|
|
37
|
+
|
|
38
|
+
OUTPUT_MODES = VALID_OUTPUTS
|
|
39
|
+
|
|
40
|
+
# Typer (pinned >=0.27,<0.28) ships a *vendored* click fork (``typer._click``)
|
|
41
|
+
# and does NOT depend on the external ``click`` package. Command resolution
|
|
42
|
+
# raises that fork's ``UsageError``/``Abort``, so the interceptor keys on the
|
|
43
|
+
# vendored classes only -- importing the external ``click`` here would add a
|
|
44
|
+
# phantom dependency that is absent from a clean wheel install.
|
|
45
|
+
_USAGE_ERRORS: tuple[type[BaseException], ...] = (_typer_click_exceptions.UsageError,)
|
|
46
|
+
_ABORT_ERRORS: tuple[type[BaseException], ...] = (_typer_click_exceptions.Abort,)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _output_mode_from_argv(argv: Sequence[str] | None) -> str:
|
|
50
|
+
"""Recover the requested ``--output`` mode from raw argv tokens.
|
|
51
|
+
|
|
52
|
+
Used when a Click ``UsageError`` is raised *before* the per-command option
|
|
53
|
+
is parsed (an unknown command, an unexpected argument, a bad option value),
|
|
54
|
+
so the top-level error renderer can still honor the machine-output contract.
|
|
55
|
+
Defaults to the same ``"table"`` default the ``--output`` option declares.
|
|
56
|
+
"""
|
|
57
|
+
tokens = list(argv) if argv is not None else sys.argv[1:]
|
|
58
|
+
mode = "table"
|
|
59
|
+
index = 0
|
|
60
|
+
while index < len(tokens):
|
|
61
|
+
token = tokens[index]
|
|
62
|
+
if token in ("--output", "-o"):
|
|
63
|
+
if index + 1 < len(tokens):
|
|
64
|
+
mode = tokens[index + 1]
|
|
65
|
+
index += 2
|
|
66
|
+
continue
|
|
67
|
+
if token.startswith("--output="):
|
|
68
|
+
mode = token.split("=", 1)[1]
|
|
69
|
+
elif token.startswith("-o") and len(token) > 2:
|
|
70
|
+
mode = token[2:]
|
|
71
|
+
index += 1
|
|
72
|
+
return mode
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _EnvelopeGroup(TyperGroup):
|
|
76
|
+
"""Root group that renders Click usage errors as the machine error envelope.
|
|
77
|
+
|
|
78
|
+
Click's standalone error handling prints a human ``Usage: ... Error: ...``
|
|
79
|
+
message and exits, even under ``--output json``: an agent driving the CLI
|
|
80
|
+
then receives un-parseable prose (or, for a leaf that also parents
|
|
81
|
+
subcommands, a raw ``No such command`` error) instead of the stable JSON
|
|
82
|
+
envelope every handler-level failure emits. This override intercepts every
|
|
83
|
+
usage error (Typer's vendored ``UsageError``) raised anywhere in the command
|
|
84
|
+
tree and, when a machine output was requested, emits the same envelope
|
|
85
|
+
contract. Human output is unchanged (Click's own rendering is reused).
|
|
86
|
+
|
|
87
|
+
The interception runs Click's machinery with ``standalone_mode=False`` so
|
|
88
|
+
usage errors propagate here rather than being printed by Click, then restores
|
|
89
|
+
the ``SystemExit`` contract the console entry point and the test runner rely
|
|
90
|
+
on.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def main(self, *args: Any, **kwargs: Any) -> Any:
|
|
94
|
+
if not kwargs.get("standalone_mode", True):
|
|
95
|
+
return super().main(*args, **kwargs)
|
|
96
|
+
kwargs["standalone_mode"] = False
|
|
97
|
+
try:
|
|
98
|
+
result = super().main(*args, **kwargs)
|
|
99
|
+
except _USAGE_ERRORS as error:
|
|
100
|
+
argv = kwargs.get("args")
|
|
101
|
+
if argv is None:
|
|
102
|
+
argv = args[0] if args else None
|
|
103
|
+
self._render_usage_error(error, argv)
|
|
104
|
+
raise SystemExit(getattr(error, "exit_code", EXIT_USAGE)) from None
|
|
105
|
+
except _ABORT_ERRORS:
|
|
106
|
+
typer.echo("Aborted!", err=True)
|
|
107
|
+
raise SystemExit(1) from None
|
|
108
|
+
# Under standalone_mode=False a normal return is either None (success) or
|
|
109
|
+
# an int exit code (a click Exit, e.g. --help/--version or our own
|
|
110
|
+
# CliError path). Re-raise SystemExit so both the console script and the
|
|
111
|
+
# in-process test runner observe the exit status as before.
|
|
112
|
+
raise SystemExit(result if isinstance(result, int) else 0)
|
|
113
|
+
|
|
114
|
+
def _render_usage_error(self, error: Any, argv: Sequence[str] | None) -> None:
|
|
115
|
+
"""Emit a usage error as the JSON envelope (machine) or Click prose (human).
|
|
116
|
+
|
|
117
|
+
A *missing required argument* is reported with the stable
|
|
118
|
+
``missing_argument`` code that the handler-level ``_require_*`` helpers
|
|
119
|
+
also raise, so the machine error contract is identical whether a required
|
|
120
|
+
positional is now enforced natively by Typer (a Click-layer
|
|
121
|
+
``MissingParameter``, the common case since positionals are declared with
|
|
122
|
+
their real requiredness) or by a handler that reads it from
|
|
123
|
+
``extra_args``. Every other usage error (unknown command, bad option or
|
|
124
|
+
argument value, an id read as a subcommand) stays ``usage_error``.
|
|
125
|
+
"""
|
|
126
|
+
if _output_mode_from_argv(argv) in MACHINE_OUTPUTS:
|
|
127
|
+
missing = isinstance(error, _typer_click_exceptions.MissingParameter)
|
|
128
|
+
executor.emit_error(
|
|
129
|
+
CliError(
|
|
130
|
+
code="missing_argument" if missing else "usage_error",
|
|
131
|
+
message=error.format_message(),
|
|
132
|
+
exit_status=EXIT_USAGE,
|
|
133
|
+
hint="Check the command schema with 'mammoth schema get'.",
|
|
134
|
+
),
|
|
135
|
+
machine=True,
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
error.show()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _version_callback(value: bool) -> None:
|
|
142
|
+
if value:
|
|
143
|
+
typer.echo(__version__)
|
|
144
|
+
raise typer.Exit(0)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _shared_option_params() -> list[inspect.Parameter]:
|
|
148
|
+
"""Build the global-option parameters shared by every command.
|
|
149
|
+
|
|
150
|
+
These are the machine-output and agent-mode options every command exposes.
|
|
151
|
+
They are declared once, as :class:`inspect.Parameter`s, so a dynamically
|
|
152
|
+
synthesized command signature can splice them in after any positionals while
|
|
153
|
+
keeping the option contract in exactly one place.
|
|
154
|
+
"""
|
|
155
|
+
p = inspect.Parameter
|
|
156
|
+
|
|
157
|
+
def opt(name: str, default: Any, annotation: Any) -> inspect.Parameter:
|
|
158
|
+
return p(name, p.POSITIONAL_OR_KEYWORD, default=default, annotation=annotation)
|
|
159
|
+
|
|
160
|
+
return [
|
|
161
|
+
opt(
|
|
162
|
+
"output",
|
|
163
|
+
"table",
|
|
164
|
+
Annotated[
|
|
165
|
+
str,
|
|
166
|
+
typer.Option(
|
|
167
|
+
"--output", "-o", help="Output format.", metavar="|".join(OUTPUT_MODES)
|
|
168
|
+
),
|
|
169
|
+
],
|
|
170
|
+
),
|
|
171
|
+
opt(
|
|
172
|
+
"profile",
|
|
173
|
+
None,
|
|
174
|
+
Annotated[str | None, typer.Option("--profile", help="Credential profile name.")],
|
|
175
|
+
),
|
|
176
|
+
opt(
|
|
177
|
+
"project",
|
|
178
|
+
None,
|
|
179
|
+
Annotated[int | None, typer.Option("--project", help="Active project id override.")],
|
|
180
|
+
),
|
|
181
|
+
opt(
|
|
182
|
+
"timeout",
|
|
183
|
+
None,
|
|
184
|
+
Annotated[float | None, typer.Option("--timeout", help="Per-request timeout seconds.")],
|
|
185
|
+
),
|
|
186
|
+
opt(
|
|
187
|
+
"job_timeout",
|
|
188
|
+
None,
|
|
189
|
+
Annotated[
|
|
190
|
+
float | None, typer.Option("--job-timeout", help="Job wait timeout seconds.")
|
|
191
|
+
],
|
|
192
|
+
),
|
|
193
|
+
opt(
|
|
194
|
+
"pipeline_timeout",
|
|
195
|
+
None,
|
|
196
|
+
Annotated[
|
|
197
|
+
float | None,
|
|
198
|
+
typer.Option("--pipeline-timeout", help="Pipeline wait timeout seconds."),
|
|
199
|
+
],
|
|
200
|
+
),
|
|
201
|
+
opt(
|
|
202
|
+
"color",
|
|
203
|
+
"auto",
|
|
204
|
+
Annotated[
|
|
205
|
+
str, typer.Option("--color", help="Color policy.", metavar="|".join(COLOR_MODES))
|
|
206
|
+
],
|
|
207
|
+
),
|
|
208
|
+
opt(
|
|
209
|
+
"no_input",
|
|
210
|
+
False,
|
|
211
|
+
Annotated[bool, typer.Option("--no-input", help="Never prompt; fail instead.")],
|
|
212
|
+
),
|
|
213
|
+
opt(
|
|
214
|
+
"no_progress",
|
|
215
|
+
False,
|
|
216
|
+
Annotated[bool, typer.Option("--no-progress", help="Never render progress.")],
|
|
217
|
+
),
|
|
218
|
+
opt(
|
|
219
|
+
"debug",
|
|
220
|
+
False,
|
|
221
|
+
Annotated[bool, typer.Option("--debug", help="Emit diagnostic detail to stderr.")],
|
|
222
|
+
),
|
|
223
|
+
opt(
|
|
224
|
+
"yes",
|
|
225
|
+
False,
|
|
226
|
+
Annotated[
|
|
227
|
+
bool, typer.Option("--yes", "-y", help="Confirm a mutation without prompting.")
|
|
228
|
+
],
|
|
229
|
+
),
|
|
230
|
+
opt(
|
|
231
|
+
"confirm",
|
|
232
|
+
None,
|
|
233
|
+
Annotated[
|
|
234
|
+
str | None,
|
|
235
|
+
typer.Option(
|
|
236
|
+
"--confirm", help="Exact target name required for high-impact actions."
|
|
237
|
+
),
|
|
238
|
+
],
|
|
239
|
+
),
|
|
240
|
+
opt(
|
|
241
|
+
"input_file",
|
|
242
|
+
None,
|
|
243
|
+
Annotated[
|
|
244
|
+
str | None,
|
|
245
|
+
typer.Option(
|
|
246
|
+
"--input", help="Strict JSON/YAML request document, or '-' for stdin."
|
|
247
|
+
),
|
|
248
|
+
],
|
|
249
|
+
),
|
|
250
|
+
opt(
|
|
251
|
+
"input_format",
|
|
252
|
+
None,
|
|
253
|
+
Annotated[
|
|
254
|
+
str | None,
|
|
255
|
+
typer.Option("--input-format", help="Required for stdin: json or yaml."),
|
|
256
|
+
],
|
|
257
|
+
),
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
_SHARED_OPTION_PARAMS = _shared_option_params()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _positional_param(spec: PositionalSpec) -> inspect.Parameter:
|
|
265
|
+
"""Build a Typer ``Argument`` parameter for one positional spec.
|
|
266
|
+
|
|
267
|
+
The argument is declared with the spec's native scalar type (``int`` or
|
|
268
|
+
``str``) and its native requiredness, so ``--help`` shows the truth: a
|
|
269
|
+
required id renders as ``DATASET_ID`` / ``<int>`` (not an optional
|
|
270
|
+
``[DATASET_ID]`` / ``<str>``) and Typer enforces both presence and type at
|
|
271
|
+
parse time. A missing or ill-typed *required* positional raises a Click-layer
|
|
272
|
+
usage error that :class:`_EnvelopeGroup` renders as the stable JSON envelope
|
|
273
|
+
under a machine ``--output`` (``missing_argument`` for an absent one,
|
|
274
|
+
``usage_error`` for a bad value), preserving the machine error contract. An
|
|
275
|
+
*optional* positional keeps a ``None`` default and an ``Optional`` annotation;
|
|
276
|
+
the handler fills it from ``--input`` or resolved context when omitted.
|
|
277
|
+
"""
|
|
278
|
+
if spec.required:
|
|
279
|
+
return inspect.Parameter(
|
|
280
|
+
spec.name,
|
|
281
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
282
|
+
annotation=Annotated[spec.type, typer.Argument(help=spec.help, metavar=spec.metavar)],
|
|
283
|
+
)
|
|
284
|
+
return inspect.Parameter(
|
|
285
|
+
spec.name,
|
|
286
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
287
|
+
default=None,
|
|
288
|
+
annotation=Annotated[
|
|
289
|
+
spec.type | None, typer.Argument(help=spec.help, metavar=spec.metavar)
|
|
290
|
+
],
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _build_leaf(command_id: str, *, is_group_callback: bool = False) -> Callable[..., None]:
|
|
295
|
+
"""Return a Typer callback bound to one manifest command id.
|
|
296
|
+
|
|
297
|
+
Every callback shares the same global-option signature so ``--help`` for any
|
|
298
|
+
command advertises the machine-output and agent-mode contract, and declares
|
|
299
|
+
one native Typer ``Argument`` per positional derived for the command (see
|
|
300
|
+
:func:`mammoth_cli.services.positionals.resolve_positionals`), so its
|
|
301
|
+
``--help`` shows an Arguments panel and the parsed values route into
|
|
302
|
+
:attr:`Invocation.positionals` — a single, code-derived source of truth for
|
|
303
|
+
the command's positional shape.
|
|
304
|
+
|
|
305
|
+
The parsed positionals are also mirrored, in declared order, into
|
|
306
|
+
:attr:`Invocation.extra_args` ahead of any genuine surplus tokens. Handlers
|
|
307
|
+
may read them positionally (``extra_args``) or by name
|
|
308
|
+
(:meth:`Invocation.positional`); both views come from the same declaration,
|
|
309
|
+
so they cannot drift, and the surplus/id checks in
|
|
310
|
+
:mod:`mammoth_cli.runtime.strict` and :mod:`mammoth_cli.runtime.validate`
|
|
311
|
+
(which align a command's derived positionals against ``extra_args``) keep
|
|
312
|
+
working unchanged.
|
|
313
|
+
|
|
314
|
+
When ``is_group_callback`` is set the command sits at a node that also has
|
|
315
|
+
subcommands; the callback then only runs when no subcommand is invoked and
|
|
316
|
+
declares no positionals.
|
|
317
|
+
"""
|
|
318
|
+
positionals: tuple[PositionalSpec, ...] = (
|
|
319
|
+
() if is_group_callback else resolve_positionals(command_id)
|
|
320
|
+
)
|
|
321
|
+
positional_names = tuple(spec.name for spec in positionals)
|
|
322
|
+
|
|
323
|
+
def leaf(**params: Any) -> None:
|
|
324
|
+
ctx: typer.Context = params.pop("ctx")
|
|
325
|
+
if is_group_callback and ctx.invoked_subcommand is not None:
|
|
326
|
+
return
|
|
327
|
+
resolved = {name: params.pop(name) for name in positional_names}
|
|
328
|
+
present = {name: value for name, value in resolved.items() if value is not None}
|
|
329
|
+
# Mirror the bound positionals (in declared order) into extra_args ahead
|
|
330
|
+
# of any genuine surplus tokens, so handlers reading extra_args by index
|
|
331
|
+
# and the derived-positional surplus/id checks both keep working.
|
|
332
|
+
mirrored = [str(resolved[name]) for name in positional_names if resolved[name] is not None]
|
|
333
|
+
invocation = Invocation(
|
|
334
|
+
command_id=command_id,
|
|
335
|
+
positionals=present,
|
|
336
|
+
extra_args=[*mirrored, *ctx.args],
|
|
337
|
+
**params,
|
|
338
|
+
)
|
|
339
|
+
_execute(invocation)
|
|
340
|
+
|
|
341
|
+
signature_params = [
|
|
342
|
+
inspect.Parameter("ctx", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=typer.Context),
|
|
343
|
+
*(_positional_param(spec) for spec in positionals),
|
|
344
|
+
*_SHARED_OPTION_PARAMS,
|
|
345
|
+
]
|
|
346
|
+
leaf.__signature__ = inspect.Signature(signature_params) # type: ignore[attr-defined]
|
|
347
|
+
leaf.__annotations__ = {param.name: param.annotation for param in signature_params}
|
|
348
|
+
leaf.__name__ = "cmd_" + command_id.replace(".", "_").replace("-", "_")
|
|
349
|
+
return leaf
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _execute(invocation: Invocation) -> None:
|
|
353
|
+
"""Run one command: dispatch to its handler and render the envelope."""
|
|
354
|
+
|
|
355
|
+
def producer() -> tuple[Any, dict[str, Any]]:
|
|
356
|
+
validate.validate_invocation(invocation)
|
|
357
|
+
validate_extra_args(invocation.command_id, invocation.extra_args)
|
|
358
|
+
validate.validate_positional_ids(invocation.command_id, invocation.extra_args)
|
|
359
|
+
handler = HANDLERS.get(invocation.command_id)
|
|
360
|
+
if handler is None:
|
|
361
|
+
record = command_by_id(invocation.command_id)
|
|
362
|
+
sdk_symbol = record.get("sdk_symbol", "") if record else ""
|
|
363
|
+
raise not_implemented_error(invocation.command_id, sdk_symbol)
|
|
364
|
+
return handler(invocation)
|
|
365
|
+
|
|
366
|
+
executor.run(invocation.command_id, invocation.output, producer)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _command_help(command_id: str, record: dict[str, Any] | None) -> str | None:
|
|
370
|
+
"""Build a command's user-facing ``--help`` summary.
|
|
371
|
+
|
|
372
|
+
Kept deliberately distinct from ``known_restrictions`` (internal review and
|
|
373
|
+
planning notes), which used to be shown here verbatim and leaked plan-document
|
|
374
|
+
prose -- e.g. "specified in plan 02's ... contract" -- into the user-facing
|
|
375
|
+
``--help``. Instead the summary is the backing handler's docstring first line
|
|
376
|
+
(actionable field guidance such as "``columns``/``mapping`` required"),
|
|
377
|
+
followed by the manifest's runnable ``agent_example``, which already encodes
|
|
378
|
+
every required positional and ``--input`` field. ``known_restrictions``
|
|
379
|
+
remains available to machines through ``schema get``.
|
|
380
|
+
"""
|
|
381
|
+
parts: list[str] = []
|
|
382
|
+
handler = HANDLERS.get(command_id) or BESPOKE.get(command_id)
|
|
383
|
+
doc = inspect.getdoc(handler) if handler is not None else None
|
|
384
|
+
if doc:
|
|
385
|
+
# First non-empty line, with RST inline-code backticks flattened to plain
|
|
386
|
+
# quotes so the help reads as prose rather than reStructuredText.
|
|
387
|
+
summary = doc.strip().splitlines()[0].strip().replace("``", "'")
|
|
388
|
+
if summary:
|
|
389
|
+
parts.append(summary)
|
|
390
|
+
example = (record or {}).get("agent_example")
|
|
391
|
+
if example:
|
|
392
|
+
parts.append(f"Example: {example}")
|
|
393
|
+
return "\n\n".join(parts) or None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def build_app() -> typer.Typer:
|
|
397
|
+
"""Construct the full Typer command tree from the command manifests."""
|
|
398
|
+
root = typer.Typer(
|
|
399
|
+
name="mammoth",
|
|
400
|
+
help="Command-line interface for the Mammoth Analytics platform.",
|
|
401
|
+
no_args_is_help=True,
|
|
402
|
+
add_completion=True,
|
|
403
|
+
cls=_EnvelopeGroup,
|
|
404
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
@root.callback()
|
|
408
|
+
def _root(
|
|
409
|
+
version: bool = typer.Option(
|
|
410
|
+
False, "--version", callback=_version_callback, is_eager=True, help="Show version."
|
|
411
|
+
),
|
|
412
|
+
) -> None:
|
|
413
|
+
"""Root callback holding eager global options."""
|
|
414
|
+
|
|
415
|
+
records = [r for r in load_commands() if r.get("disposition") != "alias"]
|
|
416
|
+
path_to_command: dict[tuple[str, ...], str] = {
|
|
417
|
+
tuple(r["command_path"].split()): r["command_id"] for r in records
|
|
418
|
+
}
|
|
419
|
+
all_paths = set(path_to_command)
|
|
420
|
+
# A "container command" sits at a node that also has deeper subcommands
|
|
421
|
+
# (e.g. `dataset file-settings`, which also has `... undo` / `... update`).
|
|
422
|
+
containers = {
|
|
423
|
+
tokens
|
|
424
|
+
for tokens in all_paths
|
|
425
|
+
if any(other != tokens and other[: len(tokens)] == tokens for other in all_paths)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
groups: dict[tuple[str, ...], typer.Typer] = {(): root}
|
|
429
|
+
|
|
430
|
+
def _make_group(tokens: tuple[str, ...]) -> typer.Typer:
|
|
431
|
+
command_id = path_to_command.get(tokens)
|
|
432
|
+
if command_id is not None:
|
|
433
|
+
# This node is both a group and an invocable command.
|
|
434
|
+
sub = typer.Typer(
|
|
435
|
+
no_args_is_help=False,
|
|
436
|
+
invoke_without_command=True,
|
|
437
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
438
|
+
)
|
|
439
|
+
sub.callback()(_build_leaf(command_id, is_group_callback=True))
|
|
440
|
+
else:
|
|
441
|
+
sub = typer.Typer(
|
|
442
|
+
no_args_is_help=True,
|
|
443
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
444
|
+
)
|
|
445
|
+
return sub
|
|
446
|
+
|
|
447
|
+
def _group_for(tokens: tuple[str, ...]) -> typer.Typer:
|
|
448
|
+
if tokens in groups:
|
|
449
|
+
return groups[tokens]
|
|
450
|
+
parent = _group_for(tokens[:-1])
|
|
451
|
+
sub = _make_group(tokens)
|
|
452
|
+
parent.add_typer(sub, name=tokens[-1])
|
|
453
|
+
groups[tokens] = sub
|
|
454
|
+
return sub
|
|
455
|
+
|
|
456
|
+
# Ensure every container group exists (parents before children).
|
|
457
|
+
for tokens in sorted(containers, key=len):
|
|
458
|
+
_group_for(tokens)
|
|
459
|
+
|
|
460
|
+
# Register every non-container command as a leaf under its parent group.
|
|
461
|
+
# A command_id with a bespoke, fully-typed callback overrides the generic
|
|
462
|
+
# leaf at the same registered name and path; the manifest-driven surface
|
|
463
|
+
# is otherwise unchanged.
|
|
464
|
+
for tokens, command_id in sorted(path_to_command.items()):
|
|
465
|
+
if tokens in containers:
|
|
466
|
+
continue
|
|
467
|
+
record = command_by_id(command_id)
|
|
468
|
+
callback = BESPOKE.get(command_id) or _build_leaf(command_id)
|
|
469
|
+
_group_for(tokens[:-1]).command(
|
|
470
|
+
name=tokens[-1],
|
|
471
|
+
help=_command_help(command_id, record),
|
|
472
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
473
|
+
)(callback)
|
|
474
|
+
|
|
475
|
+
return root
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def registered_command_paths() -> set[str]:
|
|
479
|
+
"""Return every registered command path by walking the built Typer tree.
|
|
480
|
+
|
|
481
|
+
Container commands (a node that is both a group and invocable) are counted
|
|
482
|
+
by their own path as well as their subcommands.
|
|
483
|
+
"""
|
|
484
|
+
paths: set[str] = set()
|
|
485
|
+
|
|
486
|
+
def walk(instance: typer.Typer, prefix: tuple[str, ...]) -> None:
|
|
487
|
+
for command in instance.registered_commands:
|
|
488
|
+
name = command.name or (command.callback.__name__ if command.callback else None)
|
|
489
|
+
if name:
|
|
490
|
+
paths.add(" ".join((*prefix, name)))
|
|
491
|
+
for group in instance.registered_groups:
|
|
492
|
+
sub = group.typer_instance
|
|
493
|
+
if sub is None or not group.name:
|
|
494
|
+
continue
|
|
495
|
+
node = (*prefix, group.name)
|
|
496
|
+
if getattr(sub.info, "invoke_without_command", False) and sub.registered_callback:
|
|
497
|
+
paths.add(" ".join(node))
|
|
498
|
+
walk(sub, node)
|
|
499
|
+
|
|
500
|
+
walk(build_app(), ())
|
|
501
|
+
return paths
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
@cache
|
|
505
|
+
def _root_click_command() -> Any:
|
|
506
|
+
"""Build (once) and cache the fully resolved Click command tree.
|
|
507
|
+
|
|
508
|
+
``typer.main.get_command`` walks and converts every registered Typer
|
|
509
|
+
sub-app into its Click representation; over the full manifest-driven tree
|
|
510
|
+
that is expensive enough that recomputing it per lookup made a full sweep
|
|
511
|
+
over every command (see the contract test) take minutes. Cached because
|
|
512
|
+
the tree is fixed for the process lifetime (:data:`app` is built once at
|
|
513
|
+
import time).
|
|
514
|
+
"""
|
|
515
|
+
return typer.main.get_command(app)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def command_option_names(command_path: str) -> set[str]:
|
|
519
|
+
"""Return every declared option flag for one registered command path.
|
|
520
|
+
|
|
521
|
+
Walks the built Click command tree by ``command_path`` tokens (for example
|
|
522
|
+
``"view transform bulk-replace"``) and collects each parameter's option
|
|
523
|
+
strings (``"--output"``, ``"-o"``, ...). This is a structural check: it
|
|
524
|
+
reads the already-parsed command declaration, so it is fast and immune to
|
|
525
|
+
the rendered-width and terminal-detection nondeterminism of asserting on
|
|
526
|
+
rendered ``--help`` text. Command nodes are walked duck-typed (via their
|
|
527
|
+
``commands`` mapping) rather than by an ``isinstance`` check against
|
|
528
|
+
``click``'s public types, since Typer's pinned version resolves its command
|
|
529
|
+
tree through its own vendored click fork (``typer._click``), whose classes
|
|
530
|
+
are not the public ``click`` package's.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
command_path: The space-separated manifest command path.
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
The union of every parameter's primary and secondary option strings
|
|
537
|
+
declared on that command (or group callback).
|
|
538
|
+
|
|
539
|
+
Raises:
|
|
540
|
+
ValueError: When ``command_path`` does not resolve to a registered
|
|
541
|
+
command.
|
|
542
|
+
"""
|
|
543
|
+
command: Any = _root_click_command()
|
|
544
|
+
for token in command_path.split():
|
|
545
|
+
subcommands: dict[str, Any] | None = getattr(command, "commands", None)
|
|
546
|
+
if subcommands is None or token not in subcommands:
|
|
547
|
+
raise ValueError(f"'{command_path}' is not a registered command path.")
|
|
548
|
+
command = subcommands[token]
|
|
549
|
+
names: set[str] = set()
|
|
550
|
+
for param in command.params:
|
|
551
|
+
names.update(getattr(param, "opts", ()))
|
|
552
|
+
names.update(getattr(param, "secondary_opts", ()))
|
|
553
|
+
return names
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
app = build_app()
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def main() -> Any:
|
|
560
|
+
"""Console-script entry point."""
|
|
561
|
+
return app()
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
if __name__ == "__main__": # pragma: no cover
|
|
565
|
+
main()
|