affinity-sdk 0.9.5__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.
- affinity/__init__.py +139 -0
- affinity/cli/__init__.py +7 -0
- affinity/cli/click_compat.py +27 -0
- affinity/cli/commands/__init__.py +1 -0
- affinity/cli/commands/_entity_files_dump.py +219 -0
- affinity/cli/commands/_list_entry_fields.py +41 -0
- affinity/cli/commands/_v1_parsing.py +77 -0
- affinity/cli/commands/company_cmds.py +2139 -0
- affinity/cli/commands/completion_cmd.py +33 -0
- affinity/cli/commands/config_cmds.py +540 -0
- affinity/cli/commands/entry_cmds.py +33 -0
- affinity/cli/commands/field_cmds.py +413 -0
- affinity/cli/commands/interaction_cmds.py +875 -0
- affinity/cli/commands/list_cmds.py +3152 -0
- affinity/cli/commands/note_cmds.py +433 -0
- affinity/cli/commands/opportunity_cmds.py +1174 -0
- affinity/cli/commands/person_cmds.py +1980 -0
- affinity/cli/commands/query_cmd.py +444 -0
- affinity/cli/commands/relationship_strength_cmds.py +62 -0
- affinity/cli/commands/reminder_cmds.py +595 -0
- affinity/cli/commands/resolve_url_cmd.py +127 -0
- affinity/cli/commands/session_cmds.py +84 -0
- affinity/cli/commands/task_cmds.py +110 -0
- affinity/cli/commands/version_cmd.py +29 -0
- affinity/cli/commands/whoami_cmd.py +36 -0
- affinity/cli/config.py +108 -0
- affinity/cli/context.py +749 -0
- affinity/cli/csv_utils.py +195 -0
- affinity/cli/date_utils.py +42 -0
- affinity/cli/decorators.py +77 -0
- affinity/cli/errors.py +28 -0
- affinity/cli/field_utils.py +355 -0
- affinity/cli/formatters.py +551 -0
- affinity/cli/help_json.py +283 -0
- affinity/cli/logging.py +100 -0
- affinity/cli/main.py +261 -0
- affinity/cli/options.py +53 -0
- affinity/cli/paths.py +32 -0
- affinity/cli/progress.py +183 -0
- affinity/cli/query/__init__.py +163 -0
- affinity/cli/query/aggregates.py +357 -0
- affinity/cli/query/dates.py +194 -0
- affinity/cli/query/exceptions.py +147 -0
- affinity/cli/query/executor.py +1236 -0
- affinity/cli/query/filters.py +248 -0
- affinity/cli/query/models.py +333 -0
- affinity/cli/query/output.py +331 -0
- affinity/cli/query/parser.py +619 -0
- affinity/cli/query/planner.py +430 -0
- affinity/cli/query/progress.py +270 -0
- affinity/cli/query/schema.py +439 -0
- affinity/cli/render.py +1589 -0
- affinity/cli/resolve.py +222 -0
- affinity/cli/resolvers.py +249 -0
- affinity/cli/results.py +308 -0
- affinity/cli/runner.py +218 -0
- affinity/cli/serialization.py +65 -0
- affinity/cli/session_cache.py +276 -0
- affinity/cli/types.py +70 -0
- affinity/client.py +771 -0
- affinity/clients/__init__.py +19 -0
- affinity/clients/http.py +3664 -0
- affinity/clients/pipeline.py +165 -0
- affinity/compare.py +501 -0
- affinity/downloads.py +114 -0
- affinity/exceptions.py +615 -0
- affinity/filters.py +1128 -0
- affinity/hooks.py +198 -0
- affinity/inbound_webhooks.py +302 -0
- affinity/models/__init__.py +163 -0
- affinity/models/entities.py +798 -0
- affinity/models/pagination.py +513 -0
- affinity/models/rate_limit_snapshot.py +48 -0
- affinity/models/secondary.py +413 -0
- affinity/models/types.py +663 -0
- affinity/policies.py +40 -0
- affinity/progress.py +22 -0
- affinity/py.typed +0 -0
- affinity/services/__init__.py +42 -0
- affinity/services/companies.py +1286 -0
- affinity/services/lists.py +1892 -0
- affinity/services/opportunities.py +1330 -0
- affinity/services/persons.py +1348 -0
- affinity/services/rate_limits.py +173 -0
- affinity/services/tasks.py +193 -0
- affinity/services/v1_only.py +2445 -0
- affinity/types.py +83 -0
- affinity_sdk-0.9.5.dist-info/METADATA +622 -0
- affinity_sdk-0.9.5.dist-info/RECORD +92 -0
- affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
- affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
- affinity_sdk-0.9.5.dist-info/licenses/LICENSE +21 -0
affinity/cli/render.py
ADDED
|
@@ -0,0 +1,1589 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from rich.console import Console, Group
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from affinity.models.rate_limit_snapshot import RateLimitSnapshot
|
|
17
|
+
|
|
18
|
+
from .results import CommandResult, ResultSummary
|
|
19
|
+
|
|
20
|
+
# Columns likely to contain long content - used for column priority (drop first when limiting)
|
|
21
|
+
_LONG_COLUMNS = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"dropdownoptions",
|
|
24
|
+
"dropdown_options",
|
|
25
|
+
"domains",
|
|
26
|
+
"emails",
|
|
27
|
+
"description",
|
|
28
|
+
"notes",
|
|
29
|
+
"content",
|
|
30
|
+
"fields",
|
|
31
|
+
"body",
|
|
32
|
+
"subject",
|
|
33
|
+
"snippet",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Essential columns that should always be kept when limiting columns
|
|
38
|
+
_ESSENTIAL_COLUMNS = frozenset({"id", "listentryid"})
|
|
39
|
+
|
|
40
|
+
# Minimum readable column width for calculating max columns
|
|
41
|
+
_MIN_COL_WIDTH = 8
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_terminal_width() -> int:
|
|
45
|
+
"""Get terminal width, with fallbacks."""
|
|
46
|
+
try:
|
|
47
|
+
return shutil.get_terminal_size().columns
|
|
48
|
+
except (ValueError, OSError):
|
|
49
|
+
return 80 # Standard fallback
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_max_columns(terminal_width: int | None = None) -> int:
|
|
53
|
+
"""Calculate max columns based on terminal width."""
|
|
54
|
+
width = terminal_width or get_terminal_width()
|
|
55
|
+
# Account for borders (~3 chars per column: | + padding)
|
|
56
|
+
usable = width - 2 # outer borders
|
|
57
|
+
return max(4, usable // (_MIN_COL_WIDTH + 3)) # ~12 for 80 chars, ~20 for 160 chars
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def limit_columns(
|
|
61
|
+
columns: list[str],
|
|
62
|
+
max_cols: int | None = None,
|
|
63
|
+
essential: frozenset[str] | None = None,
|
|
64
|
+
drop_first: frozenset[str] | None = None,
|
|
65
|
+
) -> tuple[list[str], int]:
|
|
66
|
+
"""Limit columns to fit terminal, return (limited_cols, omitted_count).
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
columns: All columns to consider
|
|
70
|
+
max_cols: Maximum columns to show (default: auto from terminal width)
|
|
71
|
+
essential: Columns to always keep (default: _ESSENTIAL_COLUMNS)
|
|
72
|
+
drop_first: Columns to drop first when over limit (default: _LONG_COLUMNS)
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
(selected_columns, omitted_count) - preserves original column order
|
|
76
|
+
"""
|
|
77
|
+
max_cols = max_cols if max_cols is not None else get_max_columns()
|
|
78
|
+
essential = essential if essential is not None else _ESSENTIAL_COLUMNS
|
|
79
|
+
drop_first = drop_first if drop_first is not None else _LONG_COLUMNS
|
|
80
|
+
|
|
81
|
+
if len(columns) <= max_cols:
|
|
82
|
+
return columns, 0
|
|
83
|
+
|
|
84
|
+
# Use LISTS (not sets) to preserve deterministic order
|
|
85
|
+
# Sets have undefined iteration order - would cause non-deterministic column selection
|
|
86
|
+
essential_cols = [c for c in columns if c.lower() in essential]
|
|
87
|
+
droppable_cols = [c for c in columns if c.lower() in drop_first and c.lower() not in essential]
|
|
88
|
+
regular_cols = [c for c in columns if c not in essential_cols and c not in droppable_cols]
|
|
89
|
+
|
|
90
|
+
# Calculate how many we can keep beyond essential
|
|
91
|
+
# Handle edge case where essential columns alone exceed max_cols
|
|
92
|
+
available = max(0, max_cols - len(essential_cols))
|
|
93
|
+
keep_regular = regular_cols[:available]
|
|
94
|
+
remaining = available - len(keep_regular)
|
|
95
|
+
keep_droppable = droppable_cols[:remaining] if remaining > 0 else []
|
|
96
|
+
|
|
97
|
+
# Build kept set for O(1) lookup
|
|
98
|
+
kept = set(essential_cols + keep_regular + keep_droppable)
|
|
99
|
+
|
|
100
|
+
# Return in ORIGINAL ORDER (critical!)
|
|
101
|
+
selected = [c for c in columns if c in kept]
|
|
102
|
+
omitted = len(columns) - len(selected)
|
|
103
|
+
return selected, omitted
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def format_duration(seconds: float) -> str:
|
|
107
|
+
"""Format duration as '2:15' or '0:45' or '3:02:05'."""
|
|
108
|
+
mins, secs = divmod(int(seconds), 60)
|
|
109
|
+
hours, mins = divmod(mins, 60)
|
|
110
|
+
if hours:
|
|
111
|
+
return f"{hours}:{mins:02d}:{secs:02d}"
|
|
112
|
+
return f"{mins}:{secs:02d}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class RenderSettings:
|
|
117
|
+
output: str # "table" | "json"
|
|
118
|
+
quiet: bool
|
|
119
|
+
verbosity: int
|
|
120
|
+
pager: bool | None # None=auto
|
|
121
|
+
all_columns: bool = False # If True, show all columns regardless of terminal width
|
|
122
|
+
max_columns: int | None = None # Override auto-calculated max columns
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _error_title(error_type: str) -> str:
|
|
126
|
+
normalized = (error_type or "").strip()
|
|
127
|
+
mapping = {
|
|
128
|
+
"usage_error": "Usage error",
|
|
129
|
+
"ambiguous_resolution": "Ambiguous",
|
|
130
|
+
"not_found": "Not found",
|
|
131
|
+
"validation_error": "Validation error",
|
|
132
|
+
"file_exists": "File exists",
|
|
133
|
+
"permission_denied": "Permission denied",
|
|
134
|
+
"disk_full": "Disk full",
|
|
135
|
+
"io_error": "I/O error",
|
|
136
|
+
"config_error": "Configuration error",
|
|
137
|
+
"auth_error": "Authentication error",
|
|
138
|
+
"forbidden": "Permission denied",
|
|
139
|
+
"rate_limited": "Rate limited",
|
|
140
|
+
"server_error": "Server error",
|
|
141
|
+
"network_error": "Network error",
|
|
142
|
+
"timeout": "Timeout",
|
|
143
|
+
"write_not_allowed": "Write blocked",
|
|
144
|
+
"api_error": "API error",
|
|
145
|
+
"internal_error": "Internal error",
|
|
146
|
+
"AuthenticationError": "Authentication error",
|
|
147
|
+
"AuthorizationError": "Permission denied",
|
|
148
|
+
"NotFoundError": "Not found",
|
|
149
|
+
"RateLimitError": "Rate limited",
|
|
150
|
+
"ServerError": "Server error",
|
|
151
|
+
}
|
|
152
|
+
return mapping.get(normalized, "Error")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _render_error_details(
|
|
156
|
+
*,
|
|
157
|
+
stderr: Console,
|
|
158
|
+
command: str,
|
|
159
|
+
error_type: str,
|
|
160
|
+
message: str,
|
|
161
|
+
hint: str | None,
|
|
162
|
+
docs_url: str | None,
|
|
163
|
+
details: dict[str, Any] | None,
|
|
164
|
+
settings: RenderSettings,
|
|
165
|
+
) -> None:
|
|
166
|
+
if settings.quiet:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
if hint:
|
|
170
|
+
stderr.print(f"Hint: {hint}")
|
|
171
|
+
if docs_url:
|
|
172
|
+
stderr.print(f"Docs: {docs_url}")
|
|
173
|
+
|
|
174
|
+
if not details:
|
|
175
|
+
if error_type == "ambiguous_resolution":
|
|
176
|
+
if not hint:
|
|
177
|
+
stderr.print(f"Hint: run `affinity {command} --help`")
|
|
178
|
+
elif error_type == "usage_error":
|
|
179
|
+
# Avoid noisy hints for credential/config errors where --help doesn't help.
|
|
180
|
+
lowered = message.lower()
|
|
181
|
+
if "api key" not in lowered and "python-dotenv" not in lowered and not hint:
|
|
182
|
+
stderr.print(f"Hint: run `affinity {command} --help`")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
if error_type == "ambiguous_resolution":
|
|
186
|
+
matches = details.get("matches")
|
|
187
|
+
if isinstance(matches, list) and matches and all(isinstance(m, dict) for m in matches):
|
|
188
|
+
matches = cast(list[dict[str, Any]], matches)
|
|
189
|
+
preferred = ["listId", "savedViewId", "fieldId", "id", "name", "type", "isDefault"]
|
|
190
|
+
columns: list[str] = []
|
|
191
|
+
for key in preferred:
|
|
192
|
+
if any(key in m for m in matches):
|
|
193
|
+
columns.append(key)
|
|
194
|
+
if not columns:
|
|
195
|
+
columns = list(matches[0].keys())[:4]
|
|
196
|
+
|
|
197
|
+
table = Table(show_header=True, header_style="bold")
|
|
198
|
+
for col in columns:
|
|
199
|
+
table.add_column(col)
|
|
200
|
+
for m in matches[:20]:
|
|
201
|
+
table.add_row(*[str(m.get(col, "")) for col in columns])
|
|
202
|
+
stderr.print(table)
|
|
203
|
+
elif "fieldIds" in details and isinstance(details.get("fieldIds"), list):
|
|
204
|
+
field_ids = details.get("fieldIds")
|
|
205
|
+
stderr.print("Matches: " + ", ".join(str(x) for x in cast(list[Any], field_ids)[:20]))
|
|
206
|
+
|
|
207
|
+
if not hint:
|
|
208
|
+
stderr.print(f"Hint: run `affinity {command} --help`")
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
if error_type == "usage_error":
|
|
212
|
+
if not hint:
|
|
213
|
+
stderr.print(f"Hint: run `affinity {command} --help`")
|
|
214
|
+
if settings.verbosity >= 1:
|
|
215
|
+
stderr.print(Panel.fit(Text(json.dumps(details, ensure_ascii=False, indent=2))))
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
if settings.verbosity >= 2:
|
|
219
|
+
stderr.print(Panel.fit(Text(json.dumps(details, ensure_ascii=False, indent=2))))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _rate_limit_footer(snapshot: RateLimitSnapshot) -> str:
|
|
223
|
+
def _fmt_int(value: int | None) -> str | None:
|
|
224
|
+
if value is None:
|
|
225
|
+
return None
|
|
226
|
+
return f"{value:,}"
|
|
227
|
+
|
|
228
|
+
def _fmt_reset_seconds(seconds: int | None) -> str | None:
|
|
229
|
+
if seconds is None:
|
|
230
|
+
return None
|
|
231
|
+
total_seconds = max(0, int(seconds))
|
|
232
|
+
days = total_seconds // 86400
|
|
233
|
+
if days:
|
|
234
|
+
return f"{days:,}d"
|
|
235
|
+
hours = (total_seconds % 86400) // 3600
|
|
236
|
+
minutes = (total_seconds % 3600) // 60
|
|
237
|
+
secs = total_seconds % 60
|
|
238
|
+
return f"{hours}:{minutes:02}:{secs:02}"
|
|
239
|
+
|
|
240
|
+
parts: list[str] = []
|
|
241
|
+
user = snapshot.api_key_per_minute
|
|
242
|
+
if user.limit is not None or user.remaining is not None:
|
|
243
|
+
user_bits: list[str] = []
|
|
244
|
+
user_remaining = _fmt_int(user.remaining)
|
|
245
|
+
user_limit = _fmt_int(user.limit)
|
|
246
|
+
if user_remaining is not None and user_limit is not None:
|
|
247
|
+
user_bits.append(f"user {user_remaining}/{user_limit}")
|
|
248
|
+
elif user_remaining is not None:
|
|
249
|
+
user_bits.append(f"user remaining {user_remaining}")
|
|
250
|
+
user_reset = _fmt_reset_seconds(user.reset_seconds)
|
|
251
|
+
if user_reset is not None:
|
|
252
|
+
user_bits.append(f"reset {user_reset}")
|
|
253
|
+
parts.append(" ".join(user_bits))
|
|
254
|
+
|
|
255
|
+
org = snapshot.org_monthly
|
|
256
|
+
if org.limit is not None or org.remaining is not None:
|
|
257
|
+
org_bits: list[str] = []
|
|
258
|
+
org_remaining = _fmt_int(org.remaining)
|
|
259
|
+
org_limit = _fmt_int(org.limit)
|
|
260
|
+
if org_remaining is not None and org_limit is not None:
|
|
261
|
+
org_bits.append(f"org {org_remaining}/{org_limit}")
|
|
262
|
+
elif org_remaining is not None:
|
|
263
|
+
org_bits.append(f"org remaining {org_remaining}")
|
|
264
|
+
org_reset = _fmt_reset_seconds(org.reset_seconds)
|
|
265
|
+
if org_reset is not None:
|
|
266
|
+
org_bits.append(f"reset {org_reset}")
|
|
267
|
+
parts.append(" ".join(org_bits))
|
|
268
|
+
|
|
269
|
+
if not parts:
|
|
270
|
+
return ""
|
|
271
|
+
return "# Rate limit: " + " | ".join(parts)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _build_row_segment(summary: ResultSummary, verbosity: int) -> str | None:
|
|
275
|
+
"""Build the row count segment, optionally with type breakdown.
|
|
276
|
+
|
|
277
|
+
Returns strings like:
|
|
278
|
+
"150 rows"
|
|
279
|
+
"150 rows: 30 call, 120 email"
|
|
280
|
+
"35 rows from 9,340 scanned"
|
|
281
|
+
|
|
282
|
+
Note: type_breakdown and scanned_rows are mutually exclusive in display
|
|
283
|
+
(scanned_rows implies a filter was applied, type_breakdown implies multi-type query)
|
|
284
|
+
"""
|
|
285
|
+
if summary.total_rows is None:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
row_str = f"{summary.total_rows:,} row{'s' if summary.total_rows != 1 else ''}"
|
|
289
|
+
|
|
290
|
+
# Type breakdown OR scanned context (not both - would read awkwardly)
|
|
291
|
+
if summary.type_breakdown:
|
|
292
|
+
show_breakdown = len(summary.type_breakdown) > 1 or verbosity >= 1
|
|
293
|
+
if show_breakdown:
|
|
294
|
+
# Sort keys for consistent output
|
|
295
|
+
type_parts = [
|
|
296
|
+
f"{count:,} {type_}" for type_, count in sorted(summary.type_breakdown.items())
|
|
297
|
+
]
|
|
298
|
+
row_str = f"{row_str}: {', '.join(type_parts)}"
|
|
299
|
+
elif summary.scanned_rows and summary.scanned_rows > summary.total_rows:
|
|
300
|
+
row_str = f"{row_str} from {summary.scanned_rows:,} scanned"
|
|
301
|
+
|
|
302
|
+
return row_str
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _render_summary_footer(summary: ResultSummary | None, verbosity: int = 0) -> Text | None:
|
|
306
|
+
"""Render ResultSummary as compact footer text.
|
|
307
|
+
|
|
308
|
+
Examples:
|
|
309
|
+
(0 rows)
|
|
310
|
+
(150 rows)
|
|
311
|
+
(150 rows: 30 call, 120 email)
|
|
312
|
+
(150 rows: 30 call, 120 email | 2023-07-26 → 2026-01-11)
|
|
313
|
+
(35 rows from 9,340 scanned)
|
|
314
|
+
(50 rows | included: 5 companies, 10 opportunities)
|
|
315
|
+
"""
|
|
316
|
+
if not summary:
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
segments: list[str] = [] # Joined with " | "
|
|
320
|
+
|
|
321
|
+
# Build row count segment (may include type breakdown)
|
|
322
|
+
row_segment = _build_row_segment(summary, verbosity)
|
|
323
|
+
if row_segment:
|
|
324
|
+
segments.append(row_segment)
|
|
325
|
+
|
|
326
|
+
# Date range segment
|
|
327
|
+
if summary.date_range:
|
|
328
|
+
segments.append(summary.date_range.format_display())
|
|
329
|
+
|
|
330
|
+
# Included counts segment (for query command)
|
|
331
|
+
if summary.included_counts:
|
|
332
|
+
# Sort keys for consistent output
|
|
333
|
+
inc_parts = [
|
|
334
|
+
f"{count:,} {entity}" for entity, count in sorted(summary.included_counts.items())
|
|
335
|
+
]
|
|
336
|
+
segments.append(f"included: {', '.join(inc_parts)}")
|
|
337
|
+
|
|
338
|
+
# Chunks processed (verbose only)
|
|
339
|
+
if verbosity >= 1 and summary.chunks_processed:
|
|
340
|
+
segments.append(f"{summary.chunks_processed} chunks")
|
|
341
|
+
|
|
342
|
+
# Custom text (escape hatch for one-off messages)
|
|
343
|
+
if summary.custom_text:
|
|
344
|
+
segments.append(summary.custom_text)
|
|
345
|
+
|
|
346
|
+
if not segments:
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
text = " | ".join(segments)
|
|
350
|
+
return Text(f"({text})", style="dim")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _table_from_rows(
|
|
354
|
+
rows: list[dict[str, Any]],
|
|
355
|
+
*,
|
|
356
|
+
max_columns: int | None = None,
|
|
357
|
+
all_columns: bool = False,
|
|
358
|
+
) -> tuple[Table, int]:
|
|
359
|
+
"""Build a Rich table from rows.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
rows: List of row dicts to render
|
|
363
|
+
max_columns: Maximum columns to show (None = auto from terminal width)
|
|
364
|
+
all_columns: If True, show all columns regardless of limit
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
(table, omitted_count) - the table and number of columns omitted
|
|
368
|
+
"""
|
|
369
|
+
table = Table(show_header=True, header_style="bold", expand=True)
|
|
370
|
+
if not rows:
|
|
371
|
+
table.add_column("result")
|
|
372
|
+
table.add_row("No results")
|
|
373
|
+
return table, 0
|
|
374
|
+
|
|
375
|
+
raw_columns = list(rows[0].keys())
|
|
376
|
+
# Partition into regular and long columns, preserving relative order
|
|
377
|
+
# Long columns (defined in module-level _LONG_COLUMNS) go to end
|
|
378
|
+
regular_cols = [c for c in raw_columns if c.lower() not in _LONG_COLUMNS]
|
|
379
|
+
long_cols = [c for c in raw_columns if c.lower() in _LONG_COLUMNS]
|
|
380
|
+
reordered_columns = regular_cols + long_cols
|
|
381
|
+
|
|
382
|
+
# Apply column limiting unless all_columns is True
|
|
383
|
+
if all_columns:
|
|
384
|
+
columns = reordered_columns
|
|
385
|
+
omitted = 0
|
|
386
|
+
else:
|
|
387
|
+
columns, omitted = limit_columns(reordered_columns, max_cols=max_columns)
|
|
388
|
+
|
|
389
|
+
def maybe_urlify_domain(value: str) -> str:
|
|
390
|
+
value = value.strip()
|
|
391
|
+
if not value:
|
|
392
|
+
return ""
|
|
393
|
+
if "://" in value:
|
|
394
|
+
return value
|
|
395
|
+
return f"https://{value}"
|
|
396
|
+
|
|
397
|
+
def localize_datetime(value: datetime) -> tuple[datetime, int | None]:
|
|
398
|
+
# dt is guaranteed UTC-aware from ISODatetime validator
|
|
399
|
+
local = value.astimezone()
|
|
400
|
+
offset = local.utcoffset()
|
|
401
|
+
if offset is None:
|
|
402
|
+
return local, None
|
|
403
|
+
return local, int(offset.total_seconds() // 60)
|
|
404
|
+
|
|
405
|
+
def format_utc_offset(minutes: int) -> str:
|
|
406
|
+
sign = "+" if minutes >= 0 else "-"
|
|
407
|
+
minutes_abs = abs(minutes)
|
|
408
|
+
hours = minutes_abs // 60
|
|
409
|
+
mins = minutes_abs % 60
|
|
410
|
+
if mins == 0:
|
|
411
|
+
return f"UTC{sign}{hours}"
|
|
412
|
+
return f"UTC{sign}{hours}:{mins:02d}"
|
|
413
|
+
|
|
414
|
+
def format_local_datetime(value: datetime, *, show_seconds: bool) -> tuple[str, int | None]:
|
|
415
|
+
local, offset_minutes = localize_datetime(value)
|
|
416
|
+
base = (
|
|
417
|
+
local.strftime("%Y-%m-%d %H:%M:%S")
|
|
418
|
+
if show_seconds
|
|
419
|
+
else local.strftime("%Y-%m-%d %H:%M")
|
|
420
|
+
)
|
|
421
|
+
return base, offset_minutes
|
|
422
|
+
|
|
423
|
+
datetime_columns: set[str] = set()
|
|
424
|
+
datetime_offsets: dict[str, set[int]] = {}
|
|
425
|
+
datetime_show_seconds: dict[str, bool] = {}
|
|
426
|
+
for col in columns:
|
|
427
|
+
offsets: set[int] = set()
|
|
428
|
+
any_dt = False
|
|
429
|
+
show_seconds = False
|
|
430
|
+
for row in rows:
|
|
431
|
+
value = row.get(col)
|
|
432
|
+
if isinstance(value, datetime):
|
|
433
|
+
any_dt = True
|
|
434
|
+
if value.second or value.microsecond:
|
|
435
|
+
show_seconds = True
|
|
436
|
+
_local, offset_minutes = localize_datetime(value)
|
|
437
|
+
if offset_minutes is not None:
|
|
438
|
+
offsets.add(offset_minutes)
|
|
439
|
+
if any_dt:
|
|
440
|
+
datetime_columns.add(col)
|
|
441
|
+
datetime_offsets[col] = offsets
|
|
442
|
+
datetime_show_seconds[col] = show_seconds
|
|
443
|
+
|
|
444
|
+
for col in columns:
|
|
445
|
+
is_long = col.lower() in _LONG_COLUMNS
|
|
446
|
+
# Give long columns more space (ratio=3 means 3x the space of ratio=1)
|
|
447
|
+
col_ratio = 3 if is_long else 1
|
|
448
|
+
if col in datetime_columns:
|
|
449
|
+
offsets = datetime_offsets.get(col, set())
|
|
450
|
+
if len(offsets) == 1:
|
|
451
|
+
offset_str = format_utc_offset(next(iter(offsets)))
|
|
452
|
+
table.add_column(f"{col} (local, {offset_str})", ratio=col_ratio)
|
|
453
|
+
else:
|
|
454
|
+
table.add_column(f"{col} (local)", ratio=col_ratio)
|
|
455
|
+
else:
|
|
456
|
+
table.add_column(col, ratio=col_ratio)
|
|
457
|
+
|
|
458
|
+
def format_cell(*, row: dict[str, Any], column: str, value: Any) -> str:
|
|
459
|
+
if value is None:
|
|
460
|
+
return ""
|
|
461
|
+
column_lower = column.lower()
|
|
462
|
+
|
|
463
|
+
def is_id_column(name: str) -> bool:
|
|
464
|
+
lowered = name.lower()
|
|
465
|
+
return lowered == "id" or lowered.endswith("id")
|
|
466
|
+
|
|
467
|
+
def format_number(value: int | float, *, allow_commas: bool) -> str:
|
|
468
|
+
if isinstance(value, bool):
|
|
469
|
+
return str(value)
|
|
470
|
+
if isinstance(value, int):
|
|
471
|
+
return f"{value:,}" if allow_commas else str(value)
|
|
472
|
+
if value.is_integer():
|
|
473
|
+
as_int = int(value)
|
|
474
|
+
return f"{as_int:,}" if allow_commas else str(as_int)
|
|
475
|
+
# Keep fractional values readable (e.g. percents) while still comma-grouping.
|
|
476
|
+
return f"{value:,.10f}".rstrip("0").rstrip(".")
|
|
477
|
+
|
|
478
|
+
def infer_currency_code(row: dict[str, Any]) -> str | None:
|
|
479
|
+
for key in ("currency", "currencyCode", "currency_code"):
|
|
480
|
+
v = row.get(key)
|
|
481
|
+
if isinstance(v, str) and v.strip():
|
|
482
|
+
return v.strip().upper()
|
|
483
|
+
name = row.get("name")
|
|
484
|
+
if isinstance(name, str) and name.strip():
|
|
485
|
+
upper = name.upper()
|
|
486
|
+
for code in ("USD", "EUR", "GBP", "CAD", "AUD", "ILS"):
|
|
487
|
+
if f"({code})" in upper or upper.endswith(f" {code}"):
|
|
488
|
+
return code
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
def should_render_money(row: dict[str, Any]) -> bool:
|
|
492
|
+
name = row.get("name")
|
|
493
|
+
if isinstance(name, str):
|
|
494
|
+
lowered = name.lower()
|
|
495
|
+
keys = (" amount", "amount ", "funding", "revenue", "price")
|
|
496
|
+
return any(k in lowered for k in keys)
|
|
497
|
+
return "amount" in column_lower
|
|
498
|
+
|
|
499
|
+
def should_render_year(row: dict[str, Any]) -> bool:
|
|
500
|
+
name = row.get("name")
|
|
501
|
+
if isinstance(name, str) and name.strip():
|
|
502
|
+
lowered = name.lower()
|
|
503
|
+
return "year" in lowered or "founded" in lowered
|
|
504
|
+
return "year" in column_lower
|
|
505
|
+
|
|
506
|
+
def maybe_format_year(value: int | float, *, row: dict[str, Any]) -> str | None:
|
|
507
|
+
if not should_render_year(row):
|
|
508
|
+
return None
|
|
509
|
+
if isinstance(value, int):
|
|
510
|
+
return str(value) if 1000 <= value <= 2999 else None
|
|
511
|
+
if value.is_integer():
|
|
512
|
+
as_int = int(value)
|
|
513
|
+
return str(as_int) if 1000 <= as_int <= 2999 else None
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
def format_money(value: int | float, *, code: str) -> str:
|
|
517
|
+
amount = format_number(value, allow_commas=True)
|
|
518
|
+
symbols = {"USD": "$", "EUR": "€", "GBP": "£", "ILS": "₪"}
|
|
519
|
+
symbol = symbols.get(code)
|
|
520
|
+
if symbol:
|
|
521
|
+
return f"{symbol}{amount}"
|
|
522
|
+
return f"{code} {amount}"
|
|
523
|
+
|
|
524
|
+
def truncate(text: str, *, max_len: int = 240) -> str:
|
|
525
|
+
text = " ".join(text.split())
|
|
526
|
+
if len(text) <= max_len:
|
|
527
|
+
return text
|
|
528
|
+
return text[: max_len - 1].rstrip() + "…"
|
|
529
|
+
|
|
530
|
+
def format_iso_datetime(value: Any) -> str | None:
|
|
531
|
+
if not isinstance(value, str):
|
|
532
|
+
return None
|
|
533
|
+
text = value.strip()
|
|
534
|
+
if not text:
|
|
535
|
+
return None
|
|
536
|
+
# Best-effort: keep this purely presentational.
|
|
537
|
+
if "T" in text:
|
|
538
|
+
text = text.replace("T", " ").replace("Z", "")
|
|
539
|
+
# Trim fractional seconds if present.
|
|
540
|
+
if "." in text:
|
|
541
|
+
head, _dot, tail = text.partition(".")
|
|
542
|
+
if any(c.isdigit() for c in tail):
|
|
543
|
+
text = head
|
|
544
|
+
return truncate(text, max_len=32)
|
|
545
|
+
|
|
546
|
+
def format_location(data: Any) -> str | None:
|
|
547
|
+
if not isinstance(data, dict):
|
|
548
|
+
return None
|
|
549
|
+
parts: list[str] = []
|
|
550
|
+
for key in ("streetAddress", "city", "state", "country", "continent"):
|
|
551
|
+
v = data.get(key)
|
|
552
|
+
if isinstance(v, str) and v.strip():
|
|
553
|
+
parts.append(v.strip())
|
|
554
|
+
return ", ".join(parts) if parts else None
|
|
555
|
+
|
|
556
|
+
def format_typed_value(obj: dict[str, Any], *, row: dict[str, Any]) -> str | None:
|
|
557
|
+
if set(obj.keys()) != {"type", "data"} and not (
|
|
558
|
+
"type" in obj and "data" in obj and len(obj) <= 4
|
|
559
|
+
):
|
|
560
|
+
return None
|
|
561
|
+
data = obj.get("data")
|
|
562
|
+
t = obj.get("type")
|
|
563
|
+
|
|
564
|
+
if data is None:
|
|
565
|
+
return ""
|
|
566
|
+
if isinstance(data, float) and data.is_integer():
|
|
567
|
+
data = int(data)
|
|
568
|
+
if isinstance(data, int):
|
|
569
|
+
year = maybe_format_year(data, row=row)
|
|
570
|
+
if year is not None:
|
|
571
|
+
return year
|
|
572
|
+
code = infer_currency_code(row) if column_lower == "value" else None
|
|
573
|
+
if code is not None and should_render_money(row):
|
|
574
|
+
return format_money(data, code=code)
|
|
575
|
+
return format_number(data, allow_commas=not is_id_column(column))
|
|
576
|
+
if isinstance(data, float):
|
|
577
|
+
year = maybe_format_year(data, row=row)
|
|
578
|
+
if year is not None:
|
|
579
|
+
return year
|
|
580
|
+
code = infer_currency_code(row) if column_lower == "value" else None
|
|
581
|
+
if code is not None and should_render_money(row):
|
|
582
|
+
return format_money(data, code=code)
|
|
583
|
+
return format_number(data, allow_commas=not is_id_column(column))
|
|
584
|
+
if isinstance(data, str):
|
|
585
|
+
return truncate(data)
|
|
586
|
+
if isinstance(data, list) and all(isinstance(x, dict) for x in data):
|
|
587
|
+
texts: list[str] = []
|
|
588
|
+
for item in cast(list[dict[str, Any]], data):
|
|
589
|
+
text = item.get("text")
|
|
590
|
+
if isinstance(text, str) and text.strip():
|
|
591
|
+
texts.append(text.strip())
|
|
592
|
+
continue
|
|
593
|
+
name = item.get("name")
|
|
594
|
+
if isinstance(name, str) and name.strip():
|
|
595
|
+
texts.append(name.strip())
|
|
596
|
+
continue
|
|
597
|
+
first = item.get("firstName")
|
|
598
|
+
last = item.get("lastName")
|
|
599
|
+
if isinstance(first, str) or isinstance(last, str):
|
|
600
|
+
display = " ".join(
|
|
601
|
+
p.strip() for p in [first, last] if isinstance(p, str) and p.strip()
|
|
602
|
+
).strip()
|
|
603
|
+
if display:
|
|
604
|
+
texts.append(display)
|
|
605
|
+
continue
|
|
606
|
+
if texts:
|
|
607
|
+
return truncate(", ".join(texts))
|
|
608
|
+
return f"list ({len(data):,} items)"
|
|
609
|
+
if isinstance(data, list) and all(isinstance(x, str) for x in data):
|
|
610
|
+
return truncate(", ".join(x.strip() for x in data if x.strip()))
|
|
611
|
+
# Handle lists of integers (e.g., personIds, companyIds)
|
|
612
|
+
if isinstance(data, list) and all(isinstance(x, int) for x in data):
|
|
613
|
+
return truncate(", ".join(str(x) for x in data))
|
|
614
|
+
if isinstance(data, dict):
|
|
615
|
+
text = data.get("text")
|
|
616
|
+
if isinstance(text, str) and text.strip():
|
|
617
|
+
return truncate(text)
|
|
618
|
+
name = data.get("name")
|
|
619
|
+
if isinstance(name, str) and name.strip():
|
|
620
|
+
return truncate(name)
|
|
621
|
+
if isinstance(t, str) and t == "person" and isinstance(data, dict):
|
|
622
|
+
first = data.get("firstName")
|
|
623
|
+
last = data.get("lastName")
|
|
624
|
+
email = data.get("primaryEmailAddress")
|
|
625
|
+
person_id = data.get("id")
|
|
626
|
+
name = " ".join(
|
|
627
|
+
p.strip() for p in [first, last] if isinstance(p, str) and p.strip()
|
|
628
|
+
).strip()
|
|
629
|
+
bits: list[str] = []
|
|
630
|
+
if name:
|
|
631
|
+
bits.append(name)
|
|
632
|
+
if isinstance(email, str) and email.strip():
|
|
633
|
+
bits.append(f"<{email.strip()}>")
|
|
634
|
+
if person_id is not None:
|
|
635
|
+
bits.append(f"(id={person_id})")
|
|
636
|
+
if bits:
|
|
637
|
+
return truncate(" ".join(bits), max_len=120)
|
|
638
|
+
return None
|
|
639
|
+
if isinstance(t, str) and t == "interaction" and isinstance(data, dict):
|
|
640
|
+
subtype = data.get("type")
|
|
641
|
+
interaction_id = data.get("id")
|
|
642
|
+
when = format_iso_datetime(data.get("sentAt")) or format_iso_datetime(
|
|
643
|
+
data.get("startTime")
|
|
644
|
+
)
|
|
645
|
+
title = None
|
|
646
|
+
subject = data.get("subject")
|
|
647
|
+
event_title = data.get("title")
|
|
648
|
+
if isinstance(subject, str) and subject.strip():
|
|
649
|
+
title = subject
|
|
650
|
+
elif isinstance(event_title, str) and event_title.strip():
|
|
651
|
+
title = event_title
|
|
652
|
+
parts: list[str] = []
|
|
653
|
+
if isinstance(subtype, str) and subtype.strip():
|
|
654
|
+
parts.append(subtype.strip())
|
|
655
|
+
if when:
|
|
656
|
+
parts.append(when)
|
|
657
|
+
if title:
|
|
658
|
+
parts.append("— " + title)
|
|
659
|
+
if interaction_id is not None:
|
|
660
|
+
parts.append(f"(id={interaction_id})")
|
|
661
|
+
if parts:
|
|
662
|
+
return truncate(" ".join(parts), max_len=140)
|
|
663
|
+
return None
|
|
664
|
+
if isinstance(t, str) and t == "location":
|
|
665
|
+
loc = format_location(data)
|
|
666
|
+
if loc is not None:
|
|
667
|
+
return truncate(loc)
|
|
668
|
+
if isinstance(data, dict) and set(data.keys()) >= {"id", "text"}:
|
|
669
|
+
text = data.get("text")
|
|
670
|
+
ident = data.get("id")
|
|
671
|
+
if isinstance(text, str) and text.strip():
|
|
672
|
+
if ident is None:
|
|
673
|
+
return truncate(text)
|
|
674
|
+
return truncate(f"{text} (id={ident})")
|
|
675
|
+
return None
|
|
676
|
+
|
|
677
|
+
def format_dict(obj: dict[str, Any], *, row: dict[str, Any]) -> str:
|
|
678
|
+
typed = format_typed_value(obj, row=row)
|
|
679
|
+
if typed is not None:
|
|
680
|
+
return typed
|
|
681
|
+
|
|
682
|
+
# Common compact shape: {"id": ..., "text": ...}
|
|
683
|
+
if set(obj.keys()) >= {"id", "text"}:
|
|
684
|
+
text = obj.get("text")
|
|
685
|
+
ident = obj.get("id")
|
|
686
|
+
if isinstance(text, str) and text.strip():
|
|
687
|
+
return truncate(f"{text} (id={ident})" if ident is not None else text)
|
|
688
|
+
|
|
689
|
+
# Location-like shape without wrapper.
|
|
690
|
+
loc = format_location(obj)
|
|
691
|
+
if loc is not None:
|
|
692
|
+
return truncate(loc)
|
|
693
|
+
|
|
694
|
+
if all(isinstance(v, (str, int, float, bool)) or v is None for v in obj.values()):
|
|
695
|
+
parts: list[str] = []
|
|
696
|
+
for k, v in obj.items():
|
|
697
|
+
if v is None:
|
|
698
|
+
continue
|
|
699
|
+
if isinstance(v, float) and v.is_integer():
|
|
700
|
+
v = int(v)
|
|
701
|
+
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
702
|
+
parts.append(f"{k}={format_number(v, allow_commas=True)}")
|
|
703
|
+
else:
|
|
704
|
+
parts.append(f"{k}={v}")
|
|
705
|
+
if parts:
|
|
706
|
+
return truncate(", ".join(parts))
|
|
707
|
+
return f"object ({len(obj):,} keys)"
|
|
708
|
+
|
|
709
|
+
if isinstance(value, datetime):
|
|
710
|
+
show_seconds = datetime_show_seconds.get(column, False)
|
|
711
|
+
base, _offset_minutes = format_local_datetime(value, show_seconds=show_seconds)
|
|
712
|
+
return base
|
|
713
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
714
|
+
year = maybe_format_year(value, row=row)
|
|
715
|
+
if year is not None:
|
|
716
|
+
return year
|
|
717
|
+
return format_number(value, allow_commas=not is_id_column(column))
|
|
718
|
+
if isinstance(value, list):
|
|
719
|
+
if not value:
|
|
720
|
+
return ""
|
|
721
|
+
if all(isinstance(v, str) for v in value):
|
|
722
|
+
parts = [
|
|
723
|
+
maybe_urlify_domain(v) if column_lower in {"domain", "domains"} else v
|
|
724
|
+
for v in value
|
|
725
|
+
]
|
|
726
|
+
return ", ".join(parts)
|
|
727
|
+
|
|
728
|
+
if all(isinstance(v, dict) for v in value):
|
|
729
|
+
dict_items = cast(list[dict[str, Any]], value)
|
|
730
|
+
|
|
731
|
+
def summarize_field_items(items: list[dict[str, Any]]) -> str:
|
|
732
|
+
parts: list[str] = []
|
|
733
|
+
for item in items:
|
|
734
|
+
name = item.get("name") or item.get("id") or "field"
|
|
735
|
+
if not isinstance(name, str) or not name.strip():
|
|
736
|
+
name = "field"
|
|
737
|
+
raw_value = item.get("value")
|
|
738
|
+
value_text: str | None = None
|
|
739
|
+
if isinstance(raw_value, dict):
|
|
740
|
+
value_text = format_dict(raw_value, row=item)
|
|
741
|
+
elif raw_value is None:
|
|
742
|
+
value_text = None
|
|
743
|
+
elif isinstance(raw_value, (int, float)) and not isinstance(
|
|
744
|
+
raw_value, bool
|
|
745
|
+
):
|
|
746
|
+
value_text = format_number(raw_value, allow_commas=True)
|
|
747
|
+
else:
|
|
748
|
+
value_text = str(raw_value)
|
|
749
|
+
|
|
750
|
+
if value_text is None or not value_text.strip():
|
|
751
|
+
continue
|
|
752
|
+
parts.append(f"{name}={value_text}")
|
|
753
|
+
if len(parts) >= 3:
|
|
754
|
+
break
|
|
755
|
+
|
|
756
|
+
if not parts:
|
|
757
|
+
return f"fields ({len(items):,} items)"
|
|
758
|
+
|
|
759
|
+
remaining = len(items) - len(parts)
|
|
760
|
+
suffix = f" … (+{remaining} more)" if remaining > 0 else ""
|
|
761
|
+
return truncate("; ".join(parts) + suffix, max_len=180)
|
|
762
|
+
|
|
763
|
+
if column_lower == "fields":
|
|
764
|
+
return summarize_field_items(dict_items)
|
|
765
|
+
|
|
766
|
+
# Try to extract text/name values from list of dicts (e.g. dropdownOptions)
|
|
767
|
+
# Color dots for dropdown options (maps to DropdownOptionColor enum)
|
|
768
|
+
_COLOR_DOTS = {
|
|
769
|
+
0: "⚪", # DEFAULT
|
|
770
|
+
1: "🔵", # BLUE
|
|
771
|
+
2: "🟢", # GREEN
|
|
772
|
+
3: "🟡", # YELLOW
|
|
773
|
+
4: "🟠", # ORANGE
|
|
774
|
+
5: "🔴", # RED
|
|
775
|
+
6: "🟣", # PURPLE
|
|
776
|
+
7: "⚫", # GRAY
|
|
777
|
+
}
|
|
778
|
+
texts: list[str] = []
|
|
779
|
+
for item in dict_items:
|
|
780
|
+
text = item.get("text")
|
|
781
|
+
if isinstance(text, str) and text.strip():
|
|
782
|
+
color = item.get("color")
|
|
783
|
+
dot = _COLOR_DOTS.get(color, "") if isinstance(color, int) else ""
|
|
784
|
+
texts.append(f"{dot}{text.strip()}" if dot else text.strip())
|
|
785
|
+
continue
|
|
786
|
+
name = item.get("name")
|
|
787
|
+
if isinstance(name, str) and name.strip():
|
|
788
|
+
texts.append(name.strip())
|
|
789
|
+
continue
|
|
790
|
+
first = item.get("firstName")
|
|
791
|
+
last = item.get("lastName")
|
|
792
|
+
if isinstance(first, str) or isinstance(last, str):
|
|
793
|
+
display = " ".join(
|
|
794
|
+
p.strip() for p in [first, last] if isinstance(p, str) and p.strip()
|
|
795
|
+
).strip()
|
|
796
|
+
if display:
|
|
797
|
+
texts.append(display)
|
|
798
|
+
continue
|
|
799
|
+
if texts:
|
|
800
|
+
return truncate(", ".join(texts))
|
|
801
|
+
|
|
802
|
+
return f"list ({len(dict_items):,} items)"
|
|
803
|
+
|
|
804
|
+
# Handle lists of simple scalars (integers, strings)
|
|
805
|
+
if all(isinstance(x, int) for x in value):
|
|
806
|
+
return truncate(", ".join(str(x) for x in value))
|
|
807
|
+
if all(isinstance(x, str) for x in value):
|
|
808
|
+
return truncate(", ".join(x.strip() for x in value if x.strip()))
|
|
809
|
+
|
|
810
|
+
return f"list ({len(value):,} items)"
|
|
811
|
+
if isinstance(value, dict):
|
|
812
|
+
return format_dict(value, row=row)
|
|
813
|
+
if isinstance(value, str) and column_lower in {"domain", "domains"}:
|
|
814
|
+
return maybe_urlify_domain(value)
|
|
815
|
+
return str(value)
|
|
816
|
+
|
|
817
|
+
for row in rows:
|
|
818
|
+
table.add_row(*[format_cell(row=row, column=c, value=row.get(c, "")) for c in columns])
|
|
819
|
+
return table, omitted
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
_CAMEL_BREAK_RE = re.compile(r"(?<!^)(?=[A-Z])")
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _humanize_title(value: str) -> str:
|
|
826
|
+
raw = (value or "").strip()
|
|
827
|
+
if not raw:
|
|
828
|
+
return ""
|
|
829
|
+
if any(ch.isspace() for ch in raw):
|
|
830
|
+
raw = raw.replace("_", " ").replace("-", " ").strip()
|
|
831
|
+
return " ".join(raw.split())
|
|
832
|
+
raw = raw.replace("_", " ").replace("-", " ").strip()
|
|
833
|
+
raw = _CAMEL_BREAK_RE.sub(" ", raw)
|
|
834
|
+
raw = " ".join(raw.split())
|
|
835
|
+
return raw[:1].upper() + raw[1:]
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _is_collection_envelope(obj: Any) -> bool:
|
|
839
|
+
if not isinstance(obj, dict):
|
|
840
|
+
return False
|
|
841
|
+
if "data" not in obj or "pagination" not in obj:
|
|
842
|
+
return False
|
|
843
|
+
data = obj.get("data")
|
|
844
|
+
pagination = obj.get("pagination")
|
|
845
|
+
if not isinstance(data, list):
|
|
846
|
+
return False
|
|
847
|
+
if not isinstance(pagination, dict):
|
|
848
|
+
return False
|
|
849
|
+
# Collection envelopes include pagination URLs.
|
|
850
|
+
return "nextUrl" in pagination or "prevUrl" in pagination
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _is_collection_with_hint(obj: Any) -> bool:
|
|
854
|
+
if not isinstance(obj, dict):
|
|
855
|
+
return False
|
|
856
|
+
if set(obj.keys()) != {"_rows", "_hint"}:
|
|
857
|
+
return False
|
|
858
|
+
if not isinstance(obj.get("_rows"), list):
|
|
859
|
+
return False
|
|
860
|
+
if not isinstance(obj.get("_hint"), str):
|
|
861
|
+
return False
|
|
862
|
+
return bool(obj.get("_hint"))
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _is_text_marker(obj: Any) -> bool:
|
|
866
|
+
"""
|
|
867
|
+
Allow commands to embed a simple human-only text section in dict-shaped data.
|
|
868
|
+
|
|
869
|
+
This is intentionally an internal convention (not part of the JSON contract).
|
|
870
|
+
"""
|
|
871
|
+
|
|
872
|
+
return (
|
|
873
|
+
isinstance(obj, dict)
|
|
874
|
+
and set(obj.keys()) == {"_text"}
|
|
875
|
+
and isinstance(obj.get("_text"), str)
|
|
876
|
+
and bool(obj.get("_text"))
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _pagination_has_more(pagination: dict[str, Any] | None) -> bool:
|
|
881
|
+
if not pagination:
|
|
882
|
+
return False
|
|
883
|
+
for key in ("nextCursor", "nextUrl", "nextPageToken"):
|
|
884
|
+
value = pagination.get(key)
|
|
885
|
+
if isinstance(value, str) and value.strip():
|
|
886
|
+
return True
|
|
887
|
+
return False
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _format_scalar_value(*, key: str | None, value: Any) -> str:
|
|
891
|
+
if value is None:
|
|
892
|
+
return ""
|
|
893
|
+
key_lower = (key or "").lower()
|
|
894
|
+
if isinstance(value, datetime):
|
|
895
|
+
# value is guaranteed UTC-aware from ISODatetime validator
|
|
896
|
+
local = value.astimezone()
|
|
897
|
+
show_seconds = bool(local.second or local.microsecond)
|
|
898
|
+
return local.strftime("%Y-%m-%d %H:%M:%S" if show_seconds else "%Y-%m-%d %H:%M")
|
|
899
|
+
if isinstance(value, list):
|
|
900
|
+
if all(isinstance(v, str) for v in value):
|
|
901
|
+
parts = [str(v) for v in value]
|
|
902
|
+
if key_lower in {"domain", "domains"}:
|
|
903
|
+
parts = [v if "://" in v else f"https://{v.strip()}" for v in parts if v.strip()]
|
|
904
|
+
return ", ".join(parts)
|
|
905
|
+
return f"list ({len(value):,} items)"
|
|
906
|
+
if isinstance(value, dict):
|
|
907
|
+
# Special handling for date range objects (e.g., metadata.dateRange)
|
|
908
|
+
if set(value.keys()) == {"start", "end"}:
|
|
909
|
+
start_str = str(value.get("start", ""))
|
|
910
|
+
end_str = str(value.get("end", ""))
|
|
911
|
+
# Extract just the date portion if it's an ISO datetime
|
|
912
|
+
if "T" in start_str:
|
|
913
|
+
start_str = start_str.split("T")[0]
|
|
914
|
+
if "T" in end_str:
|
|
915
|
+
end_str = end_str.split("T")[0]
|
|
916
|
+
return f"{start_str} → {end_str}"
|
|
917
|
+
return f"object ({len(value):,} keys)"
|
|
918
|
+
if isinstance(value, bool):
|
|
919
|
+
return str(value)
|
|
920
|
+
if isinstance(value, int) and key_lower != "id" and not key_lower.endswith("id"):
|
|
921
|
+
return f"{value:,}"
|
|
922
|
+
if isinstance(value, float) and key_lower != "id" and not key_lower.endswith("id"):
|
|
923
|
+
if value.is_integer():
|
|
924
|
+
return f"{int(value):,}"
|
|
925
|
+
return f"{value:,.10f}".rstrip("0").rstrip(".")
|
|
926
|
+
if isinstance(value, str) and key_lower in {"domain", "domains"}:
|
|
927
|
+
text = value.strip()
|
|
928
|
+
if not text:
|
|
929
|
+
return ""
|
|
930
|
+
return text if "://" in text else f"https://{text}"
|
|
931
|
+
return str(value)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _is_simple_scalar_dict(obj: dict[str, Any]) -> bool:
|
|
935
|
+
"""Check if a dict contains only simple scalar values (no nested structures).
|
|
936
|
+
|
|
937
|
+
Date range dicts (with only 'start' and 'end' keys) are treated as simple
|
|
938
|
+
since they format to inline strings like "2024-01-01 → 2024-06-01".
|
|
939
|
+
"""
|
|
940
|
+
for v in obj.values():
|
|
941
|
+
if isinstance(v, list):
|
|
942
|
+
return False
|
|
943
|
+
# Allow date range dicts (they format inline)
|
|
944
|
+
if isinstance(v, dict) and set(v.keys()) != {"start", "end"}:
|
|
945
|
+
return False
|
|
946
|
+
return True
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _simple_kv_text(obj: dict[str, Any]) -> Text:
|
|
950
|
+
"""Render a simple scalar dict as plain text lines (not a table)."""
|
|
951
|
+
lines: list[str] = []
|
|
952
|
+
for k, v in obj.items():
|
|
953
|
+
formatted = _format_scalar_value(key=str(k), value=v)
|
|
954
|
+
lines.append(f"{_humanize_title(k)}: {formatted}")
|
|
955
|
+
return Text("\n".join(lines))
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _kv_table(obj: dict[str, Any]) -> Table:
|
|
959
|
+
table = Table(show_header=True, header_style="bold")
|
|
960
|
+
table.add_column("field")
|
|
961
|
+
table.add_column("value")
|
|
962
|
+
for k, v in obj.items():
|
|
963
|
+
table.add_row(str(k), _format_scalar_value(key=str(k), value=v))
|
|
964
|
+
return table
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _render_fields_section(
|
|
968
|
+
*,
|
|
969
|
+
title: str,
|
|
970
|
+
fields: list[dict[str, Any]],
|
|
971
|
+
field_metadata: dict[str, str] | None,
|
|
972
|
+
verbose: bool = False,
|
|
973
|
+
) -> Any:
|
|
974
|
+
"""Render field values as a human-readable table with field names.
|
|
975
|
+
|
|
976
|
+
Groups multi-value fields (same field_id with multiple values) and shows
|
|
977
|
+
field name only on the first row. Shows Field Name, Field ID, Value columns.
|
|
978
|
+
If verbose=True, also shows Value ID column.
|
|
979
|
+
|
|
980
|
+
Args:
|
|
981
|
+
title: Section title (e.g., "Fields (29)")
|
|
982
|
+
fields: List of field value dicts from the API
|
|
983
|
+
field_metadata: Mapping of field_id -> field_name (can be None)
|
|
984
|
+
verbose: If True, include Value ID column
|
|
985
|
+
"""
|
|
986
|
+
if not fields:
|
|
987
|
+
return None
|
|
988
|
+
|
|
989
|
+
# Group fields by field_id to handle multi-value fields
|
|
990
|
+
from collections import OrderedDict
|
|
991
|
+
|
|
992
|
+
grouped: OrderedDict[str, list[dict[str, Any]]] = OrderedDict()
|
|
993
|
+
for field in fields:
|
|
994
|
+
field_id = field.get("fieldId") or field.get("id") or ""
|
|
995
|
+
if isinstance(field_id, str):
|
|
996
|
+
grouped.setdefault(field_id, []).append(field)
|
|
997
|
+
|
|
998
|
+
# Build table
|
|
999
|
+
table = Table(show_header=True, header_style="bold")
|
|
1000
|
+
table.add_column("Field")
|
|
1001
|
+
table.add_column("Field ID")
|
|
1002
|
+
table.add_column("Value")
|
|
1003
|
+
if verbose:
|
|
1004
|
+
table.add_column("Value ID")
|
|
1005
|
+
|
|
1006
|
+
def format_field_value(value: Any) -> str:
|
|
1007
|
+
if value is None:
|
|
1008
|
+
return ""
|
|
1009
|
+
if isinstance(value, bool):
|
|
1010
|
+
return str(value)
|
|
1011
|
+
if isinstance(value, (int, float)):
|
|
1012
|
+
if isinstance(value, float) and value.is_integer():
|
|
1013
|
+
return f"{int(value):,}"
|
|
1014
|
+
if isinstance(value, int):
|
|
1015
|
+
return f"{value:,}"
|
|
1016
|
+
return str(value)
|
|
1017
|
+
if isinstance(value, str):
|
|
1018
|
+
# Truncate long values
|
|
1019
|
+
if len(value) > 120:
|
|
1020
|
+
return value[:117] + "..."
|
|
1021
|
+
return value
|
|
1022
|
+
if isinstance(value, dict):
|
|
1023
|
+
# Handle typed values like {type: "...", data: ...}
|
|
1024
|
+
data = value.get("data")
|
|
1025
|
+
if data is not None:
|
|
1026
|
+
return format_field_value(data)
|
|
1027
|
+
# Handle name/text objects
|
|
1028
|
+
name = value.get("name") or value.get("text")
|
|
1029
|
+
if isinstance(name, str):
|
|
1030
|
+
return name
|
|
1031
|
+
return f"object ({len(value)} keys)"
|
|
1032
|
+
if isinstance(value, list):
|
|
1033
|
+
if all(isinstance(v, str) for v in value):
|
|
1034
|
+
return ", ".join(value)
|
|
1035
|
+
if all(isinstance(v, dict) for v in value):
|
|
1036
|
+
# Extract names/texts from list of objects
|
|
1037
|
+
texts = []
|
|
1038
|
+
for item in value:
|
|
1039
|
+
text = item.get("text") or item.get("name")
|
|
1040
|
+
if isinstance(text, str):
|
|
1041
|
+
texts.append(text)
|
|
1042
|
+
if texts:
|
|
1043
|
+
return ", ".join(texts)
|
|
1044
|
+
return f"list ({len(value)} items)"
|
|
1045
|
+
return str(value)
|
|
1046
|
+
|
|
1047
|
+
field_metadata = field_metadata or {}
|
|
1048
|
+
|
|
1049
|
+
for field_id, field_values in grouped.items():
|
|
1050
|
+
field_name = field_metadata.get(field_id, "")
|
|
1051
|
+
for idx, fv in enumerate(field_values):
|
|
1052
|
+
# Show field name and ID only on first row for multi-value fields
|
|
1053
|
+
display_name = field_name if idx == 0 else ""
|
|
1054
|
+
display_id = field_id if idx == 0 else ""
|
|
1055
|
+
|
|
1056
|
+
value = fv.get("value")
|
|
1057
|
+
value_str = format_field_value(value)
|
|
1058
|
+
|
|
1059
|
+
if verbose:
|
|
1060
|
+
value_id = fv.get("id", "")
|
|
1061
|
+
value_id_str = str(value_id) if value_id else ""
|
|
1062
|
+
table.add_row(display_name, display_id, value_str, value_id_str)
|
|
1063
|
+
else:
|
|
1064
|
+
table.add_row(display_name, display_id, value_str)
|
|
1065
|
+
|
|
1066
|
+
renderables: list[Any] = []
|
|
1067
|
+
renderables.append(Text(title, style="bold"))
|
|
1068
|
+
renderables.append(table)
|
|
1069
|
+
return Group(*renderables)
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _render_collection_section(
|
|
1073
|
+
*,
|
|
1074
|
+
title: str | None,
|
|
1075
|
+
rows: list[Any],
|
|
1076
|
+
pagination: dict[str, Any] | None,
|
|
1077
|
+
all_columns: bool = False,
|
|
1078
|
+
max_columns: int | None = None,
|
|
1079
|
+
) -> Any:
|
|
1080
|
+
dict_rows = [r for r in rows if isinstance(r, dict)]
|
|
1081
|
+
renderables: list[Any] = []
|
|
1082
|
+
if title:
|
|
1083
|
+
renderables.append(Text(_humanize_title(title), style="bold"))
|
|
1084
|
+
table, omitted = _table_from_rows(
|
|
1085
|
+
cast(list[dict[str, Any]], dict_rows),
|
|
1086
|
+
all_columns=all_columns,
|
|
1087
|
+
max_columns=max_columns,
|
|
1088
|
+
)
|
|
1089
|
+
renderables.append(table)
|
|
1090
|
+
if omitted > 0:
|
|
1091
|
+
renderables.append(
|
|
1092
|
+
Text(f"({omitted} columns hidden — use --all-columns or --json)", style="dim")
|
|
1093
|
+
)
|
|
1094
|
+
if _pagination_has_more(pagination):
|
|
1095
|
+
renderables.append(
|
|
1096
|
+
Text(f"({len(dict_rows):,} shown, more available — use --max-results/--all or --json)")
|
|
1097
|
+
)
|
|
1098
|
+
return Group(*renderables) if len(renderables) > 1 else renderables[0]
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def _render_collection_with_hint(
|
|
1102
|
+
*,
|
|
1103
|
+
title: str | None,
|
|
1104
|
+
rows: list[Any],
|
|
1105
|
+
hint: str,
|
|
1106
|
+
all_columns: bool = False,
|
|
1107
|
+
max_columns: int | None = None,
|
|
1108
|
+
) -> Any:
|
|
1109
|
+
dict_rows = [r for r in rows if isinstance(r, dict)]
|
|
1110
|
+
renderables: list[Any] = []
|
|
1111
|
+
if title:
|
|
1112
|
+
renderables.append(Text(_humanize_title(title), style="bold"))
|
|
1113
|
+
table, omitted = _table_from_rows(
|
|
1114
|
+
cast(list[dict[str, Any]], dict_rows),
|
|
1115
|
+
all_columns=all_columns,
|
|
1116
|
+
max_columns=max_columns,
|
|
1117
|
+
)
|
|
1118
|
+
renderables.append(table)
|
|
1119
|
+
if omitted > 0:
|
|
1120
|
+
renderables.append(
|
|
1121
|
+
Text(f"({omitted} columns hidden — use --all-columns or --json)", style="dim")
|
|
1122
|
+
)
|
|
1123
|
+
renderables.append(Text(hint))
|
|
1124
|
+
return Group(*renderables) if len(renderables) > 1 else renderables[0]
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _render_object_section(
|
|
1128
|
+
*,
|
|
1129
|
+
title: str | None,
|
|
1130
|
+
obj: dict[str, Any],
|
|
1131
|
+
verbosity: int,
|
|
1132
|
+
pagination: dict[str, Any] | None,
|
|
1133
|
+
force_nested_keys: set[str] | None = None,
|
|
1134
|
+
all_columns: bool = False,
|
|
1135
|
+
max_columns: int | None = None,
|
|
1136
|
+
) -> Any:
|
|
1137
|
+
scalar_summary: dict[str, Any] = {}
|
|
1138
|
+
nested: list[tuple[str, Any]] = []
|
|
1139
|
+
for k, v in obj.items():
|
|
1140
|
+
# Render nested collections/objects only at higher verbosity to avoid noise.
|
|
1141
|
+
if isinstance(v, (list, dict)):
|
|
1142
|
+
scalar_summary[str(k)] = v
|
|
1143
|
+
nested.append((str(k), v))
|
|
1144
|
+
else:
|
|
1145
|
+
scalar_summary[str(k)] = v
|
|
1146
|
+
|
|
1147
|
+
renderables: list[Any] = []
|
|
1148
|
+
if title:
|
|
1149
|
+
renderables.append(Text(_humanize_title(title), style="bold"))
|
|
1150
|
+
|
|
1151
|
+
# Use simple text output for dicts with only scalar values (no nested structures)
|
|
1152
|
+
# This is cleaner for commands like `files dump` that output simple summaries
|
|
1153
|
+
if _is_simple_scalar_dict(obj):
|
|
1154
|
+
renderables.append(_simple_kv_text(scalar_summary))
|
|
1155
|
+
return Group(*renderables) if len(renderables) > 1 else renderables[0]
|
|
1156
|
+
|
|
1157
|
+
renderables.append(_kv_table(scalar_summary))
|
|
1158
|
+
kv_index = len(renderables) - 1
|
|
1159
|
+
|
|
1160
|
+
show_nested_keys = set(force_nested_keys or set())
|
|
1161
|
+
if verbosity >= 1:
|
|
1162
|
+
show_nested_keys.update(k for k, _v in nested)
|
|
1163
|
+
|
|
1164
|
+
if show_nested_keys:
|
|
1165
|
+
# Avoid duplicating "fields: list (N items)" style rows when we render a section.
|
|
1166
|
+
for key in show_nested_keys:
|
|
1167
|
+
if key in scalar_summary:
|
|
1168
|
+
scalar_summary[key] = "see below"
|
|
1169
|
+
renderables[kv_index] = _kv_table(scalar_summary)
|
|
1170
|
+
|
|
1171
|
+
for k, v in nested:
|
|
1172
|
+
if k not in show_nested_keys:
|
|
1173
|
+
continue
|
|
1174
|
+
if isinstance(v, dict) and _is_collection_envelope(v):
|
|
1175
|
+
envelope = cast(dict[str, Any], v)
|
|
1176
|
+
renderables.append(
|
|
1177
|
+
_render_collection_section(
|
|
1178
|
+
title=k,
|
|
1179
|
+
rows=cast(list[Any], envelope.get("data", [])),
|
|
1180
|
+
pagination=cast(dict[str, Any] | None, envelope.get("pagination")),
|
|
1181
|
+
all_columns=all_columns,
|
|
1182
|
+
max_columns=max_columns,
|
|
1183
|
+
)
|
|
1184
|
+
)
|
|
1185
|
+
elif isinstance(v, dict) and _is_collection_with_hint(v):
|
|
1186
|
+
renderables.append(
|
|
1187
|
+
_render_collection_with_hint(
|
|
1188
|
+
title=k,
|
|
1189
|
+
rows=cast(list[Any], v.get("_rows", [])),
|
|
1190
|
+
hint=cast(str, v.get("_hint")),
|
|
1191
|
+
all_columns=all_columns,
|
|
1192
|
+
max_columns=max_columns,
|
|
1193
|
+
)
|
|
1194
|
+
)
|
|
1195
|
+
elif isinstance(v, list) and all(isinstance(x, dict) for x in v):
|
|
1196
|
+
renderables.append(
|
|
1197
|
+
_render_collection_section(
|
|
1198
|
+
title=k,
|
|
1199
|
+
rows=v,
|
|
1200
|
+
pagination=None,
|
|
1201
|
+
all_columns=all_columns,
|
|
1202
|
+
max_columns=max_columns,
|
|
1203
|
+
)
|
|
1204
|
+
)
|
|
1205
|
+
elif isinstance(v, dict):
|
|
1206
|
+
renderables.append(
|
|
1207
|
+
_render_object_section(
|
|
1208
|
+
title=k,
|
|
1209
|
+
obj=v,
|
|
1210
|
+
verbosity=verbosity,
|
|
1211
|
+
pagination=None,
|
|
1212
|
+
force_nested_keys=None,
|
|
1213
|
+
all_columns=all_columns,
|
|
1214
|
+
max_columns=max_columns,
|
|
1215
|
+
)
|
|
1216
|
+
)
|
|
1217
|
+
else:
|
|
1218
|
+
# Non-tabular nested values (e.g., list[str]) are already in the kv table.
|
|
1219
|
+
continue
|
|
1220
|
+
|
|
1221
|
+
if _pagination_has_more(pagination):
|
|
1222
|
+
# Object sections can also be paginated (rare); keep message consistent.
|
|
1223
|
+
renderables.append(Text("(more available — use --max-results/--all or --json)"))
|
|
1224
|
+
|
|
1225
|
+
return Group(*renderables) if len(renderables) > 1 else renderables[0]
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _extract_section_pagination(
|
|
1229
|
+
*,
|
|
1230
|
+
meta_pagination: dict[str, Any] | None,
|
|
1231
|
+
section: str,
|
|
1232
|
+
) -> dict[str, Any] | None:
|
|
1233
|
+
if not meta_pagination:
|
|
1234
|
+
return None
|
|
1235
|
+
# Preferred contract: always keyed by section name.
|
|
1236
|
+
maybe = meta_pagination.get(section)
|
|
1237
|
+
if isinstance(maybe, dict):
|
|
1238
|
+
return cast(dict[str, Any], maybe)
|
|
1239
|
+
# Legacy: unkeyed single-section pagination dict.
|
|
1240
|
+
if any(
|
|
1241
|
+
k in meta_pagination
|
|
1242
|
+
for k in (
|
|
1243
|
+
"nextCursor",
|
|
1244
|
+
"prevCursor",
|
|
1245
|
+
"nextPageToken",
|
|
1246
|
+
"nextUrl",
|
|
1247
|
+
"prevUrl",
|
|
1248
|
+
"prevPageToken",
|
|
1249
|
+
)
|
|
1250
|
+
):
|
|
1251
|
+
return meta_pagination
|
|
1252
|
+
return None
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def _render_human_data(
|
|
1256
|
+
*,
|
|
1257
|
+
data: Any,
|
|
1258
|
+
meta_pagination: dict[str, Any] | None,
|
|
1259
|
+
meta_resolved: dict[str, Any] | None,
|
|
1260
|
+
verbosity: int,
|
|
1261
|
+
all_columns: bool = False,
|
|
1262
|
+
max_columns: int | None = None,
|
|
1263
|
+
) -> Any:
|
|
1264
|
+
if isinstance(data, list) and all(isinstance(x, dict) for x in data):
|
|
1265
|
+
table, omitted = _table_from_rows(
|
|
1266
|
+
cast(list[dict[str, Any]], data),
|
|
1267
|
+
all_columns=all_columns,
|
|
1268
|
+
max_columns=max_columns,
|
|
1269
|
+
)
|
|
1270
|
+
pagination = (
|
|
1271
|
+
meta_pagination if meta_pagination and _pagination_has_more(meta_pagination) else None
|
|
1272
|
+
)
|
|
1273
|
+
renderables: list[Any] = [table]
|
|
1274
|
+
if omitted > 0:
|
|
1275
|
+
renderables.append(
|
|
1276
|
+
Text(f"({omitted} columns hidden — use --all-columns or --json)", style="dim")
|
|
1277
|
+
)
|
|
1278
|
+
if pagination:
|
|
1279
|
+
renderables.append(
|
|
1280
|
+
Text(f"({len(data):,} shown, more available — use --max-results/--all or --json)")
|
|
1281
|
+
)
|
|
1282
|
+
return Group(*renderables) if len(renderables) > 1 else table
|
|
1283
|
+
|
|
1284
|
+
if isinstance(data, dict):
|
|
1285
|
+
if _is_collection_envelope(data):
|
|
1286
|
+
envelope = cast(dict[str, Any], data)
|
|
1287
|
+
return _render_collection_section(
|
|
1288
|
+
title=None,
|
|
1289
|
+
rows=cast(list[Any], envelope.get("data", [])),
|
|
1290
|
+
pagination=cast(dict[str, Any] | None, envelope.get("pagination")),
|
|
1291
|
+
all_columns=all_columns,
|
|
1292
|
+
max_columns=max_columns,
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
# Sectioned dict rendering: render collections as tables; objects as kv.
|
|
1296
|
+
keys = list(data.keys())
|
|
1297
|
+
if len(keys) == 1:
|
|
1298
|
+
only_key = str(keys[0])
|
|
1299
|
+
v = data[only_key]
|
|
1300
|
+
section_pagination = _extract_section_pagination(
|
|
1301
|
+
meta_pagination=meta_pagination, section=only_key
|
|
1302
|
+
)
|
|
1303
|
+
if _is_text_marker(v):
|
|
1304
|
+
return Group(
|
|
1305
|
+
Text(_humanize_title(only_key), style="bold"),
|
|
1306
|
+
Text(cast(dict[str, Any], v)["_text"]),
|
|
1307
|
+
)
|
|
1308
|
+
if isinstance(v, dict) and _is_collection_envelope(v):
|
|
1309
|
+
envelope = cast(dict[str, Any], v)
|
|
1310
|
+
return _render_collection_section(
|
|
1311
|
+
title=only_key,
|
|
1312
|
+
rows=cast(list[Any], envelope.get("data", [])),
|
|
1313
|
+
pagination=cast(dict[str, Any] | None, envelope.get("pagination")),
|
|
1314
|
+
all_columns=all_columns,
|
|
1315
|
+
max_columns=max_columns,
|
|
1316
|
+
)
|
|
1317
|
+
if isinstance(v, dict) and _is_collection_with_hint(v):
|
|
1318
|
+
return _render_collection_with_hint(
|
|
1319
|
+
title=only_key,
|
|
1320
|
+
rows=cast(list[Any], v.get("_rows", [])),
|
|
1321
|
+
hint=cast(str, v.get("_hint")),
|
|
1322
|
+
all_columns=all_columns,
|
|
1323
|
+
max_columns=max_columns,
|
|
1324
|
+
)
|
|
1325
|
+
if isinstance(v, list) and all(isinstance(x, dict) for x in v):
|
|
1326
|
+
# Check if this is a fields section with metadata available
|
|
1327
|
+
if (
|
|
1328
|
+
only_key == "fields"
|
|
1329
|
+
and isinstance(meta_resolved, dict)
|
|
1330
|
+
and "fieldMetadata" in meta_resolved
|
|
1331
|
+
):
|
|
1332
|
+
field_metadata = meta_resolved.get("fieldMetadata")
|
|
1333
|
+
if isinstance(field_metadata, dict):
|
|
1334
|
+
fields_section = _render_fields_section(
|
|
1335
|
+
title=f"Fields ({len(v)})" if v else "Fields",
|
|
1336
|
+
fields=cast(list[dict[str, Any]], v),
|
|
1337
|
+
field_metadata=cast(dict[str, str], field_metadata),
|
|
1338
|
+
verbose=verbosity >= 1,
|
|
1339
|
+
)
|
|
1340
|
+
if fields_section is not None:
|
|
1341
|
+
return fields_section
|
|
1342
|
+
return _render_collection_section(
|
|
1343
|
+
title=only_key,
|
|
1344
|
+
rows=v,
|
|
1345
|
+
pagination=section_pagination,
|
|
1346
|
+
all_columns=all_columns,
|
|
1347
|
+
max_columns=max_columns,
|
|
1348
|
+
)
|
|
1349
|
+
if isinstance(v, dict):
|
|
1350
|
+
company_force_nested: set[str] | None = None
|
|
1351
|
+
if (
|
|
1352
|
+
only_key == "company"
|
|
1353
|
+
and isinstance(meta_resolved, dict)
|
|
1354
|
+
and "fieldSelection" in meta_resolved
|
|
1355
|
+
):
|
|
1356
|
+
company_force_nested = {"fields"}
|
|
1357
|
+
return _render_object_section(
|
|
1358
|
+
title=only_key,
|
|
1359
|
+
obj=v,
|
|
1360
|
+
verbosity=verbosity,
|
|
1361
|
+
pagination=section_pagination,
|
|
1362
|
+
force_nested_keys=company_force_nested,
|
|
1363
|
+
all_columns=all_columns,
|
|
1364
|
+
max_columns=max_columns,
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
sections: list[Any] = []
|
|
1368
|
+
for k in keys:
|
|
1369
|
+
key = str(k)
|
|
1370
|
+
v = data[key]
|
|
1371
|
+
section_pagination = _extract_section_pagination(
|
|
1372
|
+
meta_pagination=meta_pagination, section=key
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
if _is_text_marker(v):
|
|
1376
|
+
sections.append(
|
|
1377
|
+
Group(
|
|
1378
|
+
Text(_humanize_title(key), style="bold"),
|
|
1379
|
+
Text(cast(dict[str, Any], v)["_text"]),
|
|
1380
|
+
)
|
|
1381
|
+
)
|
|
1382
|
+
continue
|
|
1383
|
+
|
|
1384
|
+
if isinstance(v, dict) and _is_collection_envelope(v):
|
|
1385
|
+
envelope = cast(dict[str, Any], v)
|
|
1386
|
+
sections.append(
|
|
1387
|
+
_render_collection_section(
|
|
1388
|
+
title=key,
|
|
1389
|
+
rows=cast(list[Any], envelope.get("data", [])),
|
|
1390
|
+
pagination=cast(dict[str, Any] | None, envelope.get("pagination")),
|
|
1391
|
+
all_columns=all_columns,
|
|
1392
|
+
max_columns=max_columns,
|
|
1393
|
+
)
|
|
1394
|
+
)
|
|
1395
|
+
elif isinstance(v, dict) and _is_collection_with_hint(v):
|
|
1396
|
+
sections.append(
|
|
1397
|
+
_render_collection_with_hint(
|
|
1398
|
+
title=key,
|
|
1399
|
+
rows=cast(list[Any], v.get("_rows", [])),
|
|
1400
|
+
hint=cast(str, v.get("_hint")),
|
|
1401
|
+
all_columns=all_columns,
|
|
1402
|
+
max_columns=max_columns,
|
|
1403
|
+
)
|
|
1404
|
+
)
|
|
1405
|
+
elif isinstance(v, list) and all(isinstance(x, dict) for x in v):
|
|
1406
|
+
# Check if this is a fields section with metadata available
|
|
1407
|
+
if (
|
|
1408
|
+
key == "fields"
|
|
1409
|
+
and isinstance(meta_resolved, dict)
|
|
1410
|
+
and "fieldMetadata" in meta_resolved
|
|
1411
|
+
):
|
|
1412
|
+
field_metadata = meta_resolved.get("fieldMetadata")
|
|
1413
|
+
if isinstance(field_metadata, dict):
|
|
1414
|
+
fields_section = _render_fields_section(
|
|
1415
|
+
title=f"Fields ({len(v)})" if v else "Fields",
|
|
1416
|
+
fields=cast(list[dict[str, Any]], v),
|
|
1417
|
+
field_metadata=cast(dict[str, str], field_metadata),
|
|
1418
|
+
verbose=verbosity >= 1,
|
|
1419
|
+
)
|
|
1420
|
+
if fields_section is not None:
|
|
1421
|
+
sections.append(fields_section)
|
|
1422
|
+
continue
|
|
1423
|
+
sections.append(
|
|
1424
|
+
_render_collection_section(
|
|
1425
|
+
title=key,
|
|
1426
|
+
rows=v,
|
|
1427
|
+
pagination=section_pagination,
|
|
1428
|
+
all_columns=all_columns,
|
|
1429
|
+
max_columns=max_columns,
|
|
1430
|
+
)
|
|
1431
|
+
)
|
|
1432
|
+
elif isinstance(v, dict):
|
|
1433
|
+
force_nested: set[str] | None = None
|
|
1434
|
+
if (
|
|
1435
|
+
key == "company"
|
|
1436
|
+
and isinstance(meta_resolved, dict)
|
|
1437
|
+
and "fieldSelection" in meta_resolved
|
|
1438
|
+
):
|
|
1439
|
+
force_nested = {"fields"}
|
|
1440
|
+
sections.append(
|
|
1441
|
+
_render_object_section(
|
|
1442
|
+
title=key,
|
|
1443
|
+
obj=v,
|
|
1444
|
+
verbosity=verbosity,
|
|
1445
|
+
pagination=section_pagination,
|
|
1446
|
+
force_nested_keys=force_nested,
|
|
1447
|
+
all_columns=all_columns,
|
|
1448
|
+
max_columns=max_columns,
|
|
1449
|
+
)
|
|
1450
|
+
)
|
|
1451
|
+
else:
|
|
1452
|
+
sections.append(
|
|
1453
|
+
_render_object_section(
|
|
1454
|
+
title=key,
|
|
1455
|
+
obj={"value": v},
|
|
1456
|
+
verbosity=verbosity,
|
|
1457
|
+
pagination=section_pagination,
|
|
1458
|
+
force_nested_keys=None,
|
|
1459
|
+
all_columns=all_columns,
|
|
1460
|
+
max_columns=max_columns,
|
|
1461
|
+
)
|
|
1462
|
+
)
|
|
1463
|
+
return Group(*sections) if sections else Panel.fit(Text("OK"))
|
|
1464
|
+
|
|
1465
|
+
return Panel.fit(Text(str(data) if data is not None else "OK"))
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def _should_use_pager(*, settings: RenderSettings, stdout: Console, renderable: Any) -> bool:
|
|
1469
|
+
if settings.pager is False:
|
|
1470
|
+
return False
|
|
1471
|
+
if settings.pager is True:
|
|
1472
|
+
return True
|
|
1473
|
+
|
|
1474
|
+
# Auto mode: only page when interactive and the output is likely to scroll.
|
|
1475
|
+
if not sys.stdout.isatty():
|
|
1476
|
+
return False
|
|
1477
|
+
|
|
1478
|
+
try:
|
|
1479
|
+
height = stdout.size.height
|
|
1480
|
+
if not height:
|
|
1481
|
+
return False
|
|
1482
|
+
lines = stdout.render_lines(renderable, options=stdout.options, pad=False)
|
|
1483
|
+
return len(lines) > height
|
|
1484
|
+
except Exception:
|
|
1485
|
+
return False
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def render_result(result: CommandResult, *, settings: RenderSettings) -> int:
|
|
1489
|
+
stdout = Console(file=sys.stdout, force_terminal=False)
|
|
1490
|
+
stderr = Console(file=sys.stderr, force_terminal=False)
|
|
1491
|
+
|
|
1492
|
+
if settings.output == "json":
|
|
1493
|
+
payload = result.model_dump(by_alias=True, mode="json")
|
|
1494
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
1495
|
+
return 0
|
|
1496
|
+
|
|
1497
|
+
# table/human output
|
|
1498
|
+
if not result.ok:
|
|
1499
|
+
if result.error is not None:
|
|
1500
|
+
title = _error_title(result.error.type)
|
|
1501
|
+
stderr.print(f"{title}: {result.error.message}")
|
|
1502
|
+
_render_error_details(
|
|
1503
|
+
stderr=stderr,
|
|
1504
|
+
command=result.command.name,
|
|
1505
|
+
error_type=result.error.type,
|
|
1506
|
+
message=result.error.message,
|
|
1507
|
+
hint=result.error.hint,
|
|
1508
|
+
docs_url=result.error.docs_url,
|
|
1509
|
+
details=result.error.details,
|
|
1510
|
+
settings=settings,
|
|
1511
|
+
)
|
|
1512
|
+
else:
|
|
1513
|
+
stderr.print("Error")
|
|
1514
|
+
return 0
|
|
1515
|
+
|
|
1516
|
+
# Display context header if available
|
|
1517
|
+
context_header = result.command.format_header()
|
|
1518
|
+
if context_header and not settings.quiet:
|
|
1519
|
+
stdout.print(Text(context_header, style="bold"))
|
|
1520
|
+
|
|
1521
|
+
renderable: Any
|
|
1522
|
+
if result.command.name == "version" and isinstance(result.data, dict):
|
|
1523
|
+
renderable = Text(result.data.get("version", ""), style="bold")
|
|
1524
|
+
elif result.command.name == "config path" and isinstance(result.data, dict):
|
|
1525
|
+
renderable = Text(str(result.data.get("path", "")))
|
|
1526
|
+
elif result.command.name == "config init" and isinstance(result.data, dict):
|
|
1527
|
+
renderable = Panel.fit(Text(f"Initialized config at {result.data.get('path', '')}"))
|
|
1528
|
+
elif result.command.name == "resolve-url" and isinstance(result.data, dict):
|
|
1529
|
+
renderable = Text(
|
|
1530
|
+
f"{result.data.get('type')} {result.data.get('canonicalUrl', '')}".strip()
|
|
1531
|
+
)
|
|
1532
|
+
elif result.command.name == "whoami" and isinstance(result.data, dict):
|
|
1533
|
+
tenant = (
|
|
1534
|
+
result.data.get("tenant", {}) if isinstance(result.data.get("tenant"), dict) else {}
|
|
1535
|
+
)
|
|
1536
|
+
user = result.data.get("user", {}) if isinstance(result.data.get("user"), dict) else {}
|
|
1537
|
+
tenant_name = tenant.get("name") or ""
|
|
1538
|
+
tenant_id = tenant.get("id")
|
|
1539
|
+
first = user.get("firstName", "") or ""
|
|
1540
|
+
last = user.get("lastName", "") or ""
|
|
1541
|
+
email = user.get("emailAddress", "") or ""
|
|
1542
|
+
user_id = user.get("id")
|
|
1543
|
+
name = f"{first} {last}".strip()
|
|
1544
|
+
# Simple text output: "Name <email>" with optional tenant on separate line
|
|
1545
|
+
lines = []
|
|
1546
|
+
if tenant_name:
|
|
1547
|
+
lines.append(tenant_name)
|
|
1548
|
+
if name and email:
|
|
1549
|
+
lines.append(f"{name} <{email}>")
|
|
1550
|
+
elif name:
|
|
1551
|
+
lines.append(name)
|
|
1552
|
+
elif email:
|
|
1553
|
+
lines.append(email)
|
|
1554
|
+
# With -v, show IDs (useful for scripting)
|
|
1555
|
+
if settings.verbosity >= 1:
|
|
1556
|
+
if user_id is not None:
|
|
1557
|
+
lines.append(f"User ID: {user_id}")
|
|
1558
|
+
if tenant_id is not None:
|
|
1559
|
+
lines.append(f"Tenant ID: {tenant_id}")
|
|
1560
|
+
renderable = Text("\n".join(lines)) if lines else Text("OK")
|
|
1561
|
+
else:
|
|
1562
|
+
renderable = _render_human_data(
|
|
1563
|
+
data=result.data,
|
|
1564
|
+
meta_pagination=result.meta.pagination,
|
|
1565
|
+
meta_resolved=result.meta.resolved,
|
|
1566
|
+
verbosity=settings.verbosity,
|
|
1567
|
+
all_columns=settings.all_columns,
|
|
1568
|
+
max_columns=settings.max_columns,
|
|
1569
|
+
)
|
|
1570
|
+
|
|
1571
|
+
if renderable is not None:
|
|
1572
|
+
if _should_use_pager(settings=settings, stdout=stdout, renderable=renderable):
|
|
1573
|
+
with stdout.pager(styles=True):
|
|
1574
|
+
stdout.print(renderable)
|
|
1575
|
+
else:
|
|
1576
|
+
stdout.print(renderable)
|
|
1577
|
+
|
|
1578
|
+
# Render summary footer (e.g., row counts, date ranges, type breakdowns)
|
|
1579
|
+
if result.meta.summary is not None and not settings.quiet:
|
|
1580
|
+
summary_footer = _render_summary_footer(result.meta.summary, settings.verbosity)
|
|
1581
|
+
if summary_footer:
|
|
1582
|
+
stdout.print(summary_footer)
|
|
1583
|
+
|
|
1584
|
+
if result.meta.rate_limit is not None and not settings.quiet:
|
|
1585
|
+
footer = _rate_limit_footer(result.meta.rate_limit)
|
|
1586
|
+
if footer:
|
|
1587
|
+
stdout.print(footer)
|
|
1588
|
+
|
|
1589
|
+
return 0
|