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,444 @@
1
+ """CLI query command.
2
+
3
+ Executes structured queries against Affinity data.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from ..click_compat import RichCommand, click
15
+ from ..context import CLIContext
16
+ from ..decorators import category, progress_capable
17
+ from ..errors import CLIError
18
+ from ..options import output_options
19
+
20
+ # =============================================================================
21
+ # CLI Command
22
+ # =============================================================================
23
+
24
+
25
+ @category("read")
26
+ @progress_capable
27
+ @click.command(name="query", cls=RichCommand)
28
+ @click.option(
29
+ "--file",
30
+ "-f",
31
+ "file_path",
32
+ type=click.Path(exists=True, path_type=Path), # type: ignore[type-var]
33
+ help="Read query from JSON file.",
34
+ )
35
+ @click.option(
36
+ "--query",
37
+ "query_str",
38
+ type=str,
39
+ help="Inline JSON query string.",
40
+ )
41
+ @click.option(
42
+ "--query-version",
43
+ type=str,
44
+ help="Override $version in query (e.g., '1.0').",
45
+ )
46
+ @click.option(
47
+ "--dry-run",
48
+ is_flag=True,
49
+ help="Show execution plan without running.",
50
+ )
51
+ @click.option(
52
+ "--dry-run-verbose",
53
+ is_flag=True,
54
+ help="Show detailed plan with API call breakdown.",
55
+ )
56
+ @click.option(
57
+ "--confirm",
58
+ is_flag=True,
59
+ help="Require confirmation before expensive operations.",
60
+ )
61
+ @click.option(
62
+ "--max-records",
63
+ type=int,
64
+ default=10000,
65
+ show_default=True,
66
+ help="Safety limit on total records fetched.",
67
+ )
68
+ @click.option(
69
+ "--timeout",
70
+ type=float,
71
+ default=300.0,
72
+ show_default=True,
73
+ help="Overall timeout in seconds.",
74
+ )
75
+ @click.option(
76
+ "--csv",
77
+ "csv_flag",
78
+ is_flag=True,
79
+ help="Output as CSV.",
80
+ )
81
+ @click.option(
82
+ "--csv-bom",
83
+ is_flag=True,
84
+ help="Add UTF-8 BOM for Excel (use with redirection: --csv --csv-bom > file.csv).",
85
+ )
86
+ @click.option(
87
+ "--pretty",
88
+ is_flag=True,
89
+ help="Pretty-print JSON output.",
90
+ )
91
+ @click.option(
92
+ "--include-meta",
93
+ is_flag=True,
94
+ help="Include execution metadata in output.",
95
+ )
96
+ @click.option(
97
+ "--quiet",
98
+ "-q",
99
+ is_flag=True,
100
+ help="Suppress progress output.",
101
+ )
102
+ @click.option(
103
+ "--verbose",
104
+ "-v",
105
+ is_flag=True,
106
+ help="Show detailed progress.",
107
+ )
108
+ @output_options
109
+ @click.pass_obj
110
+ def query_cmd(
111
+ ctx: CLIContext,
112
+ file_path: Path | None,
113
+ query_str: str | None,
114
+ query_version: str | None,
115
+ dry_run: bool,
116
+ dry_run_verbose: bool,
117
+ confirm: bool,
118
+ max_records: int,
119
+ timeout: float,
120
+ csv_flag: bool,
121
+ csv_bom: bool,
122
+ pretty: bool,
123
+ include_meta: bool,
124
+ quiet: bool,
125
+ verbose: bool,
126
+ ) -> None:
127
+ """Execute a structured query against Affinity data.
128
+
129
+ The query can be provided via --file, --query, or piped from stdin.
130
+
131
+ \b
132
+ Examples:
133
+ # From file
134
+ xaffinity query --file query.json
135
+
136
+ # Inline JSON
137
+ xaffinity query --query '{"from": "persons", "limit": 10}'
138
+
139
+ # Dry-run to preview execution plan
140
+ xaffinity query --file query.json --dry-run
141
+
142
+ # CSV output
143
+ xaffinity query --file query.json --csv
144
+
145
+ # JSON output
146
+ xaffinity query --file query.json --json
147
+ """
148
+ try:
149
+ _query_cmd_impl(
150
+ ctx=ctx,
151
+ file_path=file_path,
152
+ query_str=query_str,
153
+ query_version=query_version,
154
+ dry_run=dry_run,
155
+ dry_run_verbose=dry_run_verbose,
156
+ confirm=confirm,
157
+ max_records=max_records,
158
+ timeout=timeout,
159
+ csv_flag=csv_flag,
160
+ csv_bom=csv_bom,
161
+ pretty=pretty,
162
+ include_meta=include_meta,
163
+ quiet=quiet,
164
+ verbose=verbose,
165
+ )
166
+ except CLIError as e:
167
+ # Display error cleanly without traceback
168
+ click.echo(f"Error: {e.message}", err=True)
169
+ if e.hint:
170
+ click.echo(f"Hint: {e.hint}", err=True)
171
+ raise click.exceptions.Exit(e.exit_code) from None
172
+
173
+
174
+ def _query_cmd_impl(
175
+ *,
176
+ ctx: CLIContext,
177
+ file_path: Path | None,
178
+ query_str: str | None,
179
+ query_version: str | None,
180
+ dry_run: bool,
181
+ dry_run_verbose: bool,
182
+ confirm: bool,
183
+ max_records: int,
184
+ timeout: float,
185
+ csv_flag: bool,
186
+ csv_bom: bool,
187
+ pretty: bool,
188
+ include_meta: bool,
189
+ quiet: bool,
190
+ verbose: bool,
191
+ ) -> None:
192
+ """Internal implementation of query command."""
193
+ from affinity.cli.query import (
194
+ QueryExecutionError,
195
+ QueryInterruptedError,
196
+ QueryParseError,
197
+ QuerySafetyLimitError,
198
+ QueryTimeoutError,
199
+ QueryValidationError,
200
+ create_planner,
201
+ parse_query,
202
+ )
203
+ from affinity.cli.query.executor import QueryExecutor
204
+ from affinity.cli.query.output import (
205
+ format_dry_run,
206
+ format_dry_run_json,
207
+ format_json,
208
+ format_table,
209
+ )
210
+ from affinity.cli.query.progress import RichQueryProgress, create_progress_callback
211
+
212
+ # Check mutual exclusivity: --csv and --json
213
+ if csv_flag and ctx.output == "json":
214
+ raise CLIError(
215
+ "--csv and --json are mutually exclusive.",
216
+ )
217
+
218
+ # Get query input
219
+ query_dict = _get_query_input(file_path, query_str)
220
+
221
+ # Parse and validate query
222
+ try:
223
+ parse_result = parse_query(query_dict, version_override=query_version)
224
+ except (QueryParseError, QueryValidationError) as e:
225
+ raise CLIError(f"Query validation failed: {e}") from None
226
+
227
+ query = parse_result.query
228
+
229
+ # Show parsing warnings
230
+ if parse_result.warnings and not quiet:
231
+ for warning in parse_result.warnings:
232
+ click.echo(f"[warning] {warning}", err=True)
233
+
234
+ # Create execution plan
235
+ planner = create_planner(max_records=max_records)
236
+ try:
237
+ plan = planner.plan(query)
238
+ except QueryValidationError as e:
239
+ raise CLIError(f"Query planning failed: {e}") from None
240
+
241
+ # Dry-run mode
242
+ if dry_run or dry_run_verbose:
243
+ if ctx.output == "json":
244
+ click.echo(format_dry_run_json(plan))
245
+ else:
246
+ click.echo(format_dry_run(plan, verbose=dry_run_verbose))
247
+ return
248
+
249
+ # Check for expensive operations
250
+ if (
251
+ plan.has_expensive_operations
252
+ and confirm
253
+ and not click.confirm(
254
+ f"This query will make approximately {plan.total_api_calls} API calls. Continue?"
255
+ )
256
+ ):
257
+ raise CLIError("Query cancelled by user.")
258
+
259
+ # Show warnings
260
+ if plan.warnings and not quiet:
261
+ for warning in plan.warnings:
262
+ click.echo(f"[warning] {warning}", err=True)
263
+
264
+ # Resolve client settings before async execution
265
+ warnings_list: list[str] = []
266
+ settings = ctx.resolve_client_settings(warnings=warnings_list)
267
+ for warning in warnings_list:
268
+ click.echo(f"[warning] {warning}", err=True)
269
+
270
+ # Execute query
271
+ async def run_query() -> Any:
272
+ from affinity import AsyncAffinity
273
+
274
+ async with AsyncAffinity(
275
+ api_key=settings.api_key,
276
+ v1_base_url=settings.v1_base_url,
277
+ v2_base_url=settings.v2_base_url,
278
+ timeout=settings.timeout,
279
+ log_requests=settings.log_requests,
280
+ max_retries=settings.max_retries,
281
+ on_request=settings.on_request,
282
+ on_response=settings.on_response,
283
+ on_error=settings.on_error,
284
+ policies=settings.policies,
285
+ ) as client:
286
+ # Create progress callback
287
+ if quiet:
288
+ progress = None
289
+ else:
290
+ progress = create_progress_callback(
291
+ total_steps=len(plan.steps),
292
+ quiet=quiet,
293
+ force_ndjson=ctx.output == "json",
294
+ )
295
+
296
+ # Use context manager for Rich progress
297
+ if isinstance(progress, RichQueryProgress):
298
+ with progress:
299
+ executor = QueryExecutor(
300
+ client,
301
+ progress=progress,
302
+ concurrency=10,
303
+ max_records=max_records,
304
+ timeout=timeout,
305
+ allow_partial=True,
306
+ )
307
+ result = await executor.execute(plan)
308
+ else:
309
+ executor = QueryExecutor(
310
+ client,
311
+ progress=progress,
312
+ concurrency=10,
313
+ max_records=max_records,
314
+ timeout=timeout,
315
+ allow_partial=True,
316
+ )
317
+ result = await executor.execute(plan)
318
+
319
+ # Capture rate limit before client closes
320
+ result.rate_limit = client.rate_limits.snapshot()
321
+ return result
322
+
323
+ try:
324
+ result = asyncio.run(run_query())
325
+ except QueryTimeoutError as e:
326
+ raise CLIError(f"Query timed out after {e.elapsed_seconds:.1f}s: {e}") from None
327
+ except QuerySafetyLimitError as e:
328
+ raise CLIError(f"Query exceeded safety limit: {e}") from None
329
+ except QueryInterruptedError as e:
330
+ if e.partial_results:
331
+ click.echo(
332
+ f"[interrupted] Returning {len(e.partial_results)} partial results",
333
+ err=True,
334
+ )
335
+ from affinity.cli.query.models import QueryResult
336
+
337
+ result = QueryResult(data=e.partial_results, meta={"interrupted": True})
338
+ else:
339
+ raise CLIError(f"Query interrupted: {e}") from None
340
+ except QueryExecutionError as e:
341
+ raise CLIError(f"Query execution failed: {e}") from None
342
+
343
+ # Format and output results
344
+ if csv_flag:
345
+ from ..csv_utils import write_csv_to_stdout
346
+
347
+ if not result.data:
348
+ click.echo("No results.", err=True)
349
+ sys.exit(0)
350
+
351
+ # Collect all unique field names from data
352
+ all_keys: set[str] = set()
353
+ for record in result.data:
354
+ all_keys.update(record.keys())
355
+ fieldnames = sorted(all_keys)
356
+
357
+ write_csv_to_stdout(rows=result.data, fieldnames=fieldnames, bom=csv_bom)
358
+ sys.exit(0)
359
+ elif ctx.output == "json":
360
+ output = format_json(result, pretty=pretty, include_meta=include_meta)
361
+ elif ctx.output in ("jsonl", "markdown", "toon", "csv"):
362
+ # Use unified formatters for new output formats
363
+ from ..formatters import format_data
364
+
365
+ if not result.data:
366
+ click.echo("No results.", err=True)
367
+ sys.exit(0)
368
+
369
+ # Collect all unique field names from data
370
+ keys: set[str] = set()
371
+ for record in result.data:
372
+ keys.update(record.keys())
373
+ fieldnames = sorted(keys)
374
+
375
+ output = format_data(result.data, ctx.output, fieldnames=fieldnames)
376
+ else:
377
+ # Default to table for interactive use
378
+ output = format_table(result)
379
+
380
+ click.echo(output)
381
+
382
+ # Show summary if not quiet
383
+ if not quiet and include_meta and result.meta:
384
+ exec_time = result.meta.get("executionTime", 0)
385
+ click.echo(f"\n[info] {len(result.data)} records in {exec_time:.2f}s", err=True)
386
+
387
+ # Show rate limit info (at verbose level, matching other commands)
388
+ if verbose and not quiet and result.rate_limit is not None:
389
+ rl = result.rate_limit
390
+ parts: list[str] = []
391
+ if rl.api_key_per_minute.remaining is not None and rl.api_key_per_minute.limit is not None:
392
+ parts.append(f"user {rl.api_key_per_minute.remaining}/{rl.api_key_per_minute.limit}")
393
+ if rl.org_monthly.remaining is not None and rl.org_monthly.limit is not None:
394
+ parts.append(f"org {rl.org_monthly.remaining}/{rl.org_monthly.limit}")
395
+ if parts:
396
+ click.echo(f"rate-limit[{rl.source}]: " + " | ".join(parts), err=True)
397
+
398
+
399
+ def _get_query_input(file_path: Path | None, query_str: str | None) -> dict[str, Any]:
400
+ """Get query input from file, string, or stdin.
401
+
402
+ Args:
403
+ file_path: Path to query file
404
+ query_str: Inline JSON string
405
+
406
+ Returns:
407
+ Parsed query dict
408
+
409
+ Raises:
410
+ CLIError: If no input provided or parsing fails
411
+ """
412
+ if file_path:
413
+ try:
414
+ content = file_path.read_text()
415
+ result: dict[str, Any] = json.loads(content)
416
+ return result
417
+ except json.JSONDecodeError as e:
418
+ raise CLIError(f"Invalid JSON in file: {e}") from None
419
+ except OSError as e:
420
+ raise CLIError(f"Failed to read file: {e}") from None
421
+
422
+ if query_str:
423
+ try:
424
+ result = json.loads(query_str)
425
+ return result
426
+ except json.JSONDecodeError as e:
427
+ raise CLIError(f"Invalid JSON: {e}") from None
428
+
429
+ # Try stdin
430
+ if not sys.stdin.isatty():
431
+ try:
432
+ content = sys.stdin.read()
433
+ result = json.loads(content)
434
+ return result
435
+ except json.JSONDecodeError as e:
436
+ raise CLIError(f"Invalid JSON from stdin: {e}") from None
437
+
438
+ raise CLIError(
439
+ "No query provided. Use --file, --query, or pipe JSON to stdin.\n\n"
440
+ "Examples:\n"
441
+ " xaffinity query --file query.json\n"
442
+ ' xaffinity query --query \'{"from": "persons", "limit": 10}\'\n'
443
+ ' echo \'{"from": "persons"}\' | xaffinity query'
444
+ )
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from affinity.models.secondary import RelationshipStrength
4
+ from affinity.types import PersonId, UserId
5
+
6
+ from ..click_compat import RichCommand, RichGroup, click
7
+ from ..context import CLIContext
8
+ from ..decorators import category
9
+ from ..options import output_options
10
+ from ..results import CommandContext
11
+ from ..runner import CommandOutput, run_command
12
+ from ..serialization import serialize_model_for_cli
13
+
14
+
15
+ @click.group(name="relationship-strength", cls=RichGroup)
16
+ def relationship_strength_group() -> None:
17
+ """Relationship strength commands."""
18
+
19
+
20
+ def _strength_payload(item: RelationshipStrength) -> dict[str, object]:
21
+ return serialize_model_for_cli(item)
22
+
23
+
24
+ @category("read")
25
+ @relationship_strength_group.command(name="ls", cls=RichCommand)
26
+ @click.option("--external-id", type=int, required=True, help="External person id.")
27
+ @click.option("--internal-id", type=int, default=None, help="Internal user id.")
28
+ @output_options
29
+ @click.pass_obj
30
+ def relationship_strength_ls(
31
+ ctx: CLIContext,
32
+ *,
33
+ external_id: int,
34
+ internal_id: int | None,
35
+ ) -> None:
36
+ """List relationship strengths."""
37
+
38
+ def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
39
+ client = ctx.get_client(warnings=warnings)
40
+ strengths = client.relationships.get(
41
+ external_id=PersonId(external_id),
42
+ internal_id=UserId(internal_id) if internal_id is not None else None,
43
+ )
44
+ payload = [_strength_payload(item) for item in strengths]
45
+
46
+ # Build CommandContext
47
+ # Both externalId and internalId are inputs (composite key per spec)
48
+ ctx_inputs: dict[str, object] = {"externalId": external_id}
49
+ if internal_id is not None:
50
+ ctx_inputs["internalId"] = internal_id
51
+
52
+ cmd_context = CommandContext(
53
+ name="relationship-strength ls",
54
+ inputs=ctx_inputs,
55
+ modifiers={},
56
+ )
57
+
58
+ return CommandOutput(
59
+ data={"relationshipStrengths": payload}, context=cmd_context, api_called=True
60
+ )
61
+
62
+ run_command(ctx, command="relationship-strength ls", fn=fn)