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.
Files changed (92) hide show
  1. affinity/__init__.py +139 -0
  2. affinity/cli/__init__.py +7 -0
  3. affinity/cli/click_compat.py +27 -0
  4. affinity/cli/commands/__init__.py +1 -0
  5. affinity/cli/commands/_entity_files_dump.py +219 -0
  6. affinity/cli/commands/_list_entry_fields.py +41 -0
  7. affinity/cli/commands/_v1_parsing.py +77 -0
  8. affinity/cli/commands/company_cmds.py +2139 -0
  9. affinity/cli/commands/completion_cmd.py +33 -0
  10. affinity/cli/commands/config_cmds.py +540 -0
  11. affinity/cli/commands/entry_cmds.py +33 -0
  12. affinity/cli/commands/field_cmds.py +413 -0
  13. affinity/cli/commands/interaction_cmds.py +875 -0
  14. affinity/cli/commands/list_cmds.py +3152 -0
  15. affinity/cli/commands/note_cmds.py +433 -0
  16. affinity/cli/commands/opportunity_cmds.py +1174 -0
  17. affinity/cli/commands/person_cmds.py +1980 -0
  18. affinity/cli/commands/query_cmd.py +444 -0
  19. affinity/cli/commands/relationship_strength_cmds.py +62 -0
  20. affinity/cli/commands/reminder_cmds.py +595 -0
  21. affinity/cli/commands/resolve_url_cmd.py +127 -0
  22. affinity/cli/commands/session_cmds.py +84 -0
  23. affinity/cli/commands/task_cmds.py +110 -0
  24. affinity/cli/commands/version_cmd.py +29 -0
  25. affinity/cli/commands/whoami_cmd.py +36 -0
  26. affinity/cli/config.py +108 -0
  27. affinity/cli/context.py +749 -0
  28. affinity/cli/csv_utils.py +195 -0
  29. affinity/cli/date_utils.py +42 -0
  30. affinity/cli/decorators.py +77 -0
  31. affinity/cli/errors.py +28 -0
  32. affinity/cli/field_utils.py +355 -0
  33. affinity/cli/formatters.py +551 -0
  34. affinity/cli/help_json.py +283 -0
  35. affinity/cli/logging.py +100 -0
  36. affinity/cli/main.py +261 -0
  37. affinity/cli/options.py +53 -0
  38. affinity/cli/paths.py +32 -0
  39. affinity/cli/progress.py +183 -0
  40. affinity/cli/query/__init__.py +163 -0
  41. affinity/cli/query/aggregates.py +357 -0
  42. affinity/cli/query/dates.py +194 -0
  43. affinity/cli/query/exceptions.py +147 -0
  44. affinity/cli/query/executor.py +1236 -0
  45. affinity/cli/query/filters.py +248 -0
  46. affinity/cli/query/models.py +333 -0
  47. affinity/cli/query/output.py +331 -0
  48. affinity/cli/query/parser.py +619 -0
  49. affinity/cli/query/planner.py +430 -0
  50. affinity/cli/query/progress.py +270 -0
  51. affinity/cli/query/schema.py +439 -0
  52. affinity/cli/render.py +1589 -0
  53. affinity/cli/resolve.py +222 -0
  54. affinity/cli/resolvers.py +249 -0
  55. affinity/cli/results.py +308 -0
  56. affinity/cli/runner.py +218 -0
  57. affinity/cli/serialization.py +65 -0
  58. affinity/cli/session_cache.py +276 -0
  59. affinity/cli/types.py +70 -0
  60. affinity/client.py +771 -0
  61. affinity/clients/__init__.py +19 -0
  62. affinity/clients/http.py +3664 -0
  63. affinity/clients/pipeline.py +165 -0
  64. affinity/compare.py +501 -0
  65. affinity/downloads.py +114 -0
  66. affinity/exceptions.py +615 -0
  67. affinity/filters.py +1128 -0
  68. affinity/hooks.py +198 -0
  69. affinity/inbound_webhooks.py +302 -0
  70. affinity/models/__init__.py +163 -0
  71. affinity/models/entities.py +798 -0
  72. affinity/models/pagination.py +513 -0
  73. affinity/models/rate_limit_snapshot.py +48 -0
  74. affinity/models/secondary.py +413 -0
  75. affinity/models/types.py +663 -0
  76. affinity/policies.py +40 -0
  77. affinity/progress.py +22 -0
  78. affinity/py.typed +0 -0
  79. affinity/services/__init__.py +42 -0
  80. affinity/services/companies.py +1286 -0
  81. affinity/services/lists.py +1892 -0
  82. affinity/services/opportunities.py +1330 -0
  83. affinity/services/persons.py +1348 -0
  84. affinity/services/rate_limits.py +173 -0
  85. affinity/services/tasks.py +193 -0
  86. affinity/services/v1_only.py +2445 -0
  87. affinity/types.py +83 -0
  88. affinity_sdk-0.9.5.dist-info/METADATA +622 -0
  89. affinity_sdk-0.9.5.dist-info/RECORD +92 -0
  90. affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
  91. affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
  92. 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