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
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import Field
7
+
8
+ from affinity.models.entities import AffinityModel
9
+ from affinity.models.rate_limit_snapshot import RateLimitSnapshot
10
+
11
+
12
+ class CommandContext(AffinityModel):
13
+ """Structured command context for reproducibility and debugging.
14
+
15
+ Attributes:
16
+ name: Command name (e.g., "person get", "list entry ls")
17
+ inputs: Required positional/named inputs that identify what was queried
18
+ modifiers: Optional flags/options that modify behavior
19
+ resolved: Human-readable names for IDs in inputs (optional)
20
+ """
21
+
22
+ name: str
23
+ inputs: dict[str, Any] = Field(default_factory=dict)
24
+ modifiers: dict[str, Any] = Field(default_factory=dict)
25
+ resolved: dict[str, str] | None = None
26
+
27
+ def format_header(self) -> str | None:
28
+ """Generate human-readable context header for CLI output.
29
+
30
+ Returns None for commands that don't need a header (e.g., whoami).
31
+ """
32
+ parts = self.name.split()
33
+ if len(parts) < 2:
34
+ return None
35
+
36
+ entity_type = parts[0] # person, company, list, etc.
37
+ action = parts[1] # get, ls, create, etc.
38
+
39
+ # Special case: no header for simple info commands
40
+ if self.name in ("whoami",):
41
+ return None
42
+
43
+ # Map entity types to display names
44
+ entity_names = {
45
+ "person": "Person",
46
+ "company": "Company",
47
+ "opportunity": "Opportunity",
48
+ "list": "List",
49
+ "field": "Field",
50
+ "field-value": "Field Value",
51
+ "field-value-changes": "Field Value Changes",
52
+ "note": "Note",
53
+ "interaction": "Interaction",
54
+ "reminder": "Reminder",
55
+ "relationship-strength": "Relationship Strength",
56
+ "entity-file": "Entity File",
57
+ "webhook": "Webhook",
58
+ }
59
+
60
+ display_name = entity_names.get(entity_type, entity_type.title())
61
+
62
+ # Handle "list entry" as a special case
63
+ if entity_type == "list" and len(parts) >= 3 and parts[1] == "entry":
64
+ display_name = "List Entry"
65
+ action = parts[2]
66
+
67
+ # Entity get commands
68
+ if action == "get":
69
+ return self._format_get_header(display_name)
70
+
71
+ # List commands
72
+ if action == "ls":
73
+ return self._format_ls_header(display_name)
74
+
75
+ # Create commands
76
+ if action == "create":
77
+ return self._format_create_header(display_name)
78
+
79
+ # Update/delete commands
80
+ if action in ("update", "delete"):
81
+ return self._format_mutation_header(display_name, action)
82
+
83
+ # Merge commands
84
+ if action == "merge":
85
+ return self._format_merge_header(display_name)
86
+
87
+ return None
88
+
89
+ def _format_get_header(self, display_name: str) -> str:
90
+ """Format header for get commands."""
91
+ # Check for selector or entity ID
92
+ if "selector" in self.inputs:
93
+ selector = self.inputs["selector"]
94
+ if self.resolved and "selector" in self.resolved:
95
+ return f'{display_name} "{self.resolved["selector"]}" ({selector})'
96
+ return f"{display_name} {selector}"
97
+
98
+ # Find the primary ID input
99
+ id_key = self._find_primary_id_key()
100
+ if id_key and id_key in self.inputs:
101
+ entity_id = self.inputs[id_key]
102
+ if self.resolved and id_key in self.resolved:
103
+ return f'{display_name} "{self.resolved[id_key]}" (ID {entity_id})'
104
+ return f"{display_name} ID {entity_id}"
105
+
106
+ return display_name
107
+
108
+ def _format_ls_header(self, display_name: str) -> str:
109
+ """Format header for ls commands."""
110
+ # Special case: relationship-strength with composite keys
111
+ if self.name == "relationship-strength ls":
112
+ internal = self.inputs.get("internalId")
113
+ external = self.inputs.get("externalId")
114
+ if internal and external:
115
+ return f"Relationship Strength: Person ID {internal} ↔ Person ID {external}"
116
+
117
+ # Check for primary scope input (e.g., listId for list entry ls)
118
+ scope_input = None
119
+ for key in ("listId", "entryId"):
120
+ if key in self.inputs:
121
+ scope_input = (key, self.inputs[key])
122
+ break
123
+
124
+ if scope_input:
125
+ key, value = scope_input
126
+ entity = key.replace("Id", "").title()
127
+ if self.resolved and key in self.resolved:
128
+ return f'{display_name}s: {entity} "{self.resolved[key]}"'
129
+ return f"{display_name}s: {entity} ID {value}"
130
+
131
+ # Check for modifier filters
132
+ filters = self._get_display_filters()
133
+ if not filters:
134
+ return f"{display_name}s"
135
+
136
+ # Check for primary entity filter (personId, companyId, etc.)
137
+ entity_filter_keys = ["personId", "companyId", "opportunityId"]
138
+ present_entity_filters = [ef for ef in entity_filter_keys if ef in filters]
139
+
140
+ # Single entity filter uses "for Entity ID X" pattern
141
+ if len(present_entity_filters) == 1:
142
+ ef = present_entity_filters[0]
143
+ entity = ef.replace("Id", "").title()
144
+ remaining = {k: v for k, v in filters.items() if k != ef}
145
+ base = f"{display_name}s for {entity} ID {self.modifiers[ef]}"
146
+ if remaining:
147
+ extra = self._format_filter_suffix(remaining)
148
+ return f"{base} ({extra})"
149
+ return base
150
+
151
+ # Multiple filters: use parenthetical format with shortened keys
152
+ filter_str = self._format_filter_suffix(filters)
153
+ return f"{display_name}s ({filter_str})"
154
+
155
+ def _format_create_header(self, display_name: str) -> str:
156
+ """Format header for create commands."""
157
+ # Check for scope input
158
+ if "listId" in self.inputs:
159
+ list_id = self.inputs["listId"]
160
+ if self.resolved and "listId" in self.resolved:
161
+ return f'{display_name} Create: List "{self.resolved["listId"]}"'
162
+ return f"{display_name} Create: List ID {list_id}"
163
+
164
+ if "type" in self.inputs:
165
+ return f"{display_name} Create (type: {self.inputs['type']})"
166
+
167
+ return f"{display_name} Create"
168
+
169
+ def _format_mutation_header(self, display_name: str, action: str) -> str:
170
+ """Format header for update/delete commands."""
171
+ id_key = self._find_primary_id_key()
172
+ if id_key and id_key in self.inputs:
173
+ entity_id = self.inputs[id_key]
174
+ if self.resolved and id_key in self.resolved:
175
+ resolved_name = self.resolved[id_key]
176
+ return f'{display_name} {action.title()}: "{resolved_name}" (ID {entity_id})'
177
+ return f"{display_name} {action.title()}: ID {entity_id}"
178
+ return f"{display_name} {action.title()}"
179
+
180
+ def _format_merge_header(self, display_name: str) -> str:
181
+ """Format header for merge commands."""
182
+ primary = self.inputs.get("primaryId")
183
+ duplicate = self.inputs.get("duplicateId")
184
+ if primary and duplicate:
185
+ return f"{display_name} Merge: ID {primary} ← ID {duplicate}"
186
+ return f"{display_name} Merge"
187
+
188
+ def _find_primary_id_key(self) -> str | None:
189
+ """Find the primary ID key in inputs."""
190
+ id_keys = [
191
+ "personId",
192
+ "companyId",
193
+ "opportunityId",
194
+ "listId",
195
+ "entryId",
196
+ "fieldId",
197
+ "fieldValueId",
198
+ "noteId",
199
+ "interactionId",
200
+ "reminderId",
201
+ "entityFileId",
202
+ "webhookId",
203
+ ]
204
+ for key in id_keys:
205
+ if key in self.inputs:
206
+ return key
207
+ return None
208
+
209
+ def _get_display_filters(self) -> dict[str, Any]:
210
+ """Get modifiers that should be displayed in headers (exclude pagination)."""
211
+ exclude = {"pageSize", "cursor", "maxResults", "allPages"}
212
+ return {k: v for k, v in self.modifiers.items() if k not in exclude and v is not None}
213
+
214
+ def _shorten_key(self, key: str) -> str:
215
+ """Shorten key names for display (personId → person)."""
216
+ if key.endswith("Id") and len(key) > 2:
217
+ return key[:-2]
218
+ return key
219
+
220
+ def _format_filter_suffix(self, filters: dict[str, Any], max_display: int = 2) -> str:
221
+ """Format filter dict as suffix string, truncating if needed."""
222
+ items = [(self._shorten_key(k), v) for k, v in filters.items()]
223
+ if len(items) <= max_display:
224
+ return ", ".join(f"{k}: {v}" for k, v in items)
225
+
226
+ displayed = items[:max_display]
227
+ remaining = len(items) - max_display
228
+ parts = [f"{k}: {v}" for k, v in displayed]
229
+ parts.append(f"+{remaining} more")
230
+ return ", ".join(parts)
231
+
232
+
233
+ class DateRange(AffinityModel):
234
+ """Date range for time-bounded queries.
235
+
236
+ Display format: YYYY-MM-DD → YYYY-MM-DD (compact, for footer)
237
+ JSON format: Full ISO-8601 with timezone (via Pydantic serialization)
238
+ """
239
+
240
+ start: datetime
241
+ end: datetime
242
+
243
+ def format_display(self) -> str:
244
+ """Format as 'YYYY-MM-DD → YYYY-MM-DD' for human display."""
245
+ return f"{self.start.strftime('%Y-%m-%d')} → {self.end.strftime('%Y-%m-%d')}"
246
+
247
+
248
+ class ResultSummary(AffinityModel):
249
+ """Metadata about query results - rendered as footer in table mode.
250
+
251
+ This is the standardized way to communicate summary information
252
+ about results. All commands should use this instead of ad-hoc
253
+ metadata dictionaries.
254
+
255
+ Attributes:
256
+ total_rows: Total number of rows in the result
257
+ date_range: For time-bounded queries (e.g., interaction ls)
258
+ type_breakdown: Count per type (e.g., {"email": 120, "call": 30})
259
+ included_counts: For query --include (e.g., {"companies": 10})
260
+ chunks_processed: For chunked fetches (interaction ls)
261
+ scanned_rows: For filtered queries (rows examined before filter)
262
+ custom_text: Escape hatch for one-off messages
263
+ """
264
+
265
+ total_rows: int | None = Field(None, alias="totalRows")
266
+ date_range: DateRange | None = Field(None, alias="dateRange")
267
+ type_breakdown: dict[str, int] | None = Field(None, alias="typeBreakdown")
268
+ included_counts: dict[str, int] | None = Field(None, alias="includedCounts")
269
+ chunks_processed: int | None = Field(None, alias="chunksProcessed")
270
+ scanned_rows: int | None = Field(None, alias="scannedRows")
271
+ custom_text: str | None = Field(None, alias="customText")
272
+
273
+
274
+ class Artifact(AffinityModel):
275
+ type: str
276
+ path: str
277
+ path_is_relative: bool = Field(..., alias="pathIsRelative")
278
+ rows_written: int | None = Field(None, alias="rowsWritten")
279
+ bytes_written: int | None = Field(None, alias="bytesWritten")
280
+ partial: bool = False
281
+
282
+
283
+ class ErrorInfo(AffinityModel):
284
+ type: str
285
+ message: str
286
+ hint: str | None = None
287
+ docs_url: str | None = Field(None, alias="docsUrl")
288
+ details: dict[str, Any] | None = None
289
+
290
+
291
+ class CommandMeta(AffinityModel):
292
+ duration_ms: int = Field(..., alias="durationMs")
293
+ profile: str | None = None
294
+ resolved: dict[str, Any] | None = None
295
+ pagination: dict[str, Any] | None = None
296
+ columns: list[dict[str, Any]] | None = None
297
+ rate_limit: RateLimitSnapshot | None = Field(None, alias="rateLimit")
298
+ summary: ResultSummary | None = None
299
+
300
+
301
+ class CommandResult(AffinityModel):
302
+ ok: bool
303
+ command: CommandContext
304
+ data: Any | None = None
305
+ artifacts: list[Artifact] = Field(default_factory=list)
306
+ warnings: list[str] = Field(default_factory=list)
307
+ meta: CommandMeta
308
+ error: ErrorInfo | None = None
affinity/cli/runner.py ADDED
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from rich.console import Console
11
+
12
+ from .click_compat import click
13
+ from .context import (
14
+ CLIContext,
15
+ build_result,
16
+ error_info_for_exception,
17
+ exit_code_for_exception,
18
+ normalize_exception,
19
+ )
20
+ from .formatters import _empty_output, format_data
21
+ from .render import RenderSettings, render_result
22
+ from .results import Artifact, CommandContext, CommandResult, ResultSummary
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class CommandOutput:
27
+ data: Any | None = None
28
+ context: CommandContext | None = None # Structured command context
29
+ artifacts: list[Artifact] | None = None
30
+ warnings: list[str] | None = None
31
+ pagination: dict[str, Any] | None = None
32
+ resolved: dict[str, Any] | None = None
33
+ columns: list[dict[str, Any]] | None = None
34
+ rate_limit: Any | None = None
35
+ summary: ResultSummary | None = None # Standardized result summary for footer
36
+ api_called: bool = False
37
+ exit_code: int = 0 # Allow commands to specify non-zero exit codes (e.g., check-key)
38
+
39
+
40
+ def _emit_json(result: CommandResult) -> None:
41
+ payload = result.model_dump(by_alias=True, mode="json")
42
+ meta = payload.get("meta")
43
+ if isinstance(meta, dict) and meta.get("rateLimit") is None:
44
+ meta.pop("rateLimit", None)
45
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
46
+
47
+
48
+ def _emit_warnings(*, ctx: CLIContext, warnings: list[str]) -> None:
49
+ if ctx.quiet:
50
+ return
51
+ if not warnings:
52
+ return
53
+ stderr = Console(file=sys.stderr, force_terminal=False)
54
+ for w in warnings:
55
+ stderr.print(f"Warning: {w}")
56
+
57
+
58
+ def emit_result(ctx: CLIContext, result: CommandResult) -> None:
59
+ # JSON always uses full envelope (backwards compatible)
60
+ if ctx.output == "json":
61
+ _emit_json(result)
62
+ if ctx.verbosity >= 1 and not ctx.quiet and result.meta.rate_limit is not None:
63
+ stderr = Console(file=sys.stderr, force_terminal=False)
64
+ rl = result.meta.rate_limit
65
+ footer = []
66
+ if (
67
+ rl.api_key_per_minute.remaining is not None
68
+ and rl.api_key_per_minute.limit is not None
69
+ ):
70
+ footer.append(
71
+ f"user {rl.api_key_per_minute.remaining}/{rl.api_key_per_minute.limit}"
72
+ )
73
+ if rl.org_monthly.remaining is not None and rl.org_monthly.limit is not None:
74
+ footer.append(f"org {rl.org_monthly.remaining}/{rl.org_monthly.limit}")
75
+ if footer:
76
+ stderr.print(f"rate-limit[{rl.source}]: " + " | ".join(footer))
77
+ return
78
+
79
+ # Table uses existing sophisticated render.py
80
+ if ctx.output == "table":
81
+ render_result(
82
+ result,
83
+ settings=RenderSettings(
84
+ output="table",
85
+ quiet=ctx.quiet,
86
+ verbosity=ctx.verbosity,
87
+ pager=ctx.pager,
88
+ all_columns=ctx.all_columns,
89
+ max_columns=ctx.max_columns,
90
+ ),
91
+ )
92
+ _emit_warnings(ctx=ctx, warnings=result.warnings)
93
+ if ctx.verbosity >= 1 and not ctx.quiet and result.meta.rate_limit is not None:
94
+ stderr = Console(file=sys.stderr, force_terminal=False)
95
+ rl = result.meta.rate_limit
96
+ parts: list[str] = []
97
+ if (
98
+ rl.api_key_per_minute.remaining is not None
99
+ and rl.api_key_per_minute.limit is not None
100
+ ):
101
+ parts.append(
102
+ f"user {rl.api_key_per_minute.remaining}/{rl.api_key_per_minute.limit}"
103
+ )
104
+ if rl.org_monthly.remaining is not None and rl.org_monthly.limit is not None:
105
+ parts.append(f"org {rl.org_monthly.remaining}/{rl.org_monthly.limit}")
106
+ if parts:
107
+ extra = ""
108
+ if rl.request_id:
109
+ extra = f" requestId={rl.request_id}"
110
+ stderr.print(f"rate-limit[{rl.source}]: " + " | ".join(parts) + extra)
111
+ return
112
+
113
+ # New formats: JSONL, Markdown, TOON, CSV
114
+ # These output DATA ONLY (no envelope) for token efficiency
115
+ if ctx.output in ("jsonl", "markdown", "toon", "csv"):
116
+ if not result.ok:
117
+ # Errors fall back to JSON envelope for structure
118
+ # Warn user about format change
119
+ if not ctx.quiet:
120
+ stderr = Console(file=sys.stderr, force_terminal=False)
121
+ stderr.print("Error occurred - output format changed to JSON for error details")
122
+ _emit_json(result)
123
+ return
124
+
125
+ data = result.data
126
+ if data is None:
127
+ sys.stdout.write(_empty_output(ctx.output) + "\n")
128
+ return
129
+
130
+ # Normalize to list of dicts
131
+ if isinstance(data, dict):
132
+ data = [data]
133
+ elif not isinstance(data, list):
134
+ # Non-tabular data falls back to JSON
135
+ if not ctx.quiet:
136
+ stderr = Console(file=sys.stderr, force_terminal=False)
137
+ stderr.print("Non-tabular data - output format changed to JSON")
138
+ _emit_json(result)
139
+ return
140
+
141
+ # Detect fieldnames: prefer meta.columns, fall back to first row keys
142
+ fieldnames: list[str] | None = None
143
+ if result.meta.columns and len(result.meta.columns) > 0:
144
+ fieldnames = [
145
+ c.get("name") or c.get("key") or f"col{i}"
146
+ for i, c in enumerate(result.meta.columns)
147
+ ]
148
+ if not fieldnames and data:
149
+ fieldnames = list(data[0].keys())
150
+
151
+ output = format_data(data, ctx.output, fieldnames=fieldnames)
152
+ sys.stdout.write(output + "\n")
153
+
154
+ # Warnings still go to stderr (consistent with table mode)
155
+ _emit_warnings(ctx=ctx, warnings=result.warnings)
156
+ return
157
+
158
+
159
+ CommandFn = Callable[[CLIContext, list[str]], CommandOutput]
160
+
161
+
162
+ def run_command(ctx: CLIContext, *, command: str, fn: CommandFn) -> None:
163
+ started = time.time()
164
+ warnings: list[str] = []
165
+ try:
166
+ out = fn(ctx, warnings)
167
+
168
+ rate_limit = out.rate_limit
169
+ if rate_limit is None and out.api_called and ctx._client is not None:
170
+ rate_limit = ctx._client.rate_limits.snapshot()
171
+
172
+ # Use provided context or create minimal one from command name
173
+ cmd_context = out.context or CommandContext(name=command)
174
+
175
+ result = build_result(
176
+ ok=True,
177
+ command=cmd_context,
178
+ started_at=started,
179
+ data=out.data,
180
+ artifacts=out.artifacts,
181
+ warnings=(out.warnings or warnings),
182
+ profile=ctx.profile,
183
+ rate_limit=rate_limit,
184
+ pagination=out.pagination,
185
+ resolved=out.resolved,
186
+ columns=out.columns,
187
+ summary=out.summary,
188
+ )
189
+ emit_result(ctx, result)
190
+ raise click.exceptions.Exit(out.exit_code)
191
+ except click.exceptions.Exit:
192
+ raise
193
+ except Exception as exc:
194
+ normalized = normalize_exception(exc, verbosity=ctx.verbosity)
195
+ code = exit_code_for_exception(normalized)
196
+ rate_limit = None
197
+ if ctx._client is not None:
198
+ try:
199
+ rate_limit = ctx._client.rate_limits.snapshot()
200
+ except Exception:
201
+ rate_limit = None
202
+
203
+ # Create minimal context for error case
204
+ cmd_context = CommandContext(name=command)
205
+
206
+ result = build_result(
207
+ ok=False,
208
+ command=cmd_context,
209
+ started_at=started,
210
+ data=None,
211
+ artifacts=None,
212
+ warnings=warnings,
213
+ profile=ctx.profile,
214
+ rate_limit=rate_limit,
215
+ error=error_info_for_exception(normalized, verbosity=ctx.verbosity),
216
+ )
217
+ emit_result(ctx, result)
218
+ raise click.exceptions.Exit(code) from exc
@@ -0,0 +1,65 @@
1
+ """
2
+ Standard serialization patterns for CLI commands.
3
+
4
+ All CLI commands should use these helpers to ensure consistent JSON output.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel
13
+
14
+
15
+ def serialize_model_for_cli(model: BaseModel) -> dict[str, Any]:
16
+ """
17
+ Serialize a Pydantic model for CLI JSON output.
18
+
19
+ This is the standard serialization pattern for all CLI commands.
20
+ It ensures:
21
+ 1. JSON-safe types (datetime → ISO string, etc.)
22
+ 2. Consistent null handling (None values excluded)
23
+ 3. Field aliases used (camelCase for API compatibility)
24
+
25
+ Args:
26
+ model: Any Pydantic model to serialize
27
+
28
+ Returns:
29
+ Dictionary suitable for JSON output
30
+
31
+ Example:
32
+ >>> from affinity.models.entities import Person
33
+ >>> person = Person(id=123, first_name="John", last_name="Doe", emails=[])
34
+ >>> result = serialize_model_for_cli(person)
35
+ >>> result['id']
36
+ 123
37
+ >>> result['firstName']
38
+ 'John'
39
+ """
40
+ return model.model_dump(by_alias=True, mode="json", exclude_none=True)
41
+
42
+
43
+ def serialize_models_for_cli(models: Sequence[BaseModel]) -> list[dict[str, Any]]:
44
+ """
45
+ Serialize a sequence of Pydantic models for CLI JSON output.
46
+
47
+ Args:
48
+ models: Sequence of Pydantic models
49
+
50
+ Returns:
51
+ List of dictionaries suitable for JSON output
52
+
53
+ Example:
54
+ >>> from affinity.models.entities import Person
55
+ >>> people = [
56
+ ... Person(id=1, first_name="Alice", last_name="Smith", emails=[]),
57
+ ... Person(id=2, first_name="Bob", last_name="Jones", emails=[])
58
+ ... ]
59
+ >>> results = serialize_models_for_cli(people)
60
+ >>> len(results)
61
+ 2
62
+ >>> results[0]['firstName']
63
+ 'Alice'
64
+ """
65
+ return [serialize_model_for_cli(model) for model in models]