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,222 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from affinity import Affinity
7
+ from affinity.exceptions import NotFoundError
8
+ from affinity.models.entities import AffinityList, FieldMetadata, SavedView
9
+ from affinity.types import ListId, SavedViewId
10
+
11
+ from .errors import CLIError
12
+
13
+ if TYPE_CHECKING:
14
+ from .session_cache import SessionCache
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class ResolvedList:
19
+ list: AffinityList
20
+ resolved: dict[str, Any]
21
+
22
+
23
+ def _looks_int(value: str) -> bool:
24
+ return value.isdigit()
25
+
26
+
27
+ def resolve_list_selector(
28
+ *,
29
+ client: Affinity,
30
+ selector: str,
31
+ cache: SessionCache | None = None,
32
+ ) -> ResolvedList:
33
+ """Resolve list by name/ID with optional session cache support."""
34
+ selector = selector.strip()
35
+
36
+ # ID lookups don't benefit from name resolution cache
37
+ if _looks_int(selector):
38
+ list_id = ListId(int(selector))
39
+ lst = client.lists.get(list_id)
40
+ return ResolvedList(list=lst, resolved={"list": {"input": selector, "listId": int(lst.id)}})
41
+
42
+ cache_key = f"list_resolve_{selector.lower()}_any"
43
+
44
+ if cache and cache.enabled:
45
+ cached = cache.get(cache_key, AffinityList)
46
+ if cached is not None:
47
+ return ResolvedList(
48
+ list=cached,
49
+ resolved={"list": {"input": selector, "listId": int(cached.id), "cached": True}},
50
+ )
51
+
52
+ matches = client.lists.resolve_all(name=selector)
53
+ if not matches:
54
+ raise CLIError(
55
+ f'List not found: "{selector}"',
56
+ exit_code=4,
57
+ error_type="not_found",
58
+ details={"selector": selector},
59
+ )
60
+ if len(matches) > 1:
61
+ raise CLIError(
62
+ f'Ambiguous list name: "{selector}" ({len(matches)} matches)',
63
+ exit_code=2,
64
+ error_type="ambiguous_resolution",
65
+ details={
66
+ "selector": selector,
67
+ "matches": [
68
+ {"listId": int(m.id), "name": m.name, "type": m.type} for m in matches[:20]
69
+ ],
70
+ },
71
+ )
72
+
73
+ lst = matches[0]
74
+
75
+ if cache and cache.enabled:
76
+ cache.set(cache_key, lst)
77
+
78
+ return ResolvedList(list=lst, resolved={"list": {"input": selector, "listId": int(lst.id)}})
79
+
80
+
81
+ def resolve_saved_view(
82
+ *,
83
+ client: Affinity,
84
+ list_id: ListId,
85
+ selector: str,
86
+ cache: SessionCache | None = None,
87
+ ) -> tuple[SavedView, dict[str, Any]]:
88
+ selector = selector.strip()
89
+ if _looks_int(selector):
90
+ view_id = SavedViewId(int(selector))
91
+ try:
92
+ v = client.lists.get_saved_view(list_id, view_id)
93
+ except NotFoundError as exc:
94
+ raise CLIError(
95
+ f"Saved view not found: {selector}",
96
+ exit_code=4,
97
+ error_type="not_found",
98
+ details={"listId": int(list_id), "selector": selector},
99
+ ) from exc
100
+ return v, {
101
+ "savedView": {
102
+ "input": selector,
103
+ "savedViewId": int(v.id),
104
+ "name": v.name,
105
+ }
106
+ }
107
+
108
+ views = list_all_saved_views(client=client, list_id=list_id, cache=cache)
109
+ exact = [v for v in views if v.name.lower() == selector.lower()]
110
+ if not exact:
111
+ raise CLIError(
112
+ f'Saved view not found: "{selector}"',
113
+ exit_code=4,
114
+ error_type="not_found",
115
+ details={"listId": int(list_id), "selector": selector},
116
+ )
117
+ if len(exact) > 1:
118
+ raise CLIError(
119
+ f'Ambiguous saved view name: "{selector}"',
120
+ exit_code=2,
121
+ error_type="ambiguous_resolution",
122
+ details={
123
+ "listId": int(list_id),
124
+ "selector": selector,
125
+ "matches": [{"savedViewId": int(v.id), "name": v.name} for v in exact[:20]],
126
+ },
127
+ )
128
+ v = exact[0]
129
+ return v, {"savedView": {"input": selector, "savedViewId": int(v.id), "name": v.name}}
130
+
131
+
132
+ def list_all_saved_views(
133
+ *,
134
+ client: Affinity,
135
+ list_id: ListId,
136
+ cache: SessionCache | None = None,
137
+ ) -> list[SavedView]:
138
+ """Get saved views for a list with optional session cache support.
139
+
140
+ Note: This caches the full list of saved views. If the fetch is
141
+ interrupted mid-pagination, the cache won't be populated.
142
+ """
143
+ cache_key = f"saved_views_{list_id}"
144
+
145
+ if cache and cache.enabled:
146
+ cached = cache.get_list(cache_key, SavedView)
147
+ if cached is not None:
148
+ return cached
149
+
150
+ # Eagerly evaluate paginated iterator to cache complete list
151
+ # Note: If pagination is interrupted (e.g., network error), no partial
152
+ # result is cached - the next call will retry from scratch
153
+ views = list(client.lists.saved_views_all(list_id))
154
+
155
+ if cache and cache.enabled:
156
+ cache.set(cache_key, views)
157
+
158
+ return views
159
+
160
+
161
+ def list_fields_for_list(
162
+ *,
163
+ client: Affinity,
164
+ list_id: ListId,
165
+ cache: SessionCache | None = None,
166
+ ) -> list[FieldMetadata]:
167
+ """Get list fields with optional session cache support."""
168
+ cache_key = f"list_fields_{list_id}"
169
+
170
+ if cache and cache.enabled:
171
+ cached = cache.get_list(cache_key, FieldMetadata)
172
+ if cached is not None:
173
+ return cached
174
+
175
+ fields = client.lists.get_fields(list_id)
176
+
177
+ if cache and cache.enabled:
178
+ cache.set(cache_key, fields)
179
+
180
+ return fields
181
+
182
+
183
+ def get_person_fields(
184
+ *,
185
+ client: Affinity,
186
+ cache: SessionCache | None = None,
187
+ ) -> list[FieldMetadata]:
188
+ """Get global person fields with optional session cache support."""
189
+ cache_key = "person_fields_global"
190
+
191
+ if cache and cache.enabled:
192
+ cached = cache.get_list(cache_key, FieldMetadata)
193
+ if cached is not None:
194
+ return cached
195
+
196
+ fields = client.persons.get_fields()
197
+
198
+ if cache and cache.enabled:
199
+ cache.set(cache_key, fields)
200
+
201
+ return fields
202
+
203
+
204
+ def get_company_fields(
205
+ *,
206
+ client: Affinity,
207
+ cache: SessionCache | None = None,
208
+ ) -> list[FieldMetadata]:
209
+ """Get global company fields with optional session cache support."""
210
+ cache_key = "company_fields_global"
211
+
212
+ if cache and cache.enabled:
213
+ cached = cache.get_list(cache_key, FieldMetadata)
214
+ if cached is not None:
215
+ return cached
216
+
217
+ fields = client.companies.get_fields()
218
+
219
+ if cache and cache.enabled:
220
+ cache.set(cache_key, fields)
221
+
222
+ return fields
@@ -0,0 +1,249 @@
1
+ """
2
+ Standard resolver types for CLI commands.
3
+
4
+ These dataclasses provide consistent structure for resolved metadata,
5
+ ensuring all CLI commands follow the same patterns when resolving
6
+ user inputs (IDs, URLs, emails, names, etc.) to API parameters.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import asdict, dataclass
12
+ from typing import Any, Literal
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class ResolvedEntity:
17
+ """
18
+ Standard structure for entity resolution metadata.
19
+
20
+ This class represents how a user's input selector (ID, URL, email, name)
21
+ was resolved to an entity ID for API calls.
22
+
23
+ Attributes:
24
+ input: The original user input (e.g., "john@example.com", "123", "acme.com")
25
+ entity_id: The resolved numeric entity ID
26
+ entity_type: Type of entity (person, company, opportunity, list)
27
+ source: How the input was resolved (id, url, email, name, domain)
28
+ canonical_url: Optional canonical Affinity URL for the entity
29
+
30
+ Example:
31
+ >>> resolved = ResolvedEntity(
32
+ ... input="john@example.com",
33
+ ... entity_id=12345,
34
+ ... entity_type="person",
35
+ ... source="email",
36
+ ... canonical_url="https://app.affinity.co/persons/12345"
37
+ ... )
38
+ >>> resolved.to_dict()
39
+ {
40
+ 'input': 'john@example.com',
41
+ 'personId': 12345,
42
+ 'source': 'email',
43
+ 'canonicalUrl': 'https://app.affinity.co/persons/12345'
44
+ }
45
+ """
46
+
47
+ input: str
48
+ entity_id: int
49
+ entity_type: Literal["person", "company", "opportunity", "list"]
50
+ source: Literal["id", "url", "email", "name", "domain"]
51
+ canonical_url: str | None = None
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ """
55
+ Convert to dict for resolved metadata.
56
+
57
+ Returns a dictionary suitable for inclusion in CommandOutput.resolved,
58
+ with entity_id renamed to {entityType}Id (e.g., personId, companyId).
59
+ """
60
+ data = asdict(self)
61
+ # Rename entity_id to {entityType}Id
62
+ entity_id = data.pop("entity_id")
63
+ data[f"{self.entity_type}Id"] = entity_id
64
+ # Remove entity_type from output (it's redundant with the key name)
65
+ data.pop("entity_type", None)
66
+ # Convert snake_case to camelCase for canonical_url
67
+ if "canonical_url" in data and data["canonical_url"] is not None:
68
+ data["canonicalUrl"] = data.pop("canonical_url")
69
+ elif "canonical_url" in data:
70
+ data.pop("canonical_url")
71
+ return data
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class ResolvedFieldSelection:
76
+ """
77
+ Field selection resolution metadata.
78
+
79
+ This class represents which fields were requested by the user
80
+ through --field-id or --field-type flags.
81
+
82
+ Attributes:
83
+ field_ids: List of specific field IDs requested (e.g., ["field-123", "field-456"])
84
+ field_types: List of field types requested (e.g., ["global", "enriched"])
85
+
86
+ Example:
87
+ >>> resolved = ResolvedFieldSelection(
88
+ ... field_ids=["field-123"],
89
+ ... field_types=["global", "enriched"]
90
+ ... )
91
+ >>> resolved.to_dict()
92
+ {'fieldIds': ['field-123'], 'fieldTypes': ['global', 'enriched']}
93
+ """
94
+
95
+ field_ids: list[str] | None = None
96
+ field_types: list[str] | None = None
97
+
98
+ def to_dict(self) -> dict[str, Any]:
99
+ """
100
+ Convert to dict, excluding None values.
101
+
102
+ Returns a dictionary with camelCase field names, omitting any
103
+ fields that are None.
104
+ """
105
+ result = {}
106
+ if self.field_ids is not None:
107
+ result["fieldIds"] = self.field_ids
108
+ if self.field_types is not None:
109
+ result["fieldTypes"] = self.field_types
110
+ return result
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class ResolvedList:
115
+ """
116
+ List resolution metadata.
117
+
118
+ This class represents how a user's list selector (ID, URL, name)
119
+ was resolved to a list ID.
120
+
121
+ Attributes:
122
+ input: The original user input
123
+ list_id: The resolved numeric list ID
124
+ source: How the input was resolved (id, url, name)
125
+
126
+ Example:
127
+ >>> resolved = ResolvedList(
128
+ ... input="Sales Pipeline",
129
+ ... list_id=789,
130
+ ... source="name"
131
+ ... )
132
+ >>> resolved.to_dict()
133
+ {'input': 'Sales Pipeline', 'listId': 789, 'source': 'name'}
134
+ """
135
+
136
+ input: str
137
+ list_id: int
138
+ source: Literal["id", "url", "name"]
139
+
140
+ def to_dict(self) -> dict[str, Any]:
141
+ """Convert to dict with camelCase field names."""
142
+ return {
143
+ "input": self.input,
144
+ "listId": self.list_id,
145
+ "source": self.source,
146
+ }
147
+
148
+
149
+ @dataclass(frozen=True, slots=True)
150
+ class ResolvedSavedView:
151
+ """
152
+ Saved view resolution metadata.
153
+
154
+ This class represents how a user's saved view selector was resolved
155
+ to a saved view ID.
156
+
157
+ Attributes:
158
+ input: The original user input
159
+ saved_view_id: The resolved numeric saved view ID
160
+ name: The name of the saved view
161
+
162
+ Example:
163
+ >>> resolved = ResolvedSavedView(
164
+ ... input="Active Deals",
165
+ ... saved_view_id=456,
166
+ ... name="Active Deals"
167
+ ... )
168
+ >>> resolved.to_dict()
169
+ {'input': 'Active Deals', 'savedViewId': 456, 'name': 'Active Deals'}
170
+ """
171
+
172
+ input: str
173
+ saved_view_id: int
174
+ name: str
175
+
176
+ def to_dict(self) -> dict[str, Any]:
177
+ """Convert to dict with camelCase field names."""
178
+ return {
179
+ "input": self.input,
180
+ "savedViewId": self.saved_view_id,
181
+ "name": self.name,
182
+ }
183
+
184
+
185
+ def build_resolved_metadata(
186
+ *,
187
+ entity: ResolvedEntity | None = None,
188
+ list_resolution: ResolvedList | None = None,
189
+ saved_view: ResolvedSavedView | None = None,
190
+ field_selection: ResolvedFieldSelection | None = None,
191
+ expand: list[str] | None = None,
192
+ ) -> dict[str, Any]:
193
+ """
194
+ Build a complete resolved metadata dict for CommandOutput.
195
+
196
+ This is a convenience function to construct the resolved metadata
197
+ dictionary in a consistent way across all commands.
198
+
199
+ Args:
200
+ entity: Entity resolution metadata (person, company, opportunity)
201
+ list_resolution: List resolution metadata
202
+ saved_view: Saved view resolution metadata
203
+ field_selection: Field selection metadata
204
+ expand: List of expansion options used
205
+
206
+ Returns:
207
+ Dictionary suitable for CommandOutput(resolved=...)
208
+
209
+ Example:
210
+ >>> person = ResolvedEntity(
211
+ ... input="john@example.com",
212
+ ... entity_id=12345,
213
+ ... entity_type="person",
214
+ ... source="email"
215
+ ... )
216
+ >>> fields = ResolvedFieldSelection(field_types=["global"])
217
+ >>> resolved = build_resolved_metadata(
218
+ ... entity=person,
219
+ ... field_selection=fields,
220
+ ... expand=["lists"]
221
+ ... )
222
+ >>> resolved
223
+ {
224
+ 'person': {'input': 'john@example.com', 'personId': 12345, 'source': 'email'},
225
+ 'fieldSelection': {'fieldTypes': ['global']},
226
+ 'expand': ['lists']
227
+ }
228
+ """
229
+ result: dict[str, Any] = {}
230
+
231
+ if entity is not None:
232
+ # Use entity_type as the key (e.g., "person", "company")
233
+ result[entity.entity_type] = entity.to_dict()
234
+
235
+ if list_resolution is not None:
236
+ result["list"] = list_resolution.to_dict()
237
+
238
+ if saved_view is not None:
239
+ result["savedView"] = saved_view.to_dict()
240
+
241
+ if field_selection is not None:
242
+ field_dict = field_selection.to_dict()
243
+ if field_dict: # Only include if not empty
244
+ result["fieldSelection"] = field_dict
245
+
246
+ if expand is not None and expand:
247
+ result["expand"] = expand
248
+
249
+ return result