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,413 @@
1
+ from __future__ import annotations
2
+
3
+ from affinity.models.entities import FieldCreate, FieldMetadata, FieldValueChange
4
+ from affinity.models.types import EntityType, FieldValueType
5
+ from affinity.types import (
6
+ CompanyId,
7
+ FieldId,
8
+ FieldValueChangeAction,
9
+ ListEntryId,
10
+ ListId,
11
+ OpportunityId,
12
+ PersonId,
13
+ )
14
+
15
+ from ..click_compat import RichCommand, RichGroup, click
16
+ from ..context import CLIContext
17
+ from ..decorators import category, destructive
18
+ from ..errors import CLIError
19
+ from ..options import output_options
20
+ from ..resolve import resolve_list_selector
21
+ from ..results import CommandContext
22
+ from ..runner import CommandOutput, run_command
23
+ from ..serialization import serialize_model_for_cli
24
+ from ._v1_parsing import parse_choice
25
+
26
+
27
+ @click.group(name="field", cls=RichGroup)
28
+ def field_group() -> None:
29
+ """Field commands."""
30
+
31
+
32
+ _ENTITY_TYPE_MAP = {
33
+ "person": EntityType.PERSON,
34
+ "people": EntityType.PERSON,
35
+ "company": EntityType.ORGANIZATION,
36
+ "organization": EntityType.ORGANIZATION,
37
+ "opportunity": EntityType.OPPORTUNITY,
38
+ }
39
+
40
+ _VALUE_TYPE_MAP = {ft.value: ft for ft in FieldValueType}
41
+
42
+ _ACTION_TYPE_MAP = {
43
+ "create": FieldValueChangeAction.CREATE,
44
+ "delete": FieldValueChangeAction.DELETE,
45
+ "update": FieldValueChangeAction.UPDATE,
46
+ }
47
+
48
+ _ACTION_TYPE_NAMES = {
49
+ FieldValueChangeAction.CREATE: "create",
50
+ FieldValueChangeAction.DELETE: "delete",
51
+ FieldValueChangeAction.UPDATE: "update",
52
+ }
53
+
54
+
55
+ def _field_payload(field: FieldMetadata) -> dict[str, object]:
56
+ return serialize_model_for_cli(field)
57
+
58
+
59
+ def _field_value_change_payload(item: FieldValueChange) -> dict[str, object]:
60
+ """Convert FieldValueChange to CLI output format."""
61
+ # Display enum name instead of integer (consistent with interaction_cmds.py)
62
+ action_name = _ACTION_TYPE_NAMES.get(
63
+ FieldValueChangeAction(item.action_type),
64
+ str(item.action_type),
65
+ )
66
+
67
+ # Flatten changer name for table display
68
+ changer_name = None
69
+ if item.changer:
70
+ first = item.changer.first_name or ""
71
+ last = item.changer.last_name or ""
72
+ changer_name = f"{first} {last}".strip() or None
73
+
74
+ return {
75
+ "id": int(item.id),
76
+ "fieldId": str(item.field_id),
77
+ "entityId": item.entity_id,
78
+ "listEntryId": int(item.list_entry_id) if item.list_entry_id else None,
79
+ "actionType": action_name,
80
+ "value": item.value,
81
+ "changedAt": item.changed_at,
82
+ "changerName": changer_name,
83
+ "changer": serialize_model_for_cli(item.changer) if item.changer else None,
84
+ }
85
+
86
+
87
+ def _validate_exactly_one_selector(
88
+ person_id: int | None,
89
+ company_id: int | None,
90
+ opportunity_id: int | None,
91
+ list_entry_id: int | None,
92
+ ) -> None:
93
+ """Validate that exactly one entity selector is provided."""
94
+ selectors = {
95
+ "--person-id": person_id,
96
+ "--company-id": company_id,
97
+ "--opportunity-id": opportunity_id,
98
+ "--list-entry-id": list_entry_id,
99
+ }
100
+ provided = [name for name, value in selectors.items() if value is not None]
101
+
102
+ if len(provided) == 1:
103
+ return
104
+
105
+ if len(provided) == 0:
106
+ raise CLIError(
107
+ "Exactly one entity selector is required: "
108
+ "--person-id, --company-id, --opportunity-id, or --list-entry-id.\n"
109
+ "Example: xaffinity field history field-123 --person-id 456",
110
+ error_type="usage_error",
111
+ exit_code=2,
112
+ )
113
+
114
+ raise CLIError(
115
+ f"Only one entity selector allowed, but got {len(provided)}: {', '.join(provided)}",
116
+ error_type="usage_error",
117
+ exit_code=2,
118
+ )
119
+
120
+
121
+ @category("read")
122
+ @field_group.command(name="ls", cls=RichCommand)
123
+ @click.option(
124
+ "--list-id",
125
+ type=str,
126
+ default=None,
127
+ help="Filter by list (ID or name).",
128
+ )
129
+ @click.option(
130
+ "--entity-type",
131
+ type=click.Choice(sorted(_ENTITY_TYPE_MAP.keys())),
132
+ default=None,
133
+ help="Filter by entity type (person/company/opportunity).",
134
+ )
135
+ @output_options
136
+ @click.pass_obj
137
+ def field_ls(
138
+ ctx: CLIContext,
139
+ *,
140
+ list_id: str | None,
141
+ entity_type: str | None,
142
+ ) -> None:
143
+ """List fields with dropdown options."""
144
+
145
+ def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
146
+ client = ctx.get_client(warnings=warnings)
147
+ cache = ctx.session_cache
148
+ parsed_type = parse_choice(entity_type, _ENTITY_TYPE_MAP, label="entity type")
149
+
150
+ # Resolve list selector (accepts name or ID)
151
+ resolved_list_id: int | None = None
152
+ ctx_resolved: dict[str, str] | None = None
153
+ if list_id is not None:
154
+ resolved = resolve_list_selector(client=client, selector=list_id, cache=cache)
155
+ resolved_list_id = int(resolved.list.id)
156
+ # Only include resolved name if different from input (i.e., name was provided)
157
+ if resolved.list.name and resolved.list.name != list_id:
158
+ ctx_resolved = {"listId": resolved.list.name}
159
+
160
+ # Build cache key from resolved ID (not input string) for consistency
161
+ cache_key = f"fields_v1_list{resolved_list_id or 'all'}_type{entity_type or 'all'}"
162
+
163
+ # Check session cache first
164
+ fields: list[FieldMetadata] | None = None
165
+ api_called = False
166
+ if cache.enabled:
167
+ fields = cache.get_list(cache_key, FieldMetadata)
168
+
169
+ if fields is None:
170
+ fields = client.fields.list(
171
+ list_id=ListId(resolved_list_id) if resolved_list_id is not None else None,
172
+ entity_type=parsed_type,
173
+ )
174
+ api_called = True
175
+ # Cache the result
176
+ if cache.enabled:
177
+ cache.set(cache_key, fields)
178
+
179
+ payload = [_field_payload(field) for field in fields]
180
+
181
+ # Build CommandContext
182
+ ctx_modifiers: dict[str, object] = {}
183
+ if resolved_list_id is not None:
184
+ ctx_modifiers["listId"] = resolved_list_id
185
+ if entity_type:
186
+ ctx_modifiers["entityType"] = entity_type
187
+
188
+ cmd_context = CommandContext(
189
+ name="field ls",
190
+ inputs={},
191
+ modifiers=ctx_modifiers,
192
+ resolved=ctx_resolved,
193
+ )
194
+
195
+ return CommandOutput(data={"fields": payload}, context=cmd_context, api_called=api_called)
196
+
197
+ run_command(ctx, command="field ls", fn=fn)
198
+
199
+
200
+ @category("write")
201
+ @field_group.command(name="create", cls=RichCommand)
202
+ @click.option("--name", required=True, help="Field name.")
203
+ @click.option(
204
+ "--entity-type",
205
+ type=click.Choice(sorted(_ENTITY_TYPE_MAP.keys())),
206
+ required=True,
207
+ help="Entity type (person/company/opportunity).",
208
+ )
209
+ @click.option(
210
+ "--value-type",
211
+ type=click.Choice(sorted(_VALUE_TYPE_MAP.keys())),
212
+ required=True,
213
+ help="Field value type (e.g. text, dropdown, person, number).",
214
+ )
215
+ @click.option("--list-id", type=int, default=None, help="List id for list-specific field.")
216
+ @click.option("--allows-multiple", is_flag=True, help="Allow multiple values.")
217
+ @click.option("--list-specific", is_flag=True, help="Mark as list-specific.")
218
+ @click.option("--required", is_flag=True, help="Mark as required.")
219
+ @output_options
220
+ @click.pass_obj
221
+ def field_create(
222
+ ctx: CLIContext,
223
+ *,
224
+ name: str,
225
+ entity_type: str,
226
+ value_type: str,
227
+ list_id: int | None,
228
+ allows_multiple: bool,
229
+ list_specific: bool,
230
+ required: bool,
231
+ ) -> None:
232
+ """Create a field."""
233
+
234
+ def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
235
+ parsed_entity_type = parse_choice(entity_type, _ENTITY_TYPE_MAP, label="entity type")
236
+ parsed_value_type = parse_choice(value_type, _VALUE_TYPE_MAP, label="value type")
237
+ if parsed_entity_type is None or parsed_value_type is None:
238
+ raise CLIError("Missing required field options.", error_type="usage_error", exit_code=2)
239
+ client = ctx.get_client(warnings=warnings)
240
+ created = client.fields.create(
241
+ FieldCreate(
242
+ name=name,
243
+ entity_type=parsed_entity_type,
244
+ value_type=parsed_value_type,
245
+ list_id=ListId(list_id) if list_id is not None else None,
246
+ allows_multiple=allows_multiple,
247
+ is_list_specific=list_specific,
248
+ is_required=required,
249
+ )
250
+ )
251
+
252
+ # Invalidate field-related caches
253
+ cache = ctx.session_cache
254
+ cache.invalidate_prefix("list_fields_")
255
+ cache.invalidate_prefix("person_fields_")
256
+ cache.invalidate_prefix("company_fields_")
257
+ cache.invalidate_prefix("fields_v1_")
258
+
259
+ payload = _field_payload(created)
260
+
261
+ # Build CommandContext
262
+ ctx_modifiers: dict[str, object] = {
263
+ "entityType": entity_type,
264
+ "valueType": value_type,
265
+ }
266
+ if list_id is not None:
267
+ ctx_modifiers["listId"] = list_id
268
+ if allows_multiple:
269
+ ctx_modifiers["allowsMultiple"] = True
270
+ if list_specific:
271
+ ctx_modifiers["listSpecific"] = True
272
+ if required:
273
+ ctx_modifiers["required"] = True
274
+
275
+ cmd_context = CommandContext(
276
+ name="field create",
277
+ inputs={"name": name},
278
+ modifiers=ctx_modifiers,
279
+ )
280
+
281
+ return CommandOutput(data={"field": payload}, context=cmd_context, api_called=True)
282
+
283
+ run_command(ctx, command="field create", fn=fn)
284
+
285
+
286
+ @category("write")
287
+ @destructive
288
+ @field_group.command(name="delete", cls=RichCommand)
289
+ @click.argument("field_id", type=str)
290
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
291
+ @output_options
292
+ @click.pass_obj
293
+ def field_delete(ctx: CLIContext, field_id: str, yes: bool) -> None:
294
+ """Delete a field."""
295
+ if not yes:
296
+ click.confirm(f"Delete field {field_id}?", abort=True)
297
+
298
+ def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
299
+ client = ctx.get_client(warnings=warnings)
300
+ success = client.fields.delete(FieldId(field_id))
301
+
302
+ # Invalidate field-related caches
303
+ cache = ctx.session_cache
304
+ cache.invalidate_prefix("list_fields_")
305
+ cache.invalidate_prefix("person_fields_")
306
+ cache.invalidate_prefix("company_fields_")
307
+ cache.invalidate_prefix("fields_v1_")
308
+
309
+ cmd_context = CommandContext(
310
+ name="field delete",
311
+ inputs={"fieldId": field_id},
312
+ modifiers={},
313
+ )
314
+
315
+ return CommandOutput(data={"success": success}, context=cmd_context, api_called=True)
316
+
317
+ run_command(ctx, command="field delete", fn=fn)
318
+
319
+
320
+ @category("read")
321
+ @field_group.command(name="history", cls=RichCommand)
322
+ @click.argument("field_id", type=str)
323
+ @click.option("--person-id", type=int, default=None, help="Filter by person ID.")
324
+ @click.option("--company-id", type=int, default=None, help="Filter by company ID.")
325
+ @click.option("--opportunity-id", type=int, default=None, help="Filter by opportunity ID.")
326
+ @click.option("--list-entry-id", type=int, default=None, help="Filter by list entry ID.")
327
+ @click.option(
328
+ "--action-type",
329
+ type=click.Choice(["create", "update", "delete"]),
330
+ default=None,
331
+ help="Filter by action type.",
332
+ )
333
+ @click.option(
334
+ "--max-results", "--limit", "-n", type=int, default=None, help="Limit number of results."
335
+ )
336
+ @output_options
337
+ @click.pass_obj
338
+ def field_history(
339
+ ctx: CLIContext,
340
+ *,
341
+ field_id: str,
342
+ person_id: int | None,
343
+ company_id: int | None,
344
+ opportunity_id: int | None,
345
+ list_entry_id: int | None,
346
+ action_type: str | None,
347
+ max_results: int | None,
348
+ ) -> None:
349
+ """Show field value change history.
350
+
351
+ FIELD_ID is the field identifier (e.g., 'field-123').
352
+ Use 'xaffinity field ls --list-id LIST' to find field IDs.
353
+
354
+ Exactly one entity selector is required.
355
+
356
+ Examples:
357
+
358
+ - `xaffinity field history field-123 --person-id 456`
359
+
360
+ - `xaffinity field history field-260415 --list-entry-id 789 --action-type update`
361
+
362
+ - `xaffinity field history field-123 --company-id 100 --max-results 10`
363
+ """
364
+
365
+ def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
366
+ _validate_exactly_one_selector(person_id, company_id, opportunity_id, list_entry_id)
367
+
368
+ client = ctx.get_client(warnings=warnings)
369
+ changes = client.field_value_changes.list(
370
+ field_id=FieldId(field_id),
371
+ person_id=PersonId(person_id) if person_id is not None else None,
372
+ company_id=CompanyId(company_id) if company_id is not None else None,
373
+ opportunity_id=OpportunityId(opportunity_id) if opportunity_id is not None else None,
374
+ list_entry_id=ListEntryId(list_entry_id) if list_entry_id is not None else None,
375
+ action_type=_ACTION_TYPE_MAP[action_type] if action_type else None,
376
+ )
377
+
378
+ # Apply client-side max_results limit
379
+ if max_results is not None:
380
+ changes = changes[:max_results]
381
+
382
+ payload = [_field_value_change_payload(item) for item in changes]
383
+
384
+ # Build CommandContext for richer output metadata
385
+ # Per spec: required params are inputs, optional params are modifiers
386
+ # fieldId and exactly one entity selector are required → both are inputs
387
+ inputs: dict[str, object] = {"fieldId": field_id}
388
+ if person_id is not None:
389
+ inputs["personId"] = person_id
390
+ elif company_id is not None:
391
+ inputs["companyId"] = company_id
392
+ elif opportunity_id is not None:
393
+ inputs["opportunityId"] = opportunity_id
394
+ elif list_entry_id is not None:
395
+ inputs["listEntryId"] = list_entry_id
396
+
397
+ modifiers: dict[str, object] = {}
398
+ if action_type is not None:
399
+ modifiers["actionType"] = action_type
400
+ if max_results is not None:
401
+ modifiers["maxResults"] = max_results
402
+
403
+ cmd_context = CommandContext(
404
+ name="field history",
405
+ inputs=inputs,
406
+ modifiers=modifiers,
407
+ )
408
+
409
+ return CommandOutput(
410
+ data={"fieldValueChanges": payload}, context=cmd_context, api_called=True
411
+ )
412
+
413
+ run_command(ctx, command="field history", fn=fn)