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/__init__.py +27 -0
- contree_cli/__main__.py +62 -0
- contree_cli/arguments.py +141 -0
- contree_cli/cli/__init__.py +0 -0
- contree_cli/cli/auth.py +124 -0
- contree_cli/cli/cat.py +74 -0
- contree_cli/cli/cd.py +49 -0
- contree_cli/cli/cp.py +107 -0
- contree_cli/cli/file.py +179 -0
- contree_cli/cli/images.py +88 -0
- contree_cli/cli/kill.py +86 -0
- contree_cli/cli/ls.py +83 -0
- contree_cli/cli/ps.py +120 -0
- contree_cli/cli/run.py +438 -0
- contree_cli/cli/session.py +282 -0
- contree_cli/cli/show.py +97 -0
- contree_cli/cli/tag.py +50 -0
- contree_cli/cli/use.py +119 -0
- contree_cli/client.py +222 -0
- contree_cli/config.py +116 -0
- contree_cli/log.py +40 -0
- contree_cli/mapped_file.py +112 -0
- contree_cli/output.py +376 -0
- contree_cli/session.py +761 -0
- contree_cli/shell/__init__.py +59 -0
- contree_cli/shell/completer.py +465 -0
- contree_cli/shell/history.py +53 -0
- contree_cli/shell/parser.py +107 -0
- contree_cli/shell/repl.py +486 -0
- contree_cli/shell/trie.py +113 -0
- contree_cli/types.py +87 -0
- contree_cli-0.2.3.dist-info/METADATA +323 -0
- contree_cli-0.2.3.dist-info/RECORD +35 -0
- contree_cli-0.2.3.dist-info/WHEEL +4 -0
- contree_cli-0.2.3.dist-info/entry_points.txt +3 -0
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
|