contree-cli 0.2.3__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.
contree_cli/output.py ADDED
@@ -0,0 +1,376 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import functools
5
+ import io
6
+ import json
7
+ import logging
8
+ import shutil
9
+ import sys
10
+ from datetime import datetime, timedelta
11
+ from types import MappingProxyType
12
+
13
+ from contree_cli.types import IS_A_TTY, Colors
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ @functools.singledispatch
19
+ def _format_value(value: object) -> str:
20
+ """Human-friendly string for a value."""
21
+ return str(value)
22
+
23
+
24
+ @_format_value.register
25
+ def _(value: datetime) -> str:
26
+ return value.strftime("%Y-%m-%d %H:%M:%S")
27
+
28
+
29
+ @_format_value.register
30
+ def _(value: timedelta) -> str:
31
+ total = int(value.total_seconds())
32
+ if total < 60:
33
+ return f"{total}s"
34
+ if total < 3600:
35
+ return f"{total // 60}m{total % 60}s"
36
+ hours, remainder = divmod(total, 3600)
37
+ minutes = remainder // 60
38
+ return f"{hours}h{minutes}m"
39
+
40
+
41
+ @_format_value.register
42
+ def _(value: float) -> str:
43
+ return f"{value:.2f}"
44
+
45
+
46
+ @_format_value.register
47
+ def _(value: bool) -> str:
48
+ return str(value).lower()
49
+
50
+
51
+ @_format_value.register(type(None))
52
+ def _(value: None) -> str:
53
+ return ""
54
+
55
+
56
+ @functools.singledispatch
57
+ def _json_default(value: object) -> object:
58
+ """JSON serialiser fallback for non-standard types."""
59
+ raise TypeError(type(value))
60
+
61
+
62
+ @_json_default.register
63
+ def _(value: datetime) -> object:
64
+ return value.isoformat()
65
+
66
+
67
+ @_json_default.register
68
+ def _(value: timedelta) -> object:
69
+ return value.total_seconds()
70
+
71
+
72
+ def _truncate(text: str, width: int, ellipsis: str = "\u2026") -> str:
73
+ """Truncate *text* to *width* characters, adding ellipsis if needed."""
74
+ if len(text) <= width:
75
+ return text
76
+ elen = len(ellipsis)
77
+ if width > elen:
78
+ return text[: width - elen] + ellipsis
79
+ return text[:width]
80
+
81
+
82
+ def _fit_columns(
83
+ widths: dict[str, int],
84
+ columns: list[str],
85
+ available: int,
86
+ min_col_width: int = 3,
87
+ ) -> tuple[dict[str, int], bool]:
88
+ """Shrink column widths to fit within *available* characters.
89
+
90
+ Allocates space fairly: narrow columns keep their natural width,
91
+ wide columns share the remaining space equally.
92
+ Returns (adjusted_widths, was_truncated).
93
+ """
94
+ result = dict(widths)
95
+ sorted_cols = sorted(columns, key=lambda c: result[c])
96
+ remaining = available
97
+ truncated = False
98
+ for idx, col in enumerate(sorted_cols):
99
+ cols_left = len(sorted_cols) - idx
100
+ fair_share = remaining // cols_left if cols_left else remaining
101
+ allocated = min(result[col], max(fair_share, min_col_width))
102
+ if allocated < result[col]:
103
+ truncated = True
104
+ result[col] = allocated
105
+ remaining -= allocated
106
+ return result, truncated
107
+
108
+
109
+ class OutputFormatter:
110
+ """Base formatter - subclasses decide the serialisation style."""
111
+
112
+ # Not suitable for streaming stdout/stderr output (e.g. from `run`)
113
+ STREAM = False
114
+
115
+ def __call__(self, **kwargs: object) -> None:
116
+ raise NotImplementedError
117
+
118
+ def flush(self) -> None:
119
+ """Flush any buffered output. No-op for streaming formatters."""
120
+
121
+
122
+ class CSVFormatter(OutputFormatter):
123
+ def __init__(self) -> None:
124
+ self._header_written = False
125
+
126
+ def __call__(self, **kwargs: object) -> None:
127
+ buf = io.StringIO()
128
+ writer = csv.writer(buf)
129
+ if not self._header_written:
130
+ writer.writerow(kwargs.keys())
131
+ self._header_written = True
132
+ writer.writerow(_format_value(v) for v in kwargs.values())
133
+ sys.stdout.write(buf.getvalue())
134
+
135
+
136
+ class TSVFormatter(OutputFormatter):
137
+ def __init__(self) -> None:
138
+ self._header_written = False
139
+
140
+ def __call__(self, **kwargs: object) -> None:
141
+ buf = io.StringIO()
142
+ writer = csv.writer(buf, dialect="excel-tab")
143
+ if not self._header_written:
144
+ writer.writerow(kwargs.keys())
145
+ self._header_written = True
146
+ writer.writerow(_format_value(v) for v in kwargs.values())
147
+ sys.stdout.write(buf.getvalue())
148
+
149
+
150
+ class JSONFormatter(OutputFormatter):
151
+ STREAM = True
152
+
153
+ def __call__(self, **kwargs: object) -> None:
154
+ sys.stdout.write(json.dumps(kwargs, default=_json_default) + "\n")
155
+
156
+
157
+ class JSONPrettyFormatter(OutputFormatter):
158
+ STREAM = True
159
+
160
+ def __init__(self) -> None:
161
+ self._rows: list[dict[str, object]] = []
162
+
163
+ def __call__(self, **kwargs: object) -> None:
164
+ self._rows.append(kwargs)
165
+
166
+ def flush(self) -> None:
167
+ if not self._rows:
168
+ return
169
+ sys.stdout.write(
170
+ json.dumps(self._rows, indent=2, default=_json_default) + "\n",
171
+ )
172
+ self._rows.clear()
173
+
174
+
175
+ class TableFormatter(OutputFormatter):
176
+ ELLIPSIS = "\u2026" if IS_A_TTY else "..."
177
+ MIN_COL_WIDTH = 3
178
+ COLUMN_PALETTE: tuple[Colors, ...] = (
179
+ Colors.CYAN,
180
+ Colors.GREEN,
181
+ Colors.YELLOW,
182
+ Colors.BLUE,
183
+ Colors.MAGENTA,
184
+ Colors.DEFAULT,
185
+ )
186
+ VALUE_COLORS = MappingProxyType({
187
+ "SUCCESS": Colors.GREEN,
188
+ "FAILED": Colors.RED,
189
+ "CANCELLED": Colors.YELLOW,
190
+ "PENDING": Colors.GRAY,
191
+ "ASSIGNED": Colors.CYAN,
192
+ "EXECUTING": Colors.BLUE,
193
+ "true": Colors.GREEN,
194
+ "false": Colors.RED,
195
+ "": Colors.GRAY,
196
+ })
197
+
198
+ def __init__(self) -> None:
199
+ self._rows: list[dict[str, object]] = []
200
+
201
+ def __call__(self, **kwargs: object) -> None:
202
+ self._rows.append(kwargs)
203
+
204
+ def flush(self) -> None:
205
+ if not self._rows:
206
+ return
207
+ columns = list(self._rows[0].keys())
208
+ widths = {col: len(col) for col in columns}
209
+ # Split each cell into lines and compute natural column widths.
210
+ split_rows: list[dict[str, list[str]]] = []
211
+ for row in self._rows:
212
+ split_row: dict[str, list[str]] = {}
213
+ for col in columns:
214
+ lines = _format_value(row.get(col, "")).split("\n")
215
+ split_row[col] = lines
216
+ for line in lines:
217
+ widths[col] = max(widths[col], len(line))
218
+ split_rows.append(split_row)
219
+ is_tty = sys.stdout.isatty()
220
+ # Constrain to terminal width when outputting to a TTY.
221
+ truncated = False
222
+ if is_tty:
223
+ term_width = shutil.get_terminal_size().columns
224
+ separator_space = (len(columns) - 1) * 2
225
+ available = term_width - separator_space
226
+ if sum(widths.values()) > available > 0:
227
+ widths, truncated = _fit_columns(
228
+ widths, columns, available, self.MIN_COL_WIDTH,
229
+ )
230
+ # Assign a color per column (cycling through the palette).
231
+ palette = self.COLUMN_PALETTE
232
+ col_colors: dict[str, Colors] = {}
233
+ if is_tty:
234
+ for idx, col in enumerate(columns):
235
+ col_colors[col] = palette[idx % len(palette)]
236
+ # Render header (bold when TTY).
237
+ header_parts: list[str] = []
238
+ for col in columns:
239
+ padded = _truncate(
240
+ col.upper(), widths[col], self.ELLIPSIS,
241
+ ).ljust(widths[col])
242
+ if is_tty:
243
+ padded = Colors.BOLD(padded)
244
+ header_parts.append(padded)
245
+ sys.stdout.write(" ".join(header_parts) + "\n")
246
+ # Render rows.
247
+ for split_row in split_rows:
248
+ height = max(len(split_row[col]) for col in columns)
249
+ for i in range(height):
250
+ parts: list[str] = []
251
+ for col in columns:
252
+ lines = split_row[col]
253
+ cell = lines[i] if i < len(lines) else ""
254
+ padded = _truncate(
255
+ cell, widths[col], self.ELLIPSIS,
256
+ ).ljust(widths[col])
257
+ if col in col_colors:
258
+ color = self.VALUE_COLORS.get(
259
+ cell.strip(), col_colors[col],
260
+ )
261
+ padded = color(padded)
262
+ parts.append(padded)
263
+ sys.stdout.write(" ".join(parts) + "\n")
264
+ if truncated:
265
+ log.warning(
266
+ "Output truncated to fit terminal;"
267
+ " use --format json to see full values",
268
+ )
269
+ self._rows.clear()
270
+
271
+
272
+ class DefaultFormatter(TableFormatter):
273
+ """Default formatter - commands may detect and replace with custom output."""
274
+
275
+
276
+ class PlainFormatter(OutputFormatter):
277
+ STREAM = True
278
+
279
+ def __init__(self) -> None:
280
+ self._count = 0
281
+
282
+ def __call__(self, **kwargs: object) -> None:
283
+ if self._count:
284
+ sys.stdout.write("---\n")
285
+ self._count += 1
286
+ key_width = max(len(k) for k in kwargs) if kwargs else 0
287
+ for key, val in kwargs.items():
288
+ text = _format_value(val)
289
+ label = f"{key}:".ljust(key_width + 2)
290
+ lines = text.split("\n")
291
+ sys.stdout.write(f"{label}{lines[0]}\n")
292
+ indent = " " * len(label)
293
+ for line in lines[1:]:
294
+ sys.stdout.write(f"{indent}{line}\n")
295
+
296
+
297
+ FORMATTERS: dict[str, type[OutputFormatter]] = {
298
+ "csv": CSVFormatter,
299
+ "tsv": TSVFormatter,
300
+ "json": JSONFormatter,
301
+ "json-pretty": JSONPrettyFormatter,
302
+ "plain": PlainFormatter,
303
+ "table": TableFormatter,
304
+ "default": DefaultFormatter,
305
+ }
306
+
307
+
308
+ @functools.singledispatch
309
+ def _toml_value(value: object) -> str:
310
+ return json.dumps(str(value))
311
+
312
+ @_toml_value.register
313
+ def _(value: str) -> str:
314
+ # TOML basic string with escaping
315
+ escaped = (
316
+ value.replace("\\", "\\\\")
317
+ .replace('"', '\\"')
318
+ .replace("\n", "\\n")
319
+ .replace("\r", "\\r")
320
+ .replace("\t", "\\t")
321
+ )
322
+ return f'"{escaped}"'
323
+
324
+ @_toml_value.register
325
+ def _(value: bool) -> str:
326
+ return "true" if value else "false"
327
+
328
+ @_toml_value.register
329
+ def _(value: int) -> str:
330
+ return str(value)
331
+
332
+ @_toml_value.register
333
+ def _(value: float) -> str:
334
+ return str(value)
335
+
336
+ @_toml_value.register
337
+ def _(value: datetime) -> str:
338
+ return value.isoformat()
339
+
340
+ @_toml_value.register
341
+ def _(value: timedelta) -> str:
342
+ return str(value.total_seconds())
343
+
344
+ @_toml_value.register(type(None))
345
+ def _(value: None) -> str:
346
+ return '""'
347
+
348
+
349
+ try:
350
+ import tomllib as _tomllib # type: ignore[import-not-found] # noqa: F401
351
+
352
+ class TOMLFormatter(OutputFormatter):
353
+ STREAM = True
354
+
355
+ def __init__(self) -> None:
356
+ self._rows: list[dict[str, object]] = []
357
+
358
+ def __call__(self, **kwargs: object) -> None:
359
+ self._rows.append(kwargs)
360
+
361
+ def flush(self) -> None:
362
+ if not self._rows:
363
+ return
364
+ parts: list[str] = []
365
+ for row in self._rows:
366
+ parts.append("[[results]]")
367
+ for key, val in row.items():
368
+ parts.append(f"{key} = {_toml_value(val)}")
369
+ parts.append("")
370
+ sys.stdout.write("\n".join(parts))
371
+ self._rows.clear()
372
+
373
+ FORMATTERS["toml"] = TOMLFormatter
374
+
375
+ except ImportError:
376
+ pass