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/results.py
ADDED
|
@@ -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]
|