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,248 @@
1
+ """Filter operators for query WHERE clauses.
2
+
3
+ This module provides extended filter operators beyond what the SDK supports.
4
+ It is CLI-only and NOT part of the public SDK API.
5
+
6
+ Uses the shared compare module (affinity/compare.py) for comparison logic,
7
+ ensuring consistent behavior between SDK filter and Query tool.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ from ...compare import compare_values
16
+ from .dates import parse_date_value
17
+ from .exceptions import QueryValidationError
18
+ from .models import WhereClause
19
+
20
+ # =============================================================================
21
+ # Operator Definitions
22
+ # =============================================================================
23
+
24
+ # Type alias for operator functions
25
+ OperatorFunc = Callable[[Any, Any], bool]
26
+
27
+
28
+ def _make_operator(op_name: str) -> OperatorFunc:
29
+ """Create an operator function that delegates to compare_values().
30
+
31
+ This is a factory function that creates operator functions for the OPERATORS
32
+ registry. Each function wraps compare_values() with the appropriate operator name.
33
+ """
34
+
35
+ def op_func(field_value: Any, target: Any) -> bool:
36
+ return compare_values(field_value, target, op_name)
37
+
38
+ return op_func
39
+
40
+
41
+ # Operator registry - all operators delegate to compare_values() from the shared module
42
+ # This ensures consistent comparison behavior between SDK filter and Query tool
43
+ OPERATORS: dict[str, OperatorFunc] = {
44
+ "eq": _make_operator("eq"),
45
+ "neq": _make_operator("neq"),
46
+ "gt": _make_operator("gt"),
47
+ "gte": _make_operator("gte"),
48
+ "lt": _make_operator("lt"),
49
+ "lte": _make_operator("lte"),
50
+ "contains": _make_operator("contains"),
51
+ "starts_with": _make_operator("starts_with"),
52
+ "ends_with": _make_operator("ends_with"), # New: was missing in query tool
53
+ "in": _make_operator("in"),
54
+ "between": _make_operator("between"),
55
+ "is_null": _make_operator("is_null"),
56
+ "is_not_null": _make_operator("is_not_null"),
57
+ "is_empty": _make_operator("is_empty"), # New: was missing in query tool
58
+ "contains_any": _make_operator("contains_any"),
59
+ "contains_all": _make_operator("contains_all"),
60
+ "has_any": _make_operator("has_any"),
61
+ "has_all": _make_operator("has_all"),
62
+ }
63
+
64
+
65
+ # =============================================================================
66
+ # Field Path Resolution
67
+ # =============================================================================
68
+
69
+
70
+ def resolve_field_path(record: dict[str, Any], path: str) -> Any:
71
+ """Resolve a field path to a value.
72
+
73
+ Supports:
74
+ - Simple fields: "firstName"
75
+ - Nested fields: "address.city"
76
+ - Array fields: "emails[0]"
77
+ - Special fields: "fields.Status" for list entry fields
78
+
79
+ Args:
80
+ record: The record to extract value from
81
+ path: The field path
82
+
83
+ Returns:
84
+ The resolved value, or None if not found
85
+ """
86
+ if not path:
87
+ return None
88
+
89
+ parts = _parse_field_path(path)
90
+ current: Any = record
91
+
92
+ for part in parts:
93
+ if current is None:
94
+ return None
95
+
96
+ if isinstance(part, int):
97
+ # Array index
98
+ if isinstance(current, list) and 0 <= part < len(current):
99
+ current = current[part]
100
+ else:
101
+ return None
102
+ elif isinstance(current, dict):
103
+ # Object property
104
+ current = current.get(part)
105
+ else:
106
+ return None
107
+
108
+ return current
109
+
110
+
111
+ def _parse_field_path(path: str) -> list[str | int]:
112
+ """Parse a field path into parts.
113
+
114
+ Examples:
115
+ "firstName" -> ["firstName"]
116
+ "address.city" -> ["address", "city"]
117
+ "emails[0]" -> ["emails", 0]
118
+ "fields.Status" -> ["fields", "Status"]
119
+ """
120
+ parts: list[str | int] = []
121
+ current = ""
122
+ i = 0
123
+
124
+ while i < len(path):
125
+ char = path[i]
126
+
127
+ if char == ".":
128
+ if current:
129
+ parts.append(current)
130
+ current = ""
131
+ i += 1
132
+ elif char == "[":
133
+ if current:
134
+ parts.append(current)
135
+ current = ""
136
+ # Find closing bracket
137
+ end = path.find("]", i)
138
+ if end == -1:
139
+ raise QueryValidationError(f"Unclosed bracket in field path: {path}")
140
+ index_str = path[i + 1 : end]
141
+ try:
142
+ parts.append(int(index_str))
143
+ except ValueError:
144
+ # Non-numeric index, treat as string
145
+ parts.append(index_str)
146
+ i = end + 1
147
+ else:
148
+ current += char
149
+ i += 1
150
+
151
+ if current:
152
+ parts.append(current)
153
+
154
+ return parts
155
+
156
+
157
+ # =============================================================================
158
+ # Filter Compilation
159
+ # =============================================================================
160
+
161
+
162
+ def compile_filter(where: WhereClause) -> Callable[[dict[str, Any]], bool]:
163
+ """Compile a WHERE clause into a filter function.
164
+
165
+ Args:
166
+ where: The WHERE clause to compile
167
+
168
+ Returns:
169
+ A function that takes a record and returns True if it matches
170
+ """
171
+ # Single condition
172
+ if where.op is not None:
173
+ return _compile_condition(where)
174
+
175
+ # Compound conditions
176
+ if where.and_ is not None:
177
+ filters = [compile_filter(clause) for clause in where.and_]
178
+ return lambda record: all(f(record) for f in filters)
179
+
180
+ if where.or_ is not None:
181
+ filters = [compile_filter(clause) for clause in where.or_]
182
+ return lambda record: any(f(record) for f in filters)
183
+
184
+ if where.not_ is not None:
185
+ inner = compile_filter(where.not_)
186
+ return lambda record: not inner(record)
187
+
188
+ # Quantifiers (all, none) - placeholder, requires relationship fetching
189
+ if where.all_ is not None or where.none_ is not None:
190
+ # These require relationship data and are handled by executor
191
+ # For now, pass through
192
+ return lambda _: True
193
+
194
+ # Exists - placeholder, requires subquery execution
195
+ if where.exists_ is not None:
196
+ return lambda _: True
197
+
198
+ # No conditions - match all
199
+ return lambda _: True
200
+
201
+
202
+ def _compile_condition(where: WhereClause) -> Callable[[dict[str, Any]], bool]:
203
+ """Compile a single filter condition."""
204
+ if where.op is None:
205
+ return lambda _: True
206
+
207
+ op_func = OPERATORS.get(where.op)
208
+ if op_func is None:
209
+ raise QueryValidationError(f"Unknown operator: {where.op}")
210
+
211
+ path = where.path
212
+ value = where.value
213
+
214
+ # Parse date values if they look like relative dates
215
+ if value is not None and isinstance(value, str):
216
+ parsed_value = parse_date_value(value)
217
+ if parsed_value is not None:
218
+ value = parsed_value
219
+
220
+ # Handle _count pseudo-field
221
+ if path and path.endswith("._count"):
222
+ # This requires relationship counting, handled by executor
223
+ # Return a placeholder that always matches
224
+ return lambda _: True
225
+
226
+ def filter_func(record: dict[str, Any]) -> bool:
227
+ if path is None:
228
+ return True
229
+ field_value = resolve_field_path(record, path)
230
+ return op_func(field_value, value)
231
+
232
+ return filter_func
233
+
234
+
235
+ def matches(record: dict[str, Any], where: WhereClause | None) -> bool:
236
+ """Check if a record matches a WHERE clause.
237
+
238
+ Args:
239
+ record: The record to check
240
+ where: The WHERE clause, or None (matches all)
241
+
242
+ Returns:
243
+ True if the record matches
244
+ """
245
+ if where is None:
246
+ return True
247
+ filter_func = compile_filter(where)
248
+ return filter_func(record)
@@ -0,0 +1,333 @@
1
+ """Pydantic models for the query language.
2
+
3
+ These models define the structure of structured queries. They are CLI-only
4
+ and NOT part of the public SDK API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import TYPE_CHECKING, Any, Literal
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ if TYPE_CHECKING:
15
+ from ..results import ResultSummary
16
+
17
+ # =============================================================================
18
+ # Filter Condition Models
19
+ # =============================================================================
20
+
21
+
22
+ class FilterCondition(BaseModel):
23
+ """A single filter condition.
24
+
25
+ Examples:
26
+ {"path": "email", "op": "contains", "value": "@acme.com"}
27
+ {"path": "createdAt", "op": "gt", "value": "-30d"}
28
+ {"path": "companies._count", "op": "gte", "value": 2}
29
+ """
30
+
31
+ model_config = ConfigDict(extra="forbid")
32
+
33
+ path: str | None = None
34
+ expr: dict[str, Any] | None = None
35
+ op: Literal[
36
+ "eq",
37
+ "neq",
38
+ "gt",
39
+ "gte",
40
+ "lt",
41
+ "lte",
42
+ "contains",
43
+ "starts_with",
44
+ "in",
45
+ "between",
46
+ "is_null",
47
+ "is_not_null",
48
+ "contains_any",
49
+ "contains_all",
50
+ ]
51
+ value: Any = None
52
+
53
+
54
+ class QuantifierClause(BaseModel):
55
+ """Quantifier clause for 'all' or 'none' predicates.
56
+
57
+ Examples:
58
+ {"all": {"path": "interactions", "where": {"path": "type", "op": "eq", "value": "MEETING"}}}
59
+ """
60
+
61
+ model_config = ConfigDict(extra="forbid")
62
+
63
+ path: str
64
+ where: WhereClause
65
+
66
+
67
+ class ExistsClause(BaseModel):
68
+ """EXISTS subquery clause.
69
+
70
+ Examples:
71
+ {"exists": {"from": "interactions", "via": "personId", "where": {...}}}
72
+ """
73
+
74
+ model_config = ConfigDict(extra="forbid")
75
+
76
+ from_: str = Field(..., alias="from")
77
+ via: str | None = None
78
+ where: WhereClause | None = None
79
+
80
+
81
+ class WhereClause(BaseModel):
82
+ """WHERE clause supporting compound conditions.
83
+
84
+ Can be:
85
+ - A single condition: {"path": "x", "op": "eq", "value": "y"}
86
+ - Compound: {"and": [...]} or {"or": [...]}
87
+ - Negation: {"not": {...}}
88
+ - Quantifiers: {"all": {...}} or {"none": {...}}
89
+ - Existence: {"exists": {...}}
90
+ """
91
+
92
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
93
+
94
+ # Single condition fields
95
+ path: str | None = None
96
+ expr: dict[str, Any] | None = None
97
+ op: str | None = None
98
+ value: Any = None
99
+
100
+ # Compound conditions
101
+ and_: list[WhereClause] | None = Field(None, alias="and")
102
+ or_: list[WhereClause] | None = Field(None, alias="or")
103
+ not_: WhereClause | None = Field(None, alias="not")
104
+
105
+ # Quantifiers
106
+ all_: QuantifierClause | None = Field(None, alias="all")
107
+ none_: QuantifierClause | None = Field(None, alias="none")
108
+
109
+ # Existence
110
+ exists_: ExistsClause | None = Field(None, alias="exists")
111
+
112
+
113
+ # =============================================================================
114
+ # Aggregate Models
115
+ # =============================================================================
116
+
117
+
118
+ class AggregateFunc(BaseModel):
119
+ """Aggregate function definition.
120
+
121
+ Examples:
122
+ {"sum": "amount"}
123
+ {"count": true}
124
+ {"avg": "score"}
125
+ {"percentile": {"field": "amount", "p": 90}}
126
+ {"multiply": ["count", "avgAmount"]}
127
+ """
128
+
129
+ model_config = ConfigDict(extra="forbid")
130
+
131
+ # Simple aggregates
132
+ sum: str | None = None
133
+ avg: str | None = None
134
+ min: str | None = None
135
+ max: str | None = None
136
+ count: bool | str | None = None # True for count(*), str for count(field)
137
+
138
+ # Advanced aggregates
139
+ percentile: dict[str, Any] | None = None # {"field": "x", "p": 90}
140
+ first: str | None = None
141
+ last: str | None = None
142
+
143
+ # Expression aggregates (operate on other aggregates)
144
+ multiply: list[str | int | float] | None = None
145
+ divide: list[str | int | float] | None = None
146
+ add: list[str | int | float] | None = None
147
+ subtract: list[str | int | float] | None = None
148
+
149
+
150
+ class HavingClause(BaseModel):
151
+ """HAVING clause for filtering aggregated results.
152
+
153
+ Examples:
154
+ {"path": "totalAmount", "op": "gt", "value": 1000}
155
+ """
156
+
157
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
158
+
159
+ path: str | None = None
160
+ op: str | None = None
161
+ value: Any = None
162
+
163
+ and_: list[HavingClause] | None = Field(None, alias="and")
164
+ or_: list[HavingClause] | None = Field(None, alias="or")
165
+
166
+
167
+ # =============================================================================
168
+ # Order By Models
169
+ # =============================================================================
170
+
171
+
172
+ class OrderByClause(BaseModel):
173
+ """ORDER BY clause.
174
+
175
+ Examples:
176
+ {"field": "lastName", "direction": "asc"}
177
+ {"field": "createdAt", "direction": "desc"}
178
+ {"expr": {"daysUntil": "dueDate"}, "direction": "asc"}
179
+ """
180
+
181
+ model_config = ConfigDict(extra="forbid")
182
+
183
+ field: str | None = None
184
+ expr: dict[str, Any] | None = None
185
+ direction: Literal["asc", "desc"] = "asc"
186
+
187
+
188
+ # =============================================================================
189
+ # Subquery Models
190
+ # =============================================================================
191
+
192
+
193
+ class SubqueryDef(BaseModel):
194
+ """Named subquery definition.
195
+
196
+ Examples:
197
+ {
198
+ "recentDeals": {
199
+ "from": "opportunities",
200
+ "where": {"path": "closedAt", "op": "gt", "value": "-90d"}
201
+ }
202
+ }
203
+ """
204
+
205
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
206
+
207
+ from_: str = Field(..., alias="from")
208
+ where: WhereClause | None = None
209
+ select: list[str] | None = None
210
+ limit: int | None = None
211
+
212
+
213
+ # =============================================================================
214
+ # Main Query Model
215
+ # =============================================================================
216
+
217
+
218
+ class Query(BaseModel):
219
+ """The main query model.
220
+
221
+ This is the top-level structure for a structured query.
222
+
223
+ Examples:
224
+ {
225
+ "$version": "1.0",
226
+ "from": "persons",
227
+ "where": {"path": "email", "op": "contains", "value": "@acme.com"},
228
+ "include": ["companies"],
229
+ "orderBy": [{"field": "lastName", "direction": "asc"}],
230
+ "limit": 50
231
+ }
232
+ """
233
+
234
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
235
+
236
+ # Version (optional but recommended)
237
+ version: str | None = Field(None, alias="$version")
238
+
239
+ # Required: entity to query
240
+ from_: str = Field(..., alias="from")
241
+
242
+ # Optional: field selection
243
+ select: list[str] | None = None
244
+
245
+ # Optional: filtering
246
+ where: WhereClause | None = None
247
+
248
+ # Optional: includes (relationships to fetch)
249
+ include: list[str] | None = None
250
+
251
+ # Optional: named subqueries
252
+ subqueries: dict[str, SubqueryDef] | None = None
253
+
254
+ # Optional: ordering
255
+ order_by: list[OrderByClause] | None = Field(None, alias="orderBy")
256
+
257
+ # Optional: grouping and aggregation
258
+ group_by: str | None = Field(None, alias="groupBy")
259
+ aggregate: dict[str, AggregateFunc] | None = None
260
+ having: HavingClause | None = None
261
+
262
+ # Optional: pagination
263
+ limit: int | None = None
264
+ cursor: str | None = None
265
+
266
+
267
+ # =============================================================================
268
+ # Execution Plan Models (used by planner and executor)
269
+ # =============================================================================
270
+
271
+
272
+ @dataclass
273
+ class PlanStep:
274
+ """A single step in the execution plan."""
275
+
276
+ step_id: int
277
+ operation: Literal[
278
+ "fetch",
279
+ "filter",
280
+ "include",
281
+ "aggregate",
282
+ "sort",
283
+ "limit",
284
+ "exists_check",
285
+ "count_relationship",
286
+ ]
287
+ description: str
288
+ entity: str | None = None
289
+ relationship: str | None = None
290
+ estimated_api_calls: int = 0
291
+ estimated_records: int | None = None
292
+ is_client_side: bool = False
293
+ warnings: list[str] = field(default_factory=list)
294
+ depends_on: list[int] = field(default_factory=list)
295
+
296
+ # Additional metadata for specific operations
297
+ filter_pushdown: bool = False
298
+ pushdown_filter: str | None = None
299
+ client_filter: WhereClause | None = None
300
+
301
+
302
+ @dataclass
303
+ class ExecutionPlan:
304
+ """Complete execution plan for a query."""
305
+
306
+ query: Query
307
+ steps: list[PlanStep]
308
+ total_api_calls: int
309
+ estimated_records_fetched: int | None
310
+ estimated_memory_mb: float | None
311
+ warnings: list[str]
312
+ recommendations: list[str]
313
+ has_expensive_operations: bool
314
+ requires_full_scan: bool
315
+ version: str = "1.0"
316
+
317
+
318
+ @dataclass
319
+ class QueryResult:
320
+ """Result of query execution."""
321
+
322
+ data: list[dict[str, Any]]
323
+ included: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
324
+ summary: ResultSummary | None = None # Standardized result summary
325
+ meta: dict[str, Any] = field(default_factory=dict) # Additional metadata (executionTime, etc.)
326
+ pagination: dict[str, Any] | None = None
327
+ rate_limit: Any | None = None # RateLimitSnapshot from client
328
+ warnings: list[str] = field(default_factory=list)
329
+
330
+
331
+ # Enable forward references
332
+ WhereClause.model_rebuild()
333
+ HavingClause.model_rebuild()