hexastack-cli 0.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.
- hexastack_cli/__init__.py +6 -0
- hexastack_cli/adapters/__init__.py +13 -0
- hexastack_cli/adapters/app.py +77 -0
- hexastack_cli/adapters/presenter.py +137 -0
- hexastack_cli/adapters/routing.py +396 -0
- hexastack_cli/infra/__init__.py +29 -0
- hexastack_cli/infra/autodiscovery.py +215 -0
- hexastack_cli/infra/bootstrap.py +80 -0
- hexastack_cli/infra/config.py +43 -0
- hexastack_cli/infra/decorators.py +219 -0
- hexastack_cli/py.typed +0 -0
- hexastack_cli/testing/__init__.py +10 -0
- hexastack_cli/testing/narrator.py +192 -0
- hexastack_cli/testing/terminal.py +298 -0
- hexastack_cli-0.0.0.dist-info/METADATA +140 -0
- hexastack_cli-0.0.0.dist-info/RECORD +18 -0
- hexastack_cli-0.0.0.dist-info/WHEEL +4 -0
- hexastack_cli-0.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from types import ModuleType
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from hexastack_cli.adapters.routing import (
|
|
11
|
+
_to_kebab_case,
|
|
12
|
+
register_cqrs_command,
|
|
13
|
+
register_cqrs_query,
|
|
14
|
+
)
|
|
15
|
+
from hexastack_cli.infra.decorators import (
|
|
16
|
+
_CLI_GROUP_ATTR,
|
|
17
|
+
_CLI_METADATA_ATTR,
|
|
18
|
+
CliMetadata,
|
|
19
|
+
GroupMetadata,
|
|
20
|
+
)
|
|
21
|
+
from hexastack_core.domain import Command, Query
|
|
22
|
+
from hexastack_core.infra.autodiscovery import (
|
|
23
|
+
DiscoveryVisitor,
|
|
24
|
+
scan_modules,
|
|
25
|
+
)
|
|
26
|
+
from hexastack_cqrs.infra.pipeline import ExecutionPipeline
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"autodiscover_cli_commands",
|
|
30
|
+
"create_cli_visitor",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _SubgroupManager:
|
|
35
|
+
"""Manages creation and nested mounting of Typer subgroups."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, root_app: typer.Typer) -> None:
|
|
38
|
+
self.root_app = root_app
|
|
39
|
+
self.subgroups: dict[tuple[str, ...], typer.Typer] = {}
|
|
40
|
+
self.group_docs: dict[tuple[str, ...], str] = {}
|
|
41
|
+
|
|
42
|
+
def get_or_create(self, group_parts: list[str]) -> typer.Typer:
|
|
43
|
+
"""Get or create nested subgroup Typer application."""
|
|
44
|
+
if not group_parts:
|
|
45
|
+
return self.root_app
|
|
46
|
+
|
|
47
|
+
current_app = self.root_app
|
|
48
|
+
current_path: list[str] = []
|
|
49
|
+
for part in group_parts:
|
|
50
|
+
current_path.append(part)
|
|
51
|
+
key = tuple(current_path)
|
|
52
|
+
if key not in self.subgroups:
|
|
53
|
+
help_text = self.group_docs.get(
|
|
54
|
+
key, f"{part.title()} management commands"
|
|
55
|
+
)
|
|
56
|
+
sub_app = typer.Typer(
|
|
57
|
+
name=part,
|
|
58
|
+
help=help_text,
|
|
59
|
+
no_args_is_help=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@sub_app.callback()
|
|
63
|
+
def _sub_cb() -> None:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
current_app.add_typer(sub_app, name=part)
|
|
67
|
+
self.subgroups[key] = sub_app
|
|
68
|
+
current_app = self.subgroups[key]
|
|
69
|
+
return current_app
|
|
70
|
+
|
|
71
|
+
def register_group_metadata(self, obj: type[Any]) -> None:
|
|
72
|
+
"""Extract and cache group documentation from @cli_group."""
|
|
73
|
+
grp_meta: GroupMetadata | None = getattr(obj, _CLI_GROUP_ATTR, None)
|
|
74
|
+
if grp_meta is not None:
|
|
75
|
+
parts = _normalize_group_path(grp_meta.name)
|
|
76
|
+
if parts and grp_meta.help:
|
|
77
|
+
self.group_docs[tuple(parts)] = grp_meta.help
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_group_path(group: str | Sequence[str] | None) -> list[str]:
|
|
81
|
+
"""Normalize a string or sequence into a list of subcommand group tokens."""
|
|
82
|
+
if not group:
|
|
83
|
+
return []
|
|
84
|
+
if isinstance(group, str):
|
|
85
|
+
return [part.strip() for part in re.split(r"[./\s]+", group) if part.strip()]
|
|
86
|
+
return [str(part).strip() for part in group if str(part).strip()]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _register_model_target(
|
|
90
|
+
target_app: typer.Typer,
|
|
91
|
+
obj: type[Any],
|
|
92
|
+
meta: CliMetadata,
|
|
93
|
+
cmd_name: str,
|
|
94
|
+
help_text: str | None,
|
|
95
|
+
pipeline: ExecutionPipeline,
|
|
96
|
+
console: Console | None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Register command or query class on the target Typer app."""
|
|
99
|
+
if meta.kind == "command" and issubclass(obj, Command):
|
|
100
|
+
register_cqrs_command(
|
|
101
|
+
app=target_app,
|
|
102
|
+
command_cls=obj,
|
|
103
|
+
pipeline=pipeline,
|
|
104
|
+
name=cmd_name,
|
|
105
|
+
positional=meta.positional,
|
|
106
|
+
help_text=help_text,
|
|
107
|
+
output_format=meta.output_format,
|
|
108
|
+
console=console,
|
|
109
|
+
feature_flag=meta.feature_flag,
|
|
110
|
+
)
|
|
111
|
+
elif meta.kind == "query" and issubclass(obj, Query):
|
|
112
|
+
register_cqrs_query(
|
|
113
|
+
app=target_app,
|
|
114
|
+
query_cls=obj,
|
|
115
|
+
pipeline=pipeline,
|
|
116
|
+
name=cmd_name,
|
|
117
|
+
positional=meta.positional,
|
|
118
|
+
help_text=help_text,
|
|
119
|
+
output_format=meta.output_format,
|
|
120
|
+
console=console,
|
|
121
|
+
feature_flag=meta.feature_flag,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _resolve_targets(
|
|
126
|
+
meta: CliMetadata, default_name: str
|
|
127
|
+
) -> list[tuple[list[str], str, str | None]]:
|
|
128
|
+
"""Resolve all (group_parts, cmd_name, help) targets including aliases."""
|
|
129
|
+
primary_group = _normalize_group_path(meta.group)
|
|
130
|
+
primary_name = meta.name or default_name
|
|
131
|
+
targets: list[tuple[list[str], str, str | None]] = [
|
|
132
|
+
(primary_group, primary_name, meta.help)
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
for alias in meta.aliases:
|
|
136
|
+
if alias.startswith("/"):
|
|
137
|
+
targets.append(([], alias.lstrip("/"), meta.help))
|
|
138
|
+
elif any(sep in alias for sep in (".", "/", " ")):
|
|
139
|
+
parts = _normalize_group_path(alias)
|
|
140
|
+
targets.append((parts[:-1], parts[-1], meta.help))
|
|
141
|
+
else:
|
|
142
|
+
targets.append((primary_group, alias, meta.help))
|
|
143
|
+
|
|
144
|
+
return targets
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def autodiscover_cli_commands(
|
|
148
|
+
app: typer.Typer,
|
|
149
|
+
packages_or_modules: Sequence[str | ModuleType],
|
|
150
|
+
pipeline: ExecutionPipeline,
|
|
151
|
+
console: Console | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Scan packages and register discovered CLI commands into a Typer application.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
app: Target Typer application instance.
|
|
157
|
+
packages_or_modules: Sequence of package names or module objects to inspect.
|
|
158
|
+
pipeline: Target ExecutionPipeline instance.
|
|
159
|
+
console: Optional rich Console instance.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
None.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
None.
|
|
166
|
+
"""
|
|
167
|
+
visitor = create_cli_visitor(app=app, pipeline=pipeline, console=console)
|
|
168
|
+
scan_modules(packages_or_modules, [visitor])
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def create_cli_visitor(
|
|
172
|
+
app: typer.Typer,
|
|
173
|
+
pipeline: ExecutionPipeline,
|
|
174
|
+
console: Console | None = None,
|
|
175
|
+
) -> DiscoveryVisitor:
|
|
176
|
+
"""Create a DiscoveryVisitor callback for single-pass CLI command and alias registration.
|
|
177
|
+
|
|
178
|
+
Notes/Architectural Intent:
|
|
179
|
+
Inspects discovered classes for @cli_command, @cli_query, and @cli_group metadata,
|
|
180
|
+
dynamically building nested Typer subcommand trees and mounting intra-group
|
|
181
|
+
and cross-group command aliases.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
app: Target Typer root application instance.
|
|
185
|
+
pipeline: ExecutionPipeline instance for command and query dispatching.
|
|
186
|
+
console: Optional rich Console instance.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
DiscoveryVisitor callable accepting (member, module).
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
None.
|
|
193
|
+
"""
|
|
194
|
+
manager = _SubgroupManager(root_app=app)
|
|
195
|
+
|
|
196
|
+
def visitor(obj: Any, module: ModuleType) -> None:
|
|
197
|
+
if not inspect.isclass(obj):
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
manager.register_group_metadata(obj)
|
|
201
|
+
|
|
202
|
+
meta: CliMetadata | None = getattr(obj, _CLI_METADATA_ATTR, None)
|
|
203
|
+
if meta is None:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
default_name = _to_kebab_case(obj.__name__)
|
|
207
|
+
targets = _resolve_targets(meta, default_name)
|
|
208
|
+
|
|
209
|
+
for group_parts, cmd_name, help_text in targets:
|
|
210
|
+
target_app = manager.get_or_create(group_parts)
|
|
211
|
+
_register_model_target(
|
|
212
|
+
target_app, obj, meta, cmd_name, help_text, pipeline, console
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return visitor
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from hexastack_cli.infra.config import (
|
|
4
|
+
HexastackCliConfig,
|
|
5
|
+
register_cli_config,
|
|
6
|
+
)
|
|
7
|
+
from hexastack_core.infra.bootstrap import BootstrapContext
|
|
8
|
+
from hexastack_core.infra.registries.config import ConfigRegistry
|
|
9
|
+
from hexastack_core.ports.bootstrap import BootstrapperPort
|
|
10
|
+
from hexastack_cqrs.infra.pipeline import ExecutionPipeline
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CliBootstrapper(BootstrapperPort):
|
|
14
|
+
"""Bootstrap extension configuring Hexastack Typer CLI presentation layer.
|
|
15
|
+
|
|
16
|
+
Notes/Architectural Intent:
|
|
17
|
+
Implements BootstrapperPort for hexastack-cli (order=40), registering 'cli'
|
|
18
|
+
configuration in Phase 1, creating the Typer application in Phase 2, and registering
|
|
19
|
+
the single-pass CLI discovery visitor.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
name: str = "cli"
|
|
23
|
+
order: int = 40
|
|
24
|
+
|
|
25
|
+
def configure(self, context: BootstrapContext) -> None:
|
|
26
|
+
"""Phase 2: Instantiate Typer app, configure presenters, and register discovery visitor.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
context: BootstrapContext containing DI container, configuration, and properties.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
None.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
None.
|
|
36
|
+
"""
|
|
37
|
+
from hexastack_cli.adapters.app import create_cli_app
|
|
38
|
+
from hexastack_cli.infra.autodiscovery import create_cli_visitor
|
|
39
|
+
|
|
40
|
+
cfg = HexastackCliConfig()
|
|
41
|
+
if context.config is not None:
|
|
42
|
+
section = context.config.get_section("cli", HexastackCliConfig)
|
|
43
|
+
if section is not None:
|
|
44
|
+
cfg = section
|
|
45
|
+
|
|
46
|
+
pipeline = context.properties.get("pipeline")
|
|
47
|
+
if pipeline is None and ExecutionPipeline in context.container:
|
|
48
|
+
pipeline = context.container.resolve(ExecutionPipeline)
|
|
49
|
+
|
|
50
|
+
app = create_cli_app(
|
|
51
|
+
config=cfg,
|
|
52
|
+
container=context.container,
|
|
53
|
+
pipeline=pipeline,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if cfg.auto_register_commands and pipeline is not None:
|
|
57
|
+
visitor = create_cli_visitor(app=app, pipeline=pipeline)
|
|
58
|
+
context.register_visitor(visitor)
|
|
59
|
+
|
|
60
|
+
context.container.add_instance(app, declared_class=typer.Typer)
|
|
61
|
+
context.properties["cli_app"] = app
|
|
62
|
+
|
|
63
|
+
def register_config(self, registry: ConfigRegistry) -> None:
|
|
64
|
+
"""Phase 1: Register CLI configuration schema under 'cli'.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
registry: Target ConfigRegistry instance.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
None.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
None.
|
|
74
|
+
"""
|
|
75
|
+
register_cli_config(registry)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
"CliBootstrapper",
|
|
80
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
from hexastack_core.infra.decorators import config_section
|
|
4
|
+
from hexastack_core.infra.registries.config import ConfigRegistry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@config_section("cli")
|
|
8
|
+
class HexastackCliConfig(BaseModel):
|
|
9
|
+
"""Configuration schema for Hexastack CLI presentation adapter.
|
|
10
|
+
|
|
11
|
+
Notes/Architectural Intent:
|
|
12
|
+
Controls CLI application naming, versioning, Typer rich formatting, error traceback display,
|
|
13
|
+
and automatic command discovery.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
app_name: str = Field(default="hexastack")
|
|
17
|
+
version: str = Field(default="0.1.0")
|
|
18
|
+
help_text: str = Field(default="Hexastack CLI Application")
|
|
19
|
+
auto_register_commands: bool = Field(default=True)
|
|
20
|
+
rich_markup: bool = Field(default=True)
|
|
21
|
+
show_exceptions: bool = Field(default=False)
|
|
22
|
+
packages_to_scan: list[str] = Field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"HexastackCliConfig",
|
|
27
|
+
"register_cli_config",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def register_cli_config(registry: ConfigRegistry) -> None:
|
|
32
|
+
"""Register CLI configuration schema with a ConfigRegistry under 'cli'.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
registry: Target ConfigRegistry instance.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
None.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
None.
|
|
42
|
+
"""
|
|
43
|
+
registry.register_config_section("cli", HexastackCliConfig)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from collections.abc import Callable, Sequence
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from hexastack_core.domain import Command, Query
|
|
6
|
+
|
|
7
|
+
_CLI_METADATA_ATTR = "__hexastack_cli__"
|
|
8
|
+
_CLI_GROUP_ATTR = "__hexastack_cli_group__"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class CliMetadata:
|
|
13
|
+
"""Metadata describing a CLI command binding for a Command or Query.
|
|
14
|
+
|
|
15
|
+
Notes/Architectural Intent:
|
|
16
|
+
Carries CLI command naming, positional parameter mappings, aliases, help descriptions,
|
|
17
|
+
optional sub-command group identifications, and feature flag gating for automated terminal routing.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
kind: Literal["command", "query"]
|
|
21
|
+
name: str | None = None
|
|
22
|
+
positional: tuple[str, ...] = field(default_factory=tuple)
|
|
23
|
+
aliases: tuple[str, ...] = field(default_factory=tuple)
|
|
24
|
+
help: str | None = None
|
|
25
|
+
output_format: str | None = None
|
|
26
|
+
group: str | Sequence[str] | None = None
|
|
27
|
+
feature_flag: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class GroupMetadata:
|
|
32
|
+
"""Metadata configuring a custom CLI subcommand group title and description.
|
|
33
|
+
|
|
34
|
+
Notes/Architectural Intent:
|
|
35
|
+
Allows domain modules or namespaces to define rich descriptions for CLI command groups.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
help: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"cli_command",
|
|
44
|
+
"cli_group",
|
|
45
|
+
"cli_query",
|
|
46
|
+
"CliMetadata",
|
|
47
|
+
"feature_flag_command",
|
|
48
|
+
"GroupMetadata",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalize_tokens(tokens: Sequence[str] | str | None) -> tuple[str, ...]:
|
|
53
|
+
"""Normalize string or sequence of token strings into an immutable tuple."""
|
|
54
|
+
if not tokens:
|
|
55
|
+
return ()
|
|
56
|
+
if isinstance(tokens, str):
|
|
57
|
+
return (tokens.strip(),)
|
|
58
|
+
return tuple(str(t).strip() for t in tokens if str(t).strip())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cli_command[TCommand: Command](
|
|
62
|
+
name: str | None = None,
|
|
63
|
+
*,
|
|
64
|
+
positional: Sequence[str] | str | None = None,
|
|
65
|
+
aliases: Sequence[str] | str | None = None,
|
|
66
|
+
help: str | None = None,
|
|
67
|
+
output_format: str | None = None,
|
|
68
|
+
group: str | Sequence[str] | None = None,
|
|
69
|
+
feature_flag: str | None = None,
|
|
70
|
+
) -> Callable[[type[TCommand]], type[TCommand]]:
|
|
71
|
+
"""Decorator marking a Command class for automatic CLI command exposure.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
name: Optional custom CLI command name (defaults to kebab-cased class name).
|
|
75
|
+
positional: Optional field name or list of field names to treat as positional CLI arguments.
|
|
76
|
+
aliases: Optional alias or list of aliases (can be intra-group like 'create' or cross-group like 'account.new').
|
|
77
|
+
help: Optional help text for the command.
|
|
78
|
+
output_format: Optional presenter output format.
|
|
79
|
+
group: Optional sub-command group name or nested group path (e.g. 'user', 'user.profile').
|
|
80
|
+
feature_flag: Optional feature flag key required to execute the command.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Decorated Command class with attached CLI metadata.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
None.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def decorator(cls: type[TCommand]) -> type[TCommand]:
|
|
90
|
+
meta = CliMetadata(
|
|
91
|
+
kind="command",
|
|
92
|
+
name=name,
|
|
93
|
+
positional=_normalize_tokens(positional),
|
|
94
|
+
aliases=_normalize_tokens(aliases),
|
|
95
|
+
help=help,
|
|
96
|
+
output_format=output_format,
|
|
97
|
+
group=group,
|
|
98
|
+
feature_flag=feature_flag,
|
|
99
|
+
)
|
|
100
|
+
setattr(cls, _CLI_METADATA_ATTR, meta)
|
|
101
|
+
return cls
|
|
102
|
+
|
|
103
|
+
return decorator
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cli_group(
|
|
107
|
+
name: str,
|
|
108
|
+
*,
|
|
109
|
+
help: str | None = None,
|
|
110
|
+
) -> Callable[[type], type]:
|
|
111
|
+
"""Decorator configuring custom metadata or documentation for a CLI group.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
name: The group name path (e.g. 'user', 'user.profile').
|
|
115
|
+
help: Help description displayed for the group in terminal --help.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Decorator function attaching GroupMetadata.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
None.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def decorator(cls: type) -> type:
|
|
125
|
+
meta = GroupMetadata(name=name, help=help)
|
|
126
|
+
setattr(cls, _CLI_GROUP_ATTR, meta)
|
|
127
|
+
return cls
|
|
128
|
+
|
|
129
|
+
return decorator
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cli_query[TQuery: Query](
|
|
133
|
+
name: str | None = None,
|
|
134
|
+
*,
|
|
135
|
+
positional: Sequence[str] | str | None = None,
|
|
136
|
+
aliases: Sequence[str] | str | None = None,
|
|
137
|
+
help: str | None = None,
|
|
138
|
+
output_format: str | None = None,
|
|
139
|
+
group: str | Sequence[str] | None = None,
|
|
140
|
+
feature_flag: str | None = None,
|
|
141
|
+
) -> Callable[[type[TQuery]], type[TQuery]]:
|
|
142
|
+
"""Decorator marking a Query class for automatic CLI command exposure.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
name: Optional custom CLI command name (defaults to kebab-cased class name).
|
|
146
|
+
positional: Optional field name or list of field names to treat as positional CLI arguments.
|
|
147
|
+
aliases: Optional alias or list of aliases (can be intra-group like 'find' or cross-group like 'search.user').
|
|
148
|
+
help: Optional help text for the command.
|
|
149
|
+
output_format: Optional presenter output format.
|
|
150
|
+
group: Optional sub-command group name or nested group path (e.g. 'user', 'user.profile').
|
|
151
|
+
feature_flag: Optional feature flag key required to execute the command.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Decorated Query class with attached CLI metadata.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
None.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def decorator(cls: type[TQuery]) -> type[TQuery]:
|
|
161
|
+
meta = CliMetadata(
|
|
162
|
+
kind="query",
|
|
163
|
+
name=name,
|
|
164
|
+
positional=_normalize_tokens(positional),
|
|
165
|
+
aliases=_normalize_tokens(aliases),
|
|
166
|
+
help=help,
|
|
167
|
+
output_format=output_format,
|
|
168
|
+
group=group,
|
|
169
|
+
feature_flag=feature_flag,
|
|
170
|
+
)
|
|
171
|
+
setattr(cls, _CLI_METADATA_ATTR, meta)
|
|
172
|
+
return cls
|
|
173
|
+
|
|
174
|
+
return decorator
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def feature_flag_command(
|
|
178
|
+
flag_key: str,
|
|
179
|
+
*,
|
|
180
|
+
default: bool = False,
|
|
181
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
182
|
+
"""Wrap a Typer command callable with dynamic feature flag evaluation.
|
|
183
|
+
|
|
184
|
+
Notes/Architectural Intent:
|
|
185
|
+
Evaluates the specified feature flag prior to command execution. If disabled,
|
|
186
|
+
prints a clean error message and exits with a non-zero status code without crashing.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
flag_key: Unique identifier of the feature flag to check.
|
|
190
|
+
default: Fallback boolean value if flag is not explicitly configured.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Decorator wrapping the target callable.
|
|
194
|
+
"""
|
|
195
|
+
from functools import wraps
|
|
196
|
+
|
|
197
|
+
import typer
|
|
198
|
+
|
|
199
|
+
from hexastack_core.adapters.feature_flags.config import ConfigFeatureFlagAdapter
|
|
200
|
+
from hexastack_core.domain.feature_flags import EvaluationContext
|
|
201
|
+
from hexastack_core.ports.feature_flags import FeatureFlagPort
|
|
202
|
+
|
|
203
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
204
|
+
@wraps(fn)
|
|
205
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
206
|
+
flags: FeatureFlagPort = ConfigFeatureFlagAdapter()
|
|
207
|
+
eval_ctx = EvaluationContext.from_current_context()
|
|
208
|
+
if not flags.is_enabled(flag_key, default=default, context=eval_ctx):
|
|
209
|
+
typer.secho(
|
|
210
|
+
f"Error: Command is disabled by feature flag '{flag_key}'.",
|
|
211
|
+
fg=typer.colors.RED,
|
|
212
|
+
err=True,
|
|
213
|
+
)
|
|
214
|
+
raise typer.Exit(code=1)
|
|
215
|
+
return fn(*args, **kwargs)
|
|
216
|
+
|
|
217
|
+
return wrapped
|
|
218
|
+
|
|
219
|
+
return decorator
|
hexastack_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Testing and demo narration utilities for Hexastack CLI applications."""
|
|
2
|
+
|
|
3
|
+
from hexastack_cli.testing.narrator import CliNarrator, TerminalEvent
|
|
4
|
+
from hexastack_cli.testing.terminal import render_cli_demo_video
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CliNarrator",
|
|
8
|
+
"render_cli_demo_video",
|
|
9
|
+
"TerminalEvent",
|
|
10
|
+
]
|