solwyn-cli 0.1.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.
solwyn_cli/output.py ADDED
@@ -0,0 +1,263 @@
1
+ """Deterministic human and machine output rendering."""
2
+
3
+ import json
4
+ import re
5
+ from collections.abc import Mapping
6
+ from enum import StrEnum
7
+ from typing import Any, TextIO
8
+
9
+ from pydantic import (
10
+ BaseModel,
11
+ ConfigDict,
12
+ JsonValue,
13
+ SecretStr,
14
+ TypeAdapter,
15
+ model_validator,
16
+ )
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+ from rich.text import Text
21
+
22
+ from solwyn_cli.errors import ApiError
23
+
24
+
25
+ _COMMAND_SCHEMA = re.compile(r"^solwyn\.[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*\.v1$")
26
+ _JSON_ADAPTER = TypeAdapter(Any)
27
+
28
+
29
+ class OutputUsageError(ValueError):
30
+ """An output option or payload violates the public CLI contract."""
31
+
32
+
33
+ class OutputFormat(StrEnum):
34
+ """The only supported presentation formats."""
35
+
36
+ TABLE = "table"
37
+ JSON = "json"
38
+
39
+
40
+ class OutputError(BaseModel):
41
+ """Safe local error fields accepted by the output engine."""
42
+
43
+ model_config = ConfigDict(extra="forbid", frozen=True)
44
+
45
+ code: str
46
+ message: str
47
+ hint: str | None = None
48
+ detail: JsonValue | None = None
49
+
50
+
51
+ class TableData(BaseModel):
52
+ """Validated human table cells supplied by a command."""
53
+
54
+ model_config = ConfigDict(extra="forbid", frozen=True)
55
+
56
+ columns: tuple[str, ...]
57
+ rows: tuple[tuple[Any, ...], ...]
58
+ right_align: tuple[str, ...] = ()
59
+
60
+ @model_validator(mode="after")
61
+ def validate_shape(self) -> "TableData":
62
+ """Require non-empty columns and consistently shaped rows."""
63
+ if not self.columns:
64
+ raise ValueError("Tables require at least one column")
65
+ if len(set(self.columns)) != len(self.columns):
66
+ raise ValueError("Table column names must be unique")
67
+ if any(len(row) != len(self.columns) for row in self.rows):
68
+ raise ValueError("Every table row must have the same number of cells")
69
+ if not set(self.right_align).issubset(self.columns):
70
+ raise ValueError("Right-aligned columns must exist in the table")
71
+ return self
72
+
73
+
74
+ class OneTimeSecretPanel(BaseModel):
75
+ """A redacted in-memory secret rendered once in table output."""
76
+
77
+ model_config = ConfigDict(extra="forbid", frozen=True)
78
+
79
+ title: str
80
+ value: SecretStr
81
+
82
+
83
+ def _parse_format(value: str | OutputFormat, *, source: str) -> OutputFormat:
84
+ try:
85
+ return OutputFormat(value)
86
+ except ValueError:
87
+ raise OutputUsageError(f"Invalid {source} output format.") from None
88
+
89
+
90
+ def resolve_output_format(
91
+ *,
92
+ explicit_format: str | OutputFormat | None,
93
+ json_flag: bool,
94
+ environ: Mapping[str, str],
95
+ config_format: str | OutputFormat | None,
96
+ ) -> OutputFormat:
97
+ """Resolve explicit intent, then environment, config, and the table default."""
98
+ if json_flag and explicit_format is not None:
99
+ parsed_explicit = _parse_format(explicit_format, source="explicit")
100
+ if parsed_explicit is OutputFormat.TABLE:
101
+ raise OutputUsageError("Conflicting output format options.")
102
+ return OutputFormat.JSON
103
+ if json_flag:
104
+ return OutputFormat.JSON
105
+ if explicit_format is not None:
106
+ return _parse_format(explicit_format, source="explicit")
107
+ if "SOLWYN_FORMAT" in environ:
108
+ try:
109
+ return OutputFormat(environ["SOLWYN_FORMAT"])
110
+ except ValueError:
111
+ raise OutputUsageError("Invalid SOLWYN_FORMAT output format.") from None
112
+ if config_format is not None:
113
+ return _parse_format(config_format, source="configured")
114
+ return OutputFormat.TABLE
115
+
116
+
117
+ def color_enabled(
118
+ *,
119
+ is_tty: bool,
120
+ no_color: bool,
121
+ environ: Mapping[str, str],
122
+ ) -> bool:
123
+ """Return whether Rich may emit color for one human stream."""
124
+ return is_tty and not no_color and "NO_COLOR" not in environ
125
+
126
+
127
+ def _json_value(value: object) -> Any:
128
+ return _JSON_ADAPTER.dump_python(value, mode="json")
129
+
130
+
131
+ def _terminal_safe(value: object) -> str:
132
+ """Neutralize terminal control characters in caller-provided human text."""
133
+ return "".join(
134
+ "\N{REPLACEMENT CHARACTER}"
135
+ if ord(character) < 0x20 or 0x7F <= ord(character) <= 0x9F
136
+ else character
137
+ for character in str(value)
138
+ )
139
+
140
+
141
+ def _success_data(data: Mapping[str, object] | BaseModel) -> dict[str, Any]:
142
+ serialized = _json_value(data)
143
+ if not isinstance(serialized, dict):
144
+ raise OutputUsageError("Success payload data must be an object.")
145
+ if "schema" in serialized:
146
+ raise OutputUsageError(
147
+ "Success payload data contains the reserved schema field."
148
+ )
149
+ return serialized
150
+
151
+
152
+ class OutputEngine:
153
+ """Write contract-safe command output to injectable streams."""
154
+
155
+ def __init__(
156
+ self,
157
+ *,
158
+ output_format: OutputFormat,
159
+ stdout: TextIO,
160
+ stderr: TextIO,
161
+ stdout_is_tty: bool,
162
+ stderr_is_tty: bool,
163
+ no_color: bool,
164
+ environ: Mapping[str, str],
165
+ width: int = 80,
166
+ ) -> None:
167
+ self.output_format = output_format
168
+ self._stdout = stdout
169
+ self._stderr = stderr
170
+ self._stdout_color = color_enabled(
171
+ is_tty=stdout_is_tty,
172
+ no_color=no_color,
173
+ environ=environ,
174
+ )
175
+ self._stderr_color = color_enabled(
176
+ is_tty=stderr_is_tty,
177
+ no_color=no_color,
178
+ environ=environ,
179
+ )
180
+ self._width = width
181
+
182
+ def _console(self, *, error: bool) -> Console:
183
+ color = self._stderr_color if error else self._stdout_color
184
+ return Console(
185
+ file=self._stderr if error else self._stdout,
186
+ width=self._width,
187
+ force_terminal=color,
188
+ no_color=not color,
189
+ color_system="standard" if color else None,
190
+ highlight=False,
191
+ soft_wrap=False,
192
+ )
193
+
194
+ def _write_json(self, value: Mapping[str, object], *, error: bool) -> None:
195
+ stream = self._stderr if error else self._stdout
196
+ serialized = _json_value(value)
197
+ stream.write(
198
+ json.dumps(serialized, ensure_ascii=True, separators=(",", ":")) + "\n"
199
+ )
200
+
201
+ def write_success(
202
+ self,
203
+ *,
204
+ schema: str,
205
+ data: Mapping[str, object] | BaseModel,
206
+ table: TableData | None = None,
207
+ one_time_secret: OneTimeSecretPanel | None = None,
208
+ ) -> None:
209
+ """Write one schema-bearing JSON object or its explicit human table."""
210
+ if _COMMAND_SCHEMA.fullmatch(schema) is None:
211
+ raise OutputUsageError("Invalid command schema.")
212
+ payload = _success_data(data)
213
+ if self.output_format is OutputFormat.JSON:
214
+ self._write_json({"schema": schema, **payload}, error=False)
215
+ return
216
+ if table is None:
217
+ raise OutputUsageError("Table output requires human table data.")
218
+ rich_table = Table(
219
+ box=None,
220
+ padding=(0, 1),
221
+ pad_edge=False,
222
+ header_style="bold cyan",
223
+ )
224
+ for column in table.columns:
225
+ rich_table.add_column(
226
+ Text(_terminal_safe(column)),
227
+ justify="right" if column in table.right_align else "left",
228
+ )
229
+ for row in table.rows:
230
+ rich_table.add_row(*(Text(_terminal_safe(cell)) for cell in row))
231
+ console = self._console(error=False)
232
+ console.print(rich_table)
233
+ if one_time_secret is not None:
234
+ console.print(
235
+ Panel(
236
+ Text(_terminal_safe(one_time_secret.value.get_secret_value())),
237
+ title=Text(_terminal_safe(one_time_secret.title)),
238
+ expand=False,
239
+ )
240
+ )
241
+
242
+ def write_error(self, error: ApiError | OutputError) -> None:
243
+ """Write a stable machine envelope or a detail-free human error."""
244
+ payload = {
245
+ "schema": "solwyn.error.v1",
246
+ "code": error.code,
247
+ "message": error.message,
248
+ "hint": error.hint,
249
+ "detail": error.detail,
250
+ }
251
+ if self.output_format is OutputFormat.JSON:
252
+ self._write_json(payload, error=True)
253
+ return
254
+
255
+ message = Text("Error ", style="bold red")
256
+ message.append(Text(f"[{_terminal_safe(error.code)}]: ", style="bold"))
257
+ message.append(Text(_terminal_safe(error.message)))
258
+ console = self._console(error=True)
259
+ console.print(message)
260
+ if error.hint is not None:
261
+ hint = Text("Hint: ", style="bold yellow")
262
+ hint.append(Text(_terminal_safe(error.hint)))
263
+ console.print(hint)
solwyn_cli/runtime.py ADDED
@@ -0,0 +1,115 @@
1
+ """Resolved global command context and output construction."""
2
+
3
+ import os
4
+ import sys
5
+ from collections.abc import Mapping
6
+
7
+ import typer
8
+ from pydantic import BaseModel, ConfigDict
9
+
10
+ from solwyn_cli.config import Config, load_config
11
+ from solwyn_cli.output import OutputEngine, OutputFormat, resolve_output_format
12
+
13
+
14
+ class CommandContext(BaseModel):
15
+ """Non-secret global settings resolved once for a command invocation."""
16
+
17
+ model_config = ConfigDict(extra="forbid", frozen=True)
18
+
19
+ api_url: str
20
+ project: str | None
21
+ output_format: OutputFormat
22
+ no_color: bool
23
+ environment_no_color: bool
24
+
25
+
26
+ def resolve_command_context(
27
+ *,
28
+ api_url: str | None,
29
+ project: str | None,
30
+ output_format: str | None,
31
+ json_flag: bool,
32
+ no_color: bool,
33
+ environ: Mapping[str, str] | None = None,
34
+ ) -> CommandContext:
35
+ """Resolve CLI, environment, config, and default precedence."""
36
+ environment = os.environ if environ is None else environ
37
+ config = load_config()
38
+ resolved_api_url = api_url or environment.get("SOLWYN_API_URL") or config.api_url
39
+ validated_url = Config(api_url=resolved_api_url).api_url
40
+ return CommandContext(
41
+ api_url=validated_url,
42
+ project=(
43
+ project or environment.get("SOLWYN_PROJECT_ID") or config.default_project
44
+ ),
45
+ output_format=resolve_output_format(
46
+ explicit_format=output_format,
47
+ json_flag=json_flag,
48
+ environ=environment,
49
+ config_format=config.format,
50
+ ),
51
+ no_color=no_color,
52
+ environment_no_color="NO_COLOR" in environment,
53
+ )
54
+
55
+
56
+ def validate_output_intent(
57
+ *,
58
+ output_format: str | None,
59
+ json_flag: bool,
60
+ environ: Mapping[str, str] | None = None,
61
+ ) -> None:
62
+ """Validate explicit and environment output intent without loading config."""
63
+ environment = os.environ if environ is None else environ
64
+ resolve_output_format(
65
+ explicit_format=output_format,
66
+ json_flag=json_flag,
67
+ environ=environment,
68
+ config_format=None,
69
+ )
70
+
71
+
72
+ def command_context(context: typer.Context) -> CommandContext:
73
+ """Return the callback-populated context for a registered command."""
74
+ if not isinstance(context.obj, CommandContext):
75
+ raise RuntimeError("Command context was not initialized.")
76
+ return context.obj
77
+
78
+
79
+ def output_engine(context: typer.Context) -> OutputEngine:
80
+ """Build an output engine over the invocation's isolated streams."""
81
+ resolved = command_context(context)
82
+ return OutputEngine(
83
+ output_format=resolved.output_format,
84
+ stdout=sys.stdout,
85
+ stderr=sys.stderr,
86
+ stdout_is_tty=sys.stdout.isatty(),
87
+ stderr_is_tty=sys.stderr.isatty(),
88
+ no_color=resolved.no_color,
89
+ environ={"NO_COLOR": ""} if resolved.environment_no_color else {},
90
+ )
91
+
92
+
93
+ def bootstrap_output_engine(
94
+ *,
95
+ output_format: str | None,
96
+ json_flag: bool,
97
+ no_color: bool,
98
+ environ: Mapping[str, str] | None = None,
99
+ ) -> OutputEngine:
100
+ """Build output before configuration can be safely loaded."""
101
+ environment = os.environ if environ is None else environ
102
+ return OutputEngine(
103
+ output_format=resolve_output_format(
104
+ explicit_format=output_format,
105
+ json_flag=json_flag,
106
+ environ=environment,
107
+ config_format=None,
108
+ ),
109
+ stdout=sys.stdout,
110
+ stderr=sys.stderr,
111
+ stdout_is_tty=sys.stdout.isatty(),
112
+ stderr_is_tty=sys.stderr.isatty(),
113
+ no_color=no_color,
114
+ environ={"NO_COLOR": ""} if "NO_COLOR" in environment else {},
115
+ )
@@ -0,0 +1 @@
1
+ """Checked-in API contract snapshot used for drift detection."""