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.
@@ -0,0 +1,6 @@
1
+ from hexastack_cli import adapters, infra
2
+
3
+ __all__ = [
4
+ "adapters",
5
+ "infra",
6
+ ]
@@ -0,0 +1,13 @@
1
+ from hexastack_cli.adapters.app import create_cli_app
2
+ from hexastack_cli.adapters.presenter import RichTerminalPresenter
3
+ from hexastack_cli.adapters.routing import (
4
+ register_cqrs_command,
5
+ register_cqrs_query,
6
+ )
7
+
8
+ __all__ = [
9
+ "create_cli_app",
10
+ "register_cqrs_command",
11
+ "register_cqrs_query",
12
+ "RichTerminalPresenter",
13
+ ]
@@ -0,0 +1,77 @@
1
+ import typer
2
+ from rich.console import Console
3
+ from rodi import Container
4
+
5
+ from hexastack_cli.adapters.presenter import RichTerminalPresenter
6
+ from hexastack_cli.infra.config import HexastackCliConfig
7
+ from hexastack_core.domain import Generic
8
+ from hexastack_cqrs.infra.pipeline import ExecutionPipeline
9
+ from hexastack_cqrs.infra.registries.presenter import PresenterRegistry
10
+
11
+ __all__ = [
12
+ "create_cli_app",
13
+ ]
14
+
15
+
16
+ def create_cli_app(
17
+ config: HexastackCliConfig | None = None,
18
+ container: Container | None = None,
19
+ pipeline: ExecutionPipeline | None = None,
20
+ console: Console | None = None,
21
+ ) -> typer.Typer:
22
+ """Factory creating and configuring a Typer CLI application integrated with Hexastack.
23
+
24
+ Notes/Architectural Intent:
25
+ Assembles a Typer CLI instance configured with Rich formatting, binds container
26
+ and pipeline references, supports top-level --version, ensures multi-command dispatching,
27
+ and registers terminal presenters into the presenter registry.
28
+
29
+ Args:
30
+ config: Optional HexastackCliConfig instance.
31
+ container: Optional rodi Container instance.
32
+ pipeline: Optional ExecutionPipeline instance.
33
+ console: Optional rich Console instance.
34
+
35
+ Returns:
36
+ Configured Typer application instance.
37
+
38
+ Raises:
39
+ None.
40
+ """
41
+ cfg = config or HexastackCliConfig()
42
+ active_console = console or Console()
43
+
44
+ app = typer.Typer(
45
+ name=cfg.app_name,
46
+ help=cfg.help_text,
47
+ rich_markup_mode="rich" if cfg.rich_markup else None,
48
+ no_args_is_help=True,
49
+ )
50
+
51
+ def _version_callback(value: bool) -> None:
52
+ if value:
53
+ active_console.print(f"{cfg.app_name} {cfg.version}")
54
+ raise typer.Exit()
55
+
56
+ # Establish root callback to enforce multi-command structure and handle --version
57
+ @app.callback()
58
+ def _main(
59
+ version: bool | None = typer.Option(
60
+ None,
61
+ "--version",
62
+ "-v",
63
+ help="Show the application version and exit.",
64
+ callback=_version_callback,
65
+ is_eager=True,
66
+ ),
67
+ ) -> None:
68
+ pass
69
+
70
+ # Register RichTerminalPresenter if presenter registry is present in DI
71
+ if container is not None and PresenterRegistry in container:
72
+ pres_reg = container.resolve(PresenterRegistry)
73
+ terminal_presenter = RichTerminalPresenter(console=active_console)
74
+ pres_reg.register(Generic, "rich", terminal_presenter)
75
+ container.add_instance(terminal_presenter, declared_class=RichTerminalPresenter)
76
+
77
+ return app
@@ -0,0 +1,137 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+
11
+ from hexastack_core.domain import Generic
12
+ from hexastack_core.ports.presenter import PresenterPort
13
+
14
+
15
+ class RichTerminalPresenter(PresenterPort):
16
+ """Terminal presenter formatting domain models into stylized Rich panels, JSON, or plain text.
17
+
18
+ Notes/Architectural Intent:
19
+ Implements Presenter port for terminal and CI/pipe environments.
20
+ Supports structured JSON, plain line-oriented text, and interactive Rich tables.
21
+ Automatically respects NO_COLOR and non-TTY stdout streams.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ console: Console | None = None,
27
+ stderr_console: Console | None = None,
28
+ ) -> None:
29
+ """Initialize RichTerminalPresenter with optional stdout and stderr Console instances.
30
+
31
+ Args:
32
+ console: Optional rich.console.Console instance for stdout.
33
+ stderr_console: Optional rich.console.Console instance for stderr.
34
+ """
35
+ no_color = bool(os.environ.get("NO_COLOR"))
36
+ self._console = console or Console(no_color=no_color, highlight=not no_color)
37
+ self._stderr = stderr_console or Console(
38
+ stderr=True, no_color=no_color, highlight=not no_color
39
+ )
40
+
41
+ def _present_json(self, data: Any) -> Any:
42
+ """Render raw, pipe-friendly JSON to stdout."""
43
+ json_str = json.dumps(data, indent=2, default=str)
44
+ sys.stdout.write(json_str + "\n")
45
+ sys.stdout.flush()
46
+ return data
47
+
48
+ def _present_plain(self, data: Any) -> Any:
49
+ """Render TSV/newline-delimited text to stdout for Unix pipeline processing."""
50
+ if isinstance(data, dict):
51
+ for k, v in data.items():
52
+ sys.stdout.write(f"{k}\t{v}\n")
53
+ elif isinstance(data, list):
54
+ for item in data:
55
+ sys.stdout.write(f"{item}\n")
56
+ else:
57
+ sys.stdout.write(f"{data}\n")
58
+ sys.stdout.flush()
59
+ return data
60
+
61
+ def _present_table(self, instance: Generic, data: Any) -> Any:
62
+ """Render colorized Rich table/panel to stdout."""
63
+ if isinstance(data, dict):
64
+ table = Table(show_header=True, header_style="bold magenta")
65
+ table.add_column("Field", style="cyan")
66
+ table.add_column("Value", style="green")
67
+ for k, v in data.items():
68
+ val_str = (
69
+ json.dumps(v, indent=2) if isinstance(v, dict | list) else str(v)
70
+ )
71
+ table.add_row(str(k), val_str)
72
+ self._console.print(
73
+ Panel(table, title=type(instance).__name__, border_style="blue")
74
+ )
75
+ elif isinstance(data, list):
76
+ for item in data:
77
+ self._console.print(f"[cyan]•[/cyan] {item}")
78
+ else:
79
+ self._console.print(f"[bold green]{data}[/bold green]")
80
+ return data
81
+
82
+ def present(
83
+ self,
84
+ instance: Generic,
85
+ format_mode: str | None = None,
86
+ ) -> Any:
87
+ """Format and print a domain Generic instance to stdout based on requested format mode.
88
+
89
+ Args:
90
+ instance: Domain Generic or DTO model instance.
91
+ format_mode: Optional format mode ('table', 'json', 'plain').
92
+
93
+ Returns:
94
+ The presented raw data representation.
95
+
96
+ Raises:
97
+ None.
98
+ """
99
+ data = instance.model_dump() if isinstance(instance, BaseModel) else instance
100
+
101
+ mode = (format_mode or "table").lower()
102
+
103
+ if mode == "json":
104
+ return self._present_json(data)
105
+ if mode == "plain":
106
+ return self._present_plain(data)
107
+ return self._present_table(instance, data)
108
+
109
+ def print_error(self, message: str) -> None:
110
+ """Print an error message to stderr.
111
+
112
+ Args:
113
+ message: The error message string.
114
+
115
+ Returns:
116
+ None.
117
+
118
+ Raises:
119
+ None.
120
+ """
121
+ self._stderr.print(f"[bold red]Error:[/bold red] {message}")
122
+
123
+ def print_exception(self) -> None:
124
+ """Print a rich formatted traceback to stderr for debugging.
125
+
126
+ Returns:
127
+ None.
128
+
129
+ Raises:
130
+ None.
131
+ """
132
+ self._stderr.print_exception(show_locals=False)
133
+
134
+
135
+ __all__ = [
136
+ "RichTerminalPresenter",
137
+ ]
@@ -0,0 +1,396 @@
1
+ import asyncio
2
+ import inspect
3
+ import json
4
+ import re
5
+ import sys
6
+ from collections.abc import Callable, Sequence
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from hexastack_cli.adapters.presenter import RichTerminalPresenter
14
+ from hexastack_core.domain import Command, Generic, Query
15
+ from hexastack_core.utils.context import (
16
+ UserContext,
17
+ set_correlation_id,
18
+ set_user_context,
19
+ )
20
+ from hexastack_cqrs.infra.pipeline import ExecutionPipeline
21
+
22
+ __all__ = [
23
+ "register_cqrs_command",
24
+ "register_cqrs_query",
25
+ ]
26
+
27
+
28
+ def _build_control_parameters(
29
+ model_cls: type[Command] | type[Query],
30
+ output_format: str | None,
31
+ ) -> list[inspect.Parameter]:
32
+ """Build universal CLI control options (output format, input payload, tracing flags)."""
33
+ fields = model_cls.model_fields
34
+ specs: list[tuple[str, str, Any, Any]] = [
35
+ (
36
+ "output",
37
+ "__output_format__",
38
+ typer.Option(
39
+ output_format or "table",
40
+ "--output",
41
+ "-o",
42
+ help="Output format: table, json, or plain (CI/pipe friendly).",
43
+ ),
44
+ str,
45
+ ),
46
+ (
47
+ "input",
48
+ "__input__",
49
+ typer.Option(
50
+ None,
51
+ "--input",
52
+ "-i",
53
+ help="Input JSON payload string, file path, or '-' for stdin.",
54
+ ),
55
+ str | None,
56
+ ),
57
+ (
58
+ "quiet",
59
+ "__quiet__",
60
+ typer.Option(
61
+ False,
62
+ "--quiet",
63
+ "-q",
64
+ help="Quiet mode: suppress decorative terminal output.",
65
+ ),
66
+ bool,
67
+ ),
68
+ (
69
+ "debug",
70
+ "__debug__",
71
+ typer.Option(
72
+ False,
73
+ "--debug",
74
+ help="Enable debug mode and render formatted error tracebacks.",
75
+ ),
76
+ bool,
77
+ ),
78
+ (
79
+ "correlation_id",
80
+ "__correlation_id__",
81
+ typer.Option(
82
+ None,
83
+ "--correlation-id",
84
+ help="Explicit correlation ID for request tracing.",
85
+ ),
86
+ str | None,
87
+ ),
88
+ (
89
+ "user_id",
90
+ "__user_id__",
91
+ typer.Option(
92
+ None,
93
+ "--user-id",
94
+ help="Authenticated user context identifier.",
95
+ ),
96
+ str | None,
97
+ ),
98
+ (
99
+ "tenant_id",
100
+ "__tenant_id__",
101
+ typer.Option(
102
+ None,
103
+ "--tenant-id",
104
+ help="Tenant isolation identifier for multi-tenancy.",
105
+ ),
106
+ str | None,
107
+ ),
108
+ ]
109
+
110
+ return [
111
+ inspect.Parameter(
112
+ name=param_name,
113
+ kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
114
+ default=default_val,
115
+ annotation=ann,
116
+ )
117
+ for field_name, param_name, default_val, ann in specs
118
+ if field_name not in fields
119
+ ]
120
+
121
+
122
+ def _build_dynamic_cli_runner(
123
+ model_cls: type[Command] | type[Query],
124
+ pipeline: ExecutionPipeline,
125
+ positional: Sequence[str] | None = None,
126
+ output_format: str | None = None,
127
+ presenter: RichTerminalPresenter | None = None,
128
+ console: Console | None = None,
129
+ feature_flag: str | None = None,
130
+ ) -> Callable[..., None]:
131
+ """Dynamically construct a typed function whose signature matches model_cls fields and CLI flags for Typer."""
132
+ active_presenter = presenter or RichTerminalPresenter(console=console)
133
+ active_console = console or Console()
134
+ pos_set = set(positional or ())
135
+
136
+ def runner(**kwargs: Any) -> None:
137
+ requested_output = kwargs.pop("__output_format__", output_format)
138
+ quiet_mode = kwargs.pop("__quiet__", False)
139
+ debug_mode = kwargs.pop("__debug__", False)
140
+ correlation_id = kwargs.pop("__correlation_id__", None)
141
+ user_id = kwargs.pop("__user_id__", None)
142
+ tenant_id = kwargs.pop("__tenant_id__", None)
143
+ input_payload = kwargs.pop("__input__", None)
144
+
145
+ _setup_cli_context(correlation_id, user_id, tenant_id)
146
+
147
+ if feature_flag:
148
+ from hexastack_core.adapters.feature_flags.config import (
149
+ ConfigFeatureFlagAdapter,
150
+ )
151
+ from hexastack_core.domain.feature_flags import EvaluationContext
152
+ from hexastack_core.ports.feature_flags import FeatureFlagPort
153
+
154
+ flags: FeatureFlagPort = ConfigFeatureFlagAdapter()
155
+ eval_ctx = EvaluationContext.from_current_context()
156
+ if not flags.is_enabled(feature_flag, default=False, context=eval_ctx):
157
+ active_presenter.print_error(
158
+ f"Command is disabled by feature flag '{feature_flag}'."
159
+ )
160
+ raise typer.Exit(code=1)
161
+
162
+ try:
163
+ field_data = _resolve_cli_input(input_payload)
164
+ cli_flags = {k: v for k, v in kwargs.items() if v is not None}
165
+ result = _execute_cli_model(
166
+ model_cls, pipeline, field_data, cli_flags, requested_output
167
+ )
168
+ _present_cli_result(
169
+ result, requested_output, quiet_mode, active_presenter, active_console
170
+ )
171
+ except typer.Exit:
172
+ raise
173
+ except Exception as exc:
174
+ if debug_mode:
175
+ active_presenter.print_exception()
176
+ else:
177
+ active_presenter.print_error(str(exc))
178
+ raise typer.Exit(code=1) from exc
179
+
180
+ pos_params, opt_params = _build_model_parameters(model_cls, pos_set)
181
+ control_params = _build_control_parameters(model_cls, output_format)
182
+
183
+ all_params = pos_params + opt_params + control_params
184
+ sig = inspect.Signature(parameters=all_params)
185
+ cast("Any", runner).__signature__ = sig
186
+ return runner
187
+
188
+
189
+ def _build_model_parameters(
190
+ model_cls: type[Command] | type[Query],
191
+ pos_set: set[str],
192
+ ) -> tuple[list[inspect.Parameter], list[inspect.Parameter]]:
193
+ """Build positional and keyword parameters matching model_cls fields."""
194
+ pos_params: list[inspect.Parameter] = []
195
+ opt_params: list[inspect.Parameter] = []
196
+
197
+ for field_name, field_info in model_cls.model_fields.items():
198
+ annotation = field_info.annotation or str
199
+ is_pos = field_name in pos_set
200
+ default = (
201
+ typer.Argument(None, help=field_info.description)
202
+ if is_pos
203
+ else typer.Option(None, help=field_info.description)
204
+ )
205
+ param = inspect.Parameter(
206
+ name=field_name,
207
+ kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
208
+ default=default,
209
+ annotation=annotation,
210
+ )
211
+ if is_pos:
212
+ pos_params.append(param)
213
+ else:
214
+ opt_params.append(param)
215
+
216
+ return pos_params, opt_params
217
+
218
+
219
+ def _execute_cli_model(
220
+ model_cls: type[Command] | type[Query],
221
+ pipeline: ExecutionPipeline,
222
+ field_data: dict[str, Any],
223
+ cli_flags: dict[str, Any],
224
+ requested_output: str | None,
225
+ ) -> Any:
226
+ """Instantiate and execute CQRS command/query through the pipeline."""
227
+ payload = dict(field_data)
228
+ payload.update(cli_flags)
229
+ instance = model_cls(**payload)
230
+ result = pipeline.execute(instance, output_format=requested_output)
231
+ if inspect.iscoroutine(result):
232
+ result = asyncio.run(result)
233
+ return result
234
+
235
+
236
+ def _present_cli_result(
237
+ result: Any,
238
+ requested_output: str | None,
239
+ quiet_mode: bool,
240
+ active_presenter: RichTerminalPresenter,
241
+ active_console: Console,
242
+ ) -> None:
243
+ """Format and write CLI execution results to stdout or terminal presenter."""
244
+ if result is None:
245
+ return
246
+ if isinstance(result, Generic):
247
+ active_presenter.present(result, format_mode=requested_output)
248
+ elif not quiet_mode or requested_output in {"json", "plain"}:
249
+ if requested_output == "json":
250
+ sys.stdout.write(json.dumps(result, indent=2, default=str) + "\n")
251
+ sys.stdout.flush()
252
+ elif requested_output == "plain":
253
+ sys.stdout.write(f"{result}\n")
254
+ sys.stdout.flush()
255
+ elif not quiet_mode:
256
+ active_console.print(f"[bold green]{result}[/bold green]")
257
+
258
+
259
+ def _resolve_cli_input(input_payload: str | None) -> dict[str, Any]:
260
+ """Parse raw JSON string, file content, or stdin input into a dictionary."""
261
+ if not input_payload:
262
+ return {}
263
+ if input_payload == "-":
264
+ raw_json = sys.stdin.read()
265
+ elif Path(input_payload).is_file():
266
+ with Path(input_payload).open(encoding="utf-8") as f:
267
+ raw_json = f.read()
268
+ else:
269
+ raw_json = input_payload
270
+ payload_dict = json.loads(raw_json)
271
+ return payload_dict if isinstance(payload_dict, dict) else {}
272
+
273
+
274
+ def _setup_cli_context(
275
+ correlation_id: str | None,
276
+ user_id: str | None,
277
+ tenant_id: str | None,
278
+ ) -> None:
279
+ """Initialize correlation and user context for CLI command execution."""
280
+ if correlation_id:
281
+ set_correlation_id(correlation_id)
282
+ if user_id or tenant_id:
283
+ set_user_context(
284
+ UserContext(user_id=user_id or "cli-user", tenant_id=tenant_id)
285
+ )
286
+
287
+
288
+ def _to_kebab_case(name: str) -> str:
289
+ """Convert PascalCase class name to kebab-case CLI command name."""
290
+ s = re.sub(r"(.)([A-Z][a-z]+)", r"\1-\2", name)
291
+ s = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", s)
292
+ kebab = s.lower()
293
+ for suffix in ("-command", "-query", "-cmd", "-qry"):
294
+ while kebab.endswith(suffix) and len(kebab) > len(suffix):
295
+ kebab = kebab[: -len(suffix)]
296
+ return kebab
297
+
298
+
299
+ def register_cqrs_command(
300
+ app: typer.Typer,
301
+ command_cls: type[Command],
302
+ pipeline: ExecutionPipeline,
303
+ name: str | None = None,
304
+ positional: Sequence[str] | None = None,
305
+ help_text: str | None = None,
306
+ output_format: str | None = None,
307
+ presenter: RichTerminalPresenter | None = None,
308
+ console: Console | None = None,
309
+ feature_flag: str | None = None,
310
+ ) -> None:
311
+ """Register a CQRS Command model as a typed CLI sub-command on a Typer application.
312
+
313
+ Notes/Architectural Intent:
314
+ Reflects over Pydantic Command model fields to build dynamic CLI options and arguments,
315
+ injecting CI-friendly flags (--output, --input, --quiet, --debug, --correlation-id, --user-id, --tenant-id).
316
+
317
+ Args:
318
+ app: Target Typer application instance.
319
+ command_cls: Pydantic Command model class.
320
+ pipeline: ExecutionPipeline for dispatching commands.
321
+ name: Optional custom command name (defaults to kebab-case class name).
322
+ positional: Optional sequence of field names to map as positional CLI arguments.
323
+ help_text: Optional command description help string.
324
+ output_format: Optional presenter format.
325
+ presenter: Optional RichTerminalPresenter instance.
326
+ console: Optional rich Console instance.
327
+ feature_flag: Optional feature flag key required for execution.
328
+
329
+ Returns:
330
+ None.
331
+
332
+ Raises:
333
+ None.
334
+ """
335
+ cmd_name = name or _to_kebab_case(command_cls.__name__)
336
+ doc = help_text or (command_cls.__doc__ or f"Execute {command_cls.__name__}")
337
+ runner = _build_dynamic_cli_runner(
338
+ command_cls,
339
+ pipeline,
340
+ positional=positional,
341
+ output_format=output_format,
342
+ presenter=presenter,
343
+ console=console,
344
+ feature_flag=feature_flag,
345
+ )
346
+ app.command(name=cmd_name, help=doc)(runner)
347
+
348
+
349
+ def register_cqrs_query(
350
+ app: typer.Typer,
351
+ query_cls: type[Query],
352
+ pipeline: ExecutionPipeline,
353
+ name: str | None = None,
354
+ positional: Sequence[str] | None = None,
355
+ help_text: str | None = None,
356
+ output_format: str | None = None,
357
+ presenter: RichTerminalPresenter | None = None,
358
+ console: Console | None = None,
359
+ feature_flag: str | None = None,
360
+ ) -> None:
361
+ """Register a CQRS Query model as a typed CLI sub-command on a Typer application.
362
+
363
+ Notes/Architectural Intent:
364
+ Reflects over Pydantic Query model fields to build dynamic CLI options and arguments,
365
+ injecting CI-friendly flags (--output, --input, --quiet, --debug, --correlation-id, --user-id, --tenant-id).
366
+
367
+ Args:
368
+ app: Target Typer application instance.
369
+ query_cls: Pydantic Query model class.
370
+ pipeline: ExecutionPipeline for dispatching queries.
371
+ name: Optional custom query command name (defaults to kebab-case class name).
372
+ positional: Optional sequence of field names to map as positional CLI arguments.
373
+ help_text: Optional query description help string.
374
+ output_format: Optional presenter format.
375
+ presenter: Optional RichTerminalPresenter instance.
376
+ console: Optional rich Console instance.
377
+ feature_flag: Optional feature flag key required for execution.
378
+
379
+ Returns:
380
+ None.
381
+
382
+ Raises:
383
+ None.
384
+ """
385
+ qry_name = name or _to_kebab_case(query_cls.__name__)
386
+ doc = help_text or (query_cls.__doc__ or f"Execute {query_cls.__name__}")
387
+ runner = _build_dynamic_cli_runner(
388
+ query_cls,
389
+ pipeline,
390
+ positional=positional,
391
+ output_format=output_format,
392
+ presenter=presenter,
393
+ console=console,
394
+ feature_flag=feature_flag,
395
+ )
396
+ app.command(name=qry_name, help=doc)(runner)
@@ -0,0 +1,29 @@
1
+ from hexastack_cli.infra.autodiscovery import (
2
+ autodiscover_cli_commands,
3
+ create_cli_visitor,
4
+ )
5
+ from hexastack_cli.infra.bootstrap import CliBootstrapper
6
+ from hexastack_cli.infra.config import (
7
+ HexastackCliConfig,
8
+ register_cli_config,
9
+ )
10
+ from hexastack_cli.infra.decorators import (
11
+ CliMetadata,
12
+ GroupMetadata,
13
+ cli_command,
14
+ cli_group,
15
+ cli_query,
16
+ )
17
+
18
+ __all__ = [
19
+ "autodiscover_cli_commands",
20
+ "cli_command",
21
+ "cli_group",
22
+ "cli_query",
23
+ "CliBootstrapper",
24
+ "CliMetadata",
25
+ "create_cli_visitor",
26
+ "GroupMetadata",
27
+ "HexastackCliConfig",
28
+ "register_cli_config",
29
+ ]