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,749 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import os
5
+ import re
6
+ import sys
7
+ import time
8
+ from contextlib import suppress
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any, Literal
12
+ from urllib.parse import parse_qs, urlsplit, urlunsplit
13
+ from urllib.parse import urlsplit as _urlsplit_for_qs
14
+
15
+ from affinity import Affinity
16
+ from affinity.client import maybe_load_dotenv
17
+ from affinity.exceptions import (
18
+ AffinityError,
19
+ AuthenticationError,
20
+ AuthorizationError,
21
+ ConfigurationError,
22
+ ConflictError,
23
+ NetworkError,
24
+ NotFoundError,
25
+ RateLimitError,
26
+ ServerError,
27
+ UnsafeUrlError,
28
+ UnsupportedOperationError,
29
+ ValidationError,
30
+ WriteNotAllowedError,
31
+ )
32
+ from affinity.exceptions import (
33
+ TimeoutError as AffinityTimeoutError,
34
+ )
35
+ from affinity.hooks import (
36
+ ErrorHook,
37
+ EventHook,
38
+ HookEvent,
39
+ RequestHook,
40
+ RequestInfo,
41
+ RequestRetrying,
42
+ ResponseHook,
43
+ ResponseInfo,
44
+ )
45
+ from affinity.hooks import (
46
+ ErrorInfo as HookErrorInfo,
47
+ )
48
+ from affinity.models.types import V1_BASE_URL, V2_BASE_URL
49
+ from affinity.policies import Policies, WritePolicy
50
+
51
+ from .config import LoadedConfig, ProfileConfig, config_file_permission_warnings, load_config
52
+ from .errors import CLIError
53
+ from .logging import set_redaction_api_key
54
+ from .paths import CliPaths, get_paths
55
+ from .results import CommandContext, CommandMeta, CommandResult, ErrorInfo, ResultSummary
56
+ from .session_cache import SessionCache, SessionCacheConfig
57
+
58
+ OutputFormat = Literal["table", "json", "jsonl", "markdown", "toon", "csv"]
59
+
60
+ _CLI_CACHE_ENABLED = True
61
+ _CLI_CACHE_TTL_SECONDS = 300.0
62
+
63
+
64
+ def _strip_url_query_and_fragment(url: str) -> str:
65
+ """
66
+ Keep scheme/host/path but drop query/fragment to reduce accidental leakage of PII/filters.
67
+ """
68
+ try:
69
+ parts = urlsplit(url)
70
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
71
+ except Exception:
72
+ return url
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class ClientSettings:
77
+ api_key: str
78
+ timeout: float
79
+ v1_base_url: str
80
+ v2_base_url: str
81
+ enable_beta_endpoints: bool
82
+ log_requests: bool
83
+ max_retries: int
84
+ policies: Policies
85
+ on_request: RequestHook | None
86
+ on_response: ResponseHook | None
87
+ on_error: ErrorHook | None
88
+ on_event: EventHook | None
89
+
90
+
91
+ @dataclass
92
+ class CLIContext:
93
+ output: OutputFormat
94
+ quiet: bool
95
+ verbosity: int
96
+ pager: bool | None
97
+ progress: Literal["auto", "always", "never"]
98
+ profile: str | None
99
+ dotenv: bool
100
+ env_file: Path
101
+ api_key_file: str | None
102
+ api_key_stdin: bool
103
+ timeout: float | None
104
+ max_retries: int
105
+ readonly: bool
106
+ trace: bool
107
+ log_file: Path | None
108
+ enable_log_file: bool
109
+ enable_beta_endpoints: bool
110
+ all_columns: bool = False # Show all columns in table output
111
+ max_columns: int | None = None # Override auto-calculated max columns
112
+
113
+ _paths: CliPaths = field(default_factory=get_paths)
114
+ _loaded_config: LoadedConfig | None = None
115
+ _client: Affinity | None = None
116
+ _session_cache_config: SessionCacheConfig = field(default_factory=SessionCacheConfig)
117
+ _session_cache: SessionCache | None = None
118
+ _no_cache: bool = False
119
+
120
+ def load_dotenv_if_requested(self) -> None:
121
+ try:
122
+ maybe_load_dotenv(
123
+ load_dotenv=self.dotenv,
124
+ dotenv_path=self.env_file,
125
+ override=False,
126
+ )
127
+ except ImportError as exc:
128
+ raise CLIError(
129
+ "Optional .env support requires python-dotenv; install `affinity-sdk[cli]`.",
130
+ exit_code=2,
131
+ error_type="usage_error",
132
+ ) from exc
133
+
134
+ @property
135
+ def paths(self) -> CliPaths:
136
+ return self._paths
137
+
138
+ def _config_path(self) -> Path:
139
+ return self.paths.config_path
140
+
141
+ def load_config(self) -> LoadedConfig:
142
+ if self._loaded_config is None:
143
+ self._loaded_config = load_config(self._config_path())
144
+ return self._loaded_config
145
+
146
+ def _effective_profile(self) -> str:
147
+ return self.profile or os.getenv("AFFINITY_PROFILE") or "default"
148
+
149
+ def _profile_config(self) -> ProfileConfig:
150
+ cfg = self.load_config()
151
+ if self._effective_profile() == "default":
152
+ return cfg.default
153
+ return cfg.profiles.get(self._effective_profile(), ProfileConfig())
154
+
155
+ def resolve_api_key(self, *, warnings: list[str]) -> str:
156
+ if self.api_key_stdin:
157
+ raw = sys.stdin.read()
158
+ key = raw.strip()
159
+ if not key:
160
+ raise CLIError(
161
+ "Empty API key provided via stdin.", exit_code=2, error_type="usage_error"
162
+ )
163
+ return key
164
+
165
+ if self.api_key_file is not None:
166
+ if self.api_key_file == "-":
167
+ raw = sys.stdin.read()
168
+ key = raw.strip()
169
+ if not key:
170
+ raise CLIError(
171
+ "Empty API key provided via stdin.", exit_code=2, error_type="usage_error"
172
+ )
173
+ return key
174
+ path = Path(self.api_key_file)
175
+ key = path.read_text(encoding="utf-8").strip()
176
+ if not key:
177
+ raise CLIError(f"Empty API key file: {path}", exit_code=2, error_type="usage_error")
178
+ return key
179
+
180
+ env_key = os.getenv("AFFINITY_API_KEY", "").strip()
181
+ if env_key:
182
+ return env_key
183
+
184
+ prof = self._profile_config()
185
+ if prof.api_key:
186
+ warnings.extend(config_file_permission_warnings(self._config_path()))
187
+ return prof.api_key.strip()
188
+
189
+ raise CLIError(
190
+ (
191
+ "Missing API key. Set AFFINITY_API_KEY, use --api-key-file/--api-key-stdin, "
192
+ "or configure profiles."
193
+ ),
194
+ exit_code=2,
195
+ error_type="usage_error",
196
+ )
197
+
198
+ def resolve_client_settings(self, *, warnings: list[str]) -> ClientSettings:
199
+ self.load_dotenv_if_requested()
200
+ api_key = self.resolve_api_key(warnings=warnings)
201
+ set_redaction_api_key(api_key)
202
+
203
+ prof = self._profile_config()
204
+ timeout = self.timeout if self.timeout is not None else prof.timeout_seconds
205
+ if timeout is None:
206
+ timeout = 30.0
207
+ if self.max_retries < 0:
208
+ raise CLIError("--max-retries must be >= 0.", exit_code=2, error_type="usage_error")
209
+
210
+ v1_base_url = os.getenv("AFFINITY_V1_BASE_URL") or prof.v1_base_url or V1_BASE_URL
211
+ v2_base_url = os.getenv("AFFINITY_V2_BASE_URL") or prof.v2_base_url or V2_BASE_URL
212
+
213
+ def _write_stderr(line: str) -> None:
214
+ sys.stderr.write(line + "\n")
215
+ with suppress(Exception):
216
+ sys.stderr.flush()
217
+
218
+ on_request: RequestHook | None = None
219
+ on_response: ResponseHook | None = None
220
+ on_error: ErrorHook | None = None
221
+ if self.trace:
222
+
223
+ def _on_request(req: RequestInfo) -> None:
224
+ method = req.method
225
+ url = _strip_url_query_and_fragment(req.url)
226
+ _write_stderr(f"trace -> {method} {url}")
227
+
228
+ def _on_response(res: ResponseInfo) -> None:
229
+ status = str(res.status_code)
230
+ url = _strip_url_query_and_fragment(res.request.url)
231
+ extra = []
232
+ extra.append(f"elapsedMs={int(res.elapsed_ms)}")
233
+ if res.cache_hit:
234
+ extra.append("cacheHit=true")
235
+ suffix = (" " + " ".join(extra)) if extra else ""
236
+ _write_stderr(f"trace <- {status} {url}{suffix}")
237
+
238
+ def _on_error(err: HookErrorInfo) -> None:
239
+ url = _strip_url_query_and_fragment(err.request.url)
240
+ exc_name = type(err.error).__name__
241
+ _write_stderr(f"trace !! {exc_name} {url}")
242
+
243
+ on_request = _on_request
244
+ on_response = _on_response
245
+ on_error = _on_error
246
+
247
+ # Rate limit visibility - always show retrying messages (not just with --trace)
248
+ def _on_event(event: HookEvent) -> None:
249
+ if isinstance(event, RequestRetrying):
250
+ wait_int = int(event.wait_seconds)
251
+ _write_stderr(f"Rate limited (429) - retrying in {wait_int}s...")
252
+
253
+ on_event: EventHook = _on_event
254
+
255
+ policies = Policies(write=WritePolicy.DENY) if self.readonly else Policies()
256
+
257
+ return ClientSettings(
258
+ api_key=api_key,
259
+ timeout=timeout,
260
+ v1_base_url=v1_base_url,
261
+ v2_base_url=v2_base_url,
262
+ enable_beta_endpoints=self.enable_beta_endpoints,
263
+ log_requests=self.verbosity >= 2,
264
+ max_retries=self.max_retries,
265
+ policies=policies,
266
+ on_request=on_request,
267
+ on_response=on_response,
268
+ on_error=on_error,
269
+ on_event=on_event,
270
+ )
271
+
272
+ @property
273
+ def session_cache(self) -> SessionCache:
274
+ """Get or create session cache."""
275
+ if self._no_cache:
276
+ # Return a disabled cache instance
277
+ config = SessionCacheConfig()
278
+ config.enabled = False
279
+ return SessionCache(config, trace=self.trace)
280
+ if self._session_cache is None:
281
+ self._session_cache = SessionCache(self._session_cache_config, trace=self.trace)
282
+ return self._session_cache
283
+
284
+ def init_session_cache(self, settings: ClientSettings) -> None:
285
+ """Initialize session cache with tenant hash from resolved settings.
286
+
287
+ Called after client settings are resolved but before client creation.
288
+ Uses settings.api_key (public) rather than client internals.
289
+ """
290
+ if self._session_cache_config.enabled and not self._no_cache:
291
+ self._session_cache_config.set_tenant_hash(settings.api_key)
292
+
293
+ def get_client(self, *, warnings: list[str]) -> Affinity:
294
+ if self._client is not None:
295
+ return self._client
296
+
297
+ settings = self.resolve_client_settings(warnings=warnings)
298
+ self.init_session_cache(settings)
299
+
300
+ self._client = Affinity(
301
+ api_key=settings.api_key,
302
+ v1_base_url=settings.v1_base_url,
303
+ v2_base_url=settings.v2_base_url,
304
+ timeout=settings.timeout,
305
+ log_requests=settings.log_requests,
306
+ max_retries=settings.max_retries,
307
+ enable_beta_endpoints=settings.enable_beta_endpoints,
308
+ enable_cache=_CLI_CACHE_ENABLED,
309
+ cache_ttl=_CLI_CACHE_TTL_SECONDS,
310
+ on_request=settings.on_request,
311
+ on_response=settings.on_response,
312
+ on_error=settings.on_error,
313
+ on_event=settings.on_event,
314
+ policies=settings.policies,
315
+ )
316
+ return self._client
317
+
318
+ def close(self) -> None:
319
+ if self._client is not None:
320
+ self._client.close()
321
+ self._client = None
322
+
323
+
324
+ def exit_code_for_exception(exc: Exception) -> int:
325
+ if isinstance(exc, CLIError):
326
+ return exc.exit_code
327
+ if isinstance(exc, (AuthenticationError, AuthorizationError)):
328
+ return 3
329
+ if isinstance(exc, NotFoundError):
330
+ return 4
331
+ if isinstance(exc, (RateLimitError, ServerError)):
332
+ return 5
333
+ if isinstance(exc, AffinityError):
334
+ return 1
335
+ return 1
336
+
337
+
338
+ def _hint_for_validation_message(message: str) -> str | None:
339
+ """Return a specific hint if the error message matches a known pattern."""
340
+ msg_lower = message.lower()
341
+
342
+ # Date range exceeded for interactions
343
+ if "date range" in msg_lower and ("1 year" in msg_lower or "within" in msg_lower):
344
+ return (
345
+ "The Affinity API limits interaction queries to 1 year. "
346
+ "Split your query into multiple 1-year ranges."
347
+ )
348
+
349
+ return None
350
+
351
+
352
+ def normalize_exception(exc: Exception, *, verbosity: int = 0) -> CLIError:
353
+ if isinstance(exc, CLIError):
354
+ return exc
355
+
356
+ if isinstance(exc, FileExistsError):
357
+ path = str(exc.filename) if getattr(exc, "filename", None) else str(exc)
358
+ return CLIError(
359
+ f"Destination already exists: {path}",
360
+ error_type="file_exists",
361
+ exit_code=2,
362
+ hint="Re-run with --overwrite or choose a different --out directory.",
363
+ details={"path": path},
364
+ cause=exc,
365
+ )
366
+
367
+ if isinstance(exc, PermissionError):
368
+ path = str(exc.filename) if getattr(exc, "filename", None) else "file"
369
+ return CLIError(
370
+ f"Permission denied: {path}",
371
+ error_type="permission_denied",
372
+ exit_code=2,
373
+ hint="Check file permissions or choose a different output location.",
374
+ details={"path": path},
375
+ cause=exc,
376
+ )
377
+
378
+ if isinstance(exc, IsADirectoryError):
379
+ path = str(exc.filename) if getattr(exc, "filename", None) else str(exc)
380
+ return CLIError(
381
+ f"Expected a file path but got a directory: {path}",
382
+ error_type="io_error",
383
+ exit_code=2,
384
+ details={"path": path},
385
+ cause=exc,
386
+ )
387
+
388
+ if isinstance(exc, OSError) and getattr(exc, "errno", None) == errno.ENOSPC:
389
+ path = str(getattr(exc, "filename", "") or "")
390
+ suffix = f": {path}" if path else ""
391
+ return CLIError(
392
+ f"No space left on device{suffix}",
393
+ error_type="disk_full",
394
+ exit_code=2,
395
+ hint="Free disk space or choose a different output directory.",
396
+ details={"path": path} if path else None,
397
+ cause=exc,
398
+ )
399
+
400
+ if isinstance(exc, WriteNotAllowedError):
401
+ details: dict[str, Any] = {}
402
+ if getattr(exc, "method", None):
403
+ details["method"] = exc.method
404
+ if getattr(exc, "url", None):
405
+ details["url"] = _strip_url_query_and_fragment(exc.url)
406
+ return CLIError(
407
+ "Write operation blocked by policy (--readonly).",
408
+ error_type="write_not_allowed",
409
+ exit_code=2,
410
+ hint="Re-run without --readonly to allow writes.",
411
+ details=details or None,
412
+ cause=exc,
413
+ )
414
+
415
+ if isinstance(exc, RateLimitError):
416
+ hint = "Wait and retry, or reduce request frequency."
417
+ if getattr(exc, "retry_after", None):
418
+ hint = f"Retry after {exc.retry_after} seconds."
419
+ return CLIError(
420
+ str(exc),
421
+ error_type="rate_limited",
422
+ exit_code=5,
423
+ hint=hint,
424
+ details=_details_for_affinity_error(exc, verbosity=verbosity),
425
+ cause=exc,
426
+ )
427
+
428
+ if isinstance(exc, ValidationError):
429
+ sanitized_params = _sanitized_request_params_from_diagnostics(exc) or {}
430
+
431
+ message = getattr(exc, "message", None)
432
+ if not isinstance(message, str) or not message:
433
+ message = str(exc) or "Request validation failed."
434
+
435
+ field_name: str | None = getattr(exc, "param", None)
436
+ if not field_name:
437
+ match = re.search(r"\bField\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*:", message)
438
+ if match:
439
+ field_name = match.group(1)
440
+ if not field_name and len(sanitized_params) == 1:
441
+ field_name = next(iter(sanitized_params.keys()))
442
+
443
+ # Check for specific error patterns in the message first
444
+ pattern_hint = _hint_for_validation_message(message)
445
+ if pattern_hint is not None:
446
+ hint = pattern_hint
447
+ else:
448
+ hint = "Check command arguments and retry."
449
+ if "organization_id" in sanitized_params or "company_id" in sanitized_params:
450
+ company_id = sanitized_params.get("organization_id") or sanitized_params.get(
451
+ "company_id"
452
+ )
453
+ if isinstance(company_id, int):
454
+ hint = (
455
+ "Verify the company id exists and you have access "
456
+ f"(company_id={company_id})."
457
+ )
458
+ elif "person_id" in sanitized_params:
459
+ person_id = sanitized_params.get("person_id")
460
+ if isinstance(person_id, int):
461
+ hint = (
462
+ f"Verify the person id exists and you have access (person_id={person_id})."
463
+ )
464
+ elif "opportunity_id" in sanitized_params:
465
+ opportunity_id = sanitized_params.get("opportunity_id")
466
+ if isinstance(opportunity_id, int):
467
+ hint = (
468
+ "Verify the opportunity id exists and you have access "
469
+ f"(opportunity_id={opportunity_id})."
470
+ )
471
+ elif field_name:
472
+ hint = f"Check the value for `{field_name}` and retry."
473
+ elif sanitized_params:
474
+ bits = ", ".join(f"{k}={v}" for k, v in sorted(sanitized_params.items()))
475
+ hint = f"Check parameter values ({bits}) and retry."
476
+
477
+ details = _details_for_affinity_error(exc, verbosity=verbosity) or {}
478
+ if sanitized_params:
479
+ details.setdefault("params", sanitized_params)
480
+ if field_name:
481
+ details.setdefault("param", field_name)
482
+
483
+ if message == "Unknown error":
484
+ diagnostics = getattr(exc, "diagnostics", None)
485
+ snippet = getattr(diagnostics, "response_body_snippet", None) if diagnostics else None
486
+ if isinstance(snippet, str) and snippet.strip() and snippet.strip() not in {"{}", "[]"}:
487
+ message = snippet.strip()
488
+ else:
489
+ message = str(exc)
490
+
491
+ return CLIError(
492
+ message,
493
+ error_type="validation_error",
494
+ exit_code=2,
495
+ hint=hint,
496
+ details=details or None,
497
+ cause=exc,
498
+ )
499
+
500
+ if isinstance(exc, AuthenticationError):
501
+ return CLIError(
502
+ str(exc),
503
+ error_type="auth_error",
504
+ exit_code=3,
505
+ hint="Run 'xaffinity config check-key' or 'xaffinity config setup-key' to configure.",
506
+ details=_details_for_affinity_error(exc, verbosity=verbosity),
507
+ cause=exc,
508
+ )
509
+
510
+ if isinstance(exc, AuthorizationError):
511
+ return CLIError(
512
+ str(exc),
513
+ error_type="forbidden",
514
+ exit_code=3,
515
+ hint="Check that your API key has access to this resource.",
516
+ details=_details_for_affinity_error(exc, verbosity=verbosity),
517
+ cause=exc,
518
+ )
519
+
520
+ if isinstance(exc, NotFoundError):
521
+ return CLIError(
522
+ str(exc),
523
+ error_type="not_found",
524
+ exit_code=4,
525
+ details=_details_for_affinity_error(exc, verbosity=verbosity),
526
+ cause=exc,
527
+ )
528
+
529
+ if isinstance(exc, ConflictError):
530
+ return CLIError(
531
+ str(exc),
532
+ error_type="conflict",
533
+ exit_code=1,
534
+ hint="The resource already exists or was modified. Check for duplicates.",
535
+ details=_details_for_affinity_error(exc, verbosity=verbosity),
536
+ cause=exc,
537
+ )
538
+
539
+ if isinstance(exc, UnsafeUrlError):
540
+ return CLIError(
541
+ str(exc),
542
+ error_type="unsafe_url",
543
+ exit_code=1,
544
+ hint="The server returned a URL that failed security validation.",
545
+ details=_details_for_affinity_error(exc, verbosity=verbosity),
546
+ cause=exc,
547
+ )
548
+
549
+ if isinstance(exc, UnsupportedOperationError):
550
+ return CLIError(
551
+ str(exc),
552
+ error_type="unsupported_operation",
553
+ exit_code=1,
554
+ hint="This operation is not available for the current API version or configuration.",
555
+ details=_details_for_affinity_error(exc, verbosity=verbosity),
556
+ cause=exc,
557
+ )
558
+
559
+ if isinstance(exc, (ServerError,)):
560
+ return CLIError(
561
+ str(exc),
562
+ error_type="server_error",
563
+ exit_code=5,
564
+ hint="Retry later; if the issue persists, contact Affinity support.",
565
+ details=_details_for_affinity_error(exc, verbosity=verbosity),
566
+ cause=exc,
567
+ )
568
+
569
+ if isinstance(exc, NetworkError):
570
+ return CLIError(
571
+ str(exc),
572
+ error_type="network_error",
573
+ exit_code=1,
574
+ hint="Check your network connection and retry.",
575
+ details=_details_for_affinity_error(exc, verbosity=verbosity),
576
+ cause=exc,
577
+ )
578
+
579
+ if isinstance(exc, AffinityTimeoutError):
580
+ return CLIError(
581
+ str(exc),
582
+ error_type="timeout",
583
+ exit_code=1,
584
+ hint="Increase --timeout and retry, or narrow the request.",
585
+ details=_details_for_affinity_error(exc, verbosity=verbosity),
586
+ cause=exc,
587
+ )
588
+
589
+ if isinstance(exc, ConfigurationError):
590
+ return CLIError(
591
+ str(exc),
592
+ error_type="config_error",
593
+ exit_code=2,
594
+ hint="Check configuration (API key, base URLs) and retry.",
595
+ details=_details_for_affinity_error(exc, verbosity=verbosity),
596
+ cause=exc,
597
+ )
598
+
599
+ if isinstance(exc, AffinityError):
600
+ return CLIError(
601
+ str(exc),
602
+ error_type="api_error",
603
+ exit_code=1,
604
+ details=_details_for_affinity_error(exc, verbosity=verbosity),
605
+ cause=exc,
606
+ )
607
+
608
+ return CLIError(
609
+ str(exc) or exc.__class__.__name__,
610
+ error_type="internal_error",
611
+ exit_code=1,
612
+ cause=exc,
613
+ )
614
+
615
+
616
+ def _details_for_affinity_error(exc: AffinityError, *, verbosity: int) -> dict[str, Any] | None:
617
+ details: dict[str, Any] = {}
618
+ if exc.status_code is not None:
619
+ details["statusCode"] = exc.status_code
620
+ diagnostics = getattr(exc, "diagnostics", None)
621
+ if diagnostics is not None:
622
+ if getattr(diagnostics, "method", None):
623
+ details["method"] = diagnostics.method
624
+ if getattr(diagnostics, "url", None):
625
+ details["url"] = _strip_url_query_and_fragment(diagnostics.url)
626
+ if getattr(diagnostics, "api_version", None):
627
+ details["apiVersion"] = diagnostics.api_version
628
+ if getattr(diagnostics, "request_id", None):
629
+ details["requestId"] = diagnostics.request_id
630
+ if verbosity >= 2:
631
+ if getattr(diagnostics, "response_headers", None):
632
+ details["responseHeaders"] = diagnostics.response_headers
633
+ if getattr(diagnostics, "response_body_snippet", None):
634
+ details["responseBodySnippet"] = diagnostics.response_body_snippet
635
+ return details or None
636
+
637
+
638
+ def _sanitized_request_params_from_diagnostics(exc: AffinityError) -> dict[str, Any] | None:
639
+ diagnostics = getattr(exc, "diagnostics", None)
640
+ if diagnostics is None:
641
+ return None
642
+
643
+ allow = {
644
+ "organization_id",
645
+ "person_id",
646
+ "opportunity_id",
647
+ "list_id",
648
+ "list_entry_id",
649
+ "page_size",
650
+ "page_token",
651
+ }
652
+
653
+ raw_params = getattr(diagnostics, "request_params", None)
654
+ if isinstance(raw_params, dict) and raw_params:
655
+ sanitized_from_params: dict[str, Any] = {}
656
+ for k, v in raw_params.items():
657
+ if k not in allow:
658
+ continue
659
+ if isinstance(v, list) and v:
660
+ v = v[0]
661
+ if isinstance(v, int):
662
+ sanitized_from_params[k] = v
663
+ elif isinstance(v, str) and v.isdigit():
664
+ sanitized_from_params[k] = int(v)
665
+ if sanitized_from_params:
666
+ if "page_token" in sanitized_from_params:
667
+ sanitized_from_params["cursor"] = sanitized_from_params.pop("page_token")
668
+ return sanitized_from_params
669
+
670
+ if not getattr(diagnostics, "url", None):
671
+ return None
672
+
673
+ try:
674
+ parts = _urlsplit_for_qs(diagnostics.url)
675
+ qs = parse_qs(parts.query, keep_blank_values=False)
676
+ except Exception:
677
+ return None
678
+
679
+ sanitized: dict[str, Any] = {}
680
+ for k, values in qs.items():
681
+ if k not in allow or not values:
682
+ continue
683
+ v = values[0]
684
+ if isinstance(v, str) and v.isdigit():
685
+ sanitized[k] = int(v)
686
+ else:
687
+ # Avoid leaking free-text search terms or other potential PII.
688
+ continue
689
+ if "page_token" in sanitized:
690
+ sanitized["cursor"] = sanitized.pop("page_token")
691
+ return sanitized or None
692
+
693
+
694
+ def error_info_for_exception(exc: Exception, *, verbosity: int = 0) -> ErrorInfo:
695
+ normalized = normalize_exception(exc, verbosity=verbosity)
696
+ details = normalized.details
697
+ if verbosity >= 2 and normalized.cause is not None:
698
+ extra: dict[str, Any] = {}
699
+ if details and isinstance(details, dict):
700
+ extra.update(details)
701
+ extra.setdefault("causeType", type(normalized.cause).__name__)
702
+ msg = str(normalized.cause)
703
+ if msg:
704
+ extra.setdefault("causeMessage", msg)
705
+ details = extra
706
+ return ErrorInfo(
707
+ type=normalized.error_type,
708
+ message=normalized.message,
709
+ hint=normalized.hint,
710
+ docs_url=normalized.docs_url,
711
+ details=details,
712
+ )
713
+
714
+
715
+ def build_result(
716
+ *,
717
+ ok: bool,
718
+ command: CommandContext,
719
+ started_at: float,
720
+ data: Any | None,
721
+ artifacts: list[Any] | None = None,
722
+ warnings: list[str],
723
+ profile: str | None,
724
+ rate_limit: Any | None,
725
+ pagination: dict[str, Any] | None = None,
726
+ resolved: dict[str, Any] | None = None,
727
+ columns: list[dict[str, Any]] | None = None,
728
+ summary: ResultSummary | None = None,
729
+ error: ErrorInfo | None = None,
730
+ ) -> CommandResult:
731
+ duration_ms = int(max(0.0, (time.time() - started_at) * 1000))
732
+ meta = CommandMeta(
733
+ duration_ms=duration_ms,
734
+ profile=profile,
735
+ pagination=pagination,
736
+ resolved=resolved,
737
+ columns=columns,
738
+ rate_limit=rate_limit,
739
+ summary=summary,
740
+ )
741
+ return CommandResult(
742
+ ok=ok,
743
+ command=command,
744
+ data=data,
745
+ artifacts=artifacts or [],
746
+ warnings=warnings,
747
+ meta=meta,
748
+ error=error,
749
+ )