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.
- affinity/__init__.py +139 -0
- affinity/cli/__init__.py +7 -0
- affinity/cli/click_compat.py +27 -0
- affinity/cli/commands/__init__.py +1 -0
- affinity/cli/commands/_entity_files_dump.py +219 -0
- affinity/cli/commands/_list_entry_fields.py +41 -0
- affinity/cli/commands/_v1_parsing.py +77 -0
- affinity/cli/commands/company_cmds.py +2139 -0
- affinity/cli/commands/completion_cmd.py +33 -0
- affinity/cli/commands/config_cmds.py +540 -0
- affinity/cli/commands/entry_cmds.py +33 -0
- affinity/cli/commands/field_cmds.py +413 -0
- affinity/cli/commands/interaction_cmds.py +875 -0
- affinity/cli/commands/list_cmds.py +3152 -0
- affinity/cli/commands/note_cmds.py +433 -0
- affinity/cli/commands/opportunity_cmds.py +1174 -0
- affinity/cli/commands/person_cmds.py +1980 -0
- affinity/cli/commands/query_cmd.py +444 -0
- affinity/cli/commands/relationship_strength_cmds.py +62 -0
- affinity/cli/commands/reminder_cmds.py +595 -0
- affinity/cli/commands/resolve_url_cmd.py +127 -0
- affinity/cli/commands/session_cmds.py +84 -0
- affinity/cli/commands/task_cmds.py +110 -0
- affinity/cli/commands/version_cmd.py +29 -0
- affinity/cli/commands/whoami_cmd.py +36 -0
- affinity/cli/config.py +108 -0
- affinity/cli/context.py +749 -0
- affinity/cli/csv_utils.py +195 -0
- affinity/cli/date_utils.py +42 -0
- affinity/cli/decorators.py +77 -0
- affinity/cli/errors.py +28 -0
- affinity/cli/field_utils.py +355 -0
- affinity/cli/formatters.py +551 -0
- affinity/cli/help_json.py +283 -0
- affinity/cli/logging.py +100 -0
- affinity/cli/main.py +261 -0
- affinity/cli/options.py +53 -0
- affinity/cli/paths.py +32 -0
- affinity/cli/progress.py +183 -0
- affinity/cli/query/__init__.py +163 -0
- affinity/cli/query/aggregates.py +357 -0
- affinity/cli/query/dates.py +194 -0
- affinity/cli/query/exceptions.py +147 -0
- affinity/cli/query/executor.py +1236 -0
- affinity/cli/query/filters.py +248 -0
- affinity/cli/query/models.py +333 -0
- affinity/cli/query/output.py +331 -0
- affinity/cli/query/parser.py +619 -0
- affinity/cli/query/planner.py +430 -0
- affinity/cli/query/progress.py +270 -0
- affinity/cli/query/schema.py +439 -0
- affinity/cli/render.py +1589 -0
- affinity/cli/resolve.py +222 -0
- affinity/cli/resolvers.py +249 -0
- affinity/cli/results.py +308 -0
- affinity/cli/runner.py +218 -0
- affinity/cli/serialization.py +65 -0
- affinity/cli/session_cache.py +276 -0
- affinity/cli/types.py +70 -0
- affinity/client.py +771 -0
- affinity/clients/__init__.py +19 -0
- affinity/clients/http.py +3664 -0
- affinity/clients/pipeline.py +165 -0
- affinity/compare.py +501 -0
- affinity/downloads.py +114 -0
- affinity/exceptions.py +615 -0
- affinity/filters.py +1128 -0
- affinity/hooks.py +198 -0
- affinity/inbound_webhooks.py +302 -0
- affinity/models/__init__.py +163 -0
- affinity/models/entities.py +798 -0
- affinity/models/pagination.py +513 -0
- affinity/models/rate_limit_snapshot.py +48 -0
- affinity/models/secondary.py +413 -0
- affinity/models/types.py +663 -0
- affinity/policies.py +40 -0
- affinity/progress.py +22 -0
- affinity/py.typed +0 -0
- affinity/services/__init__.py +42 -0
- affinity/services/companies.py +1286 -0
- affinity/services/lists.py +1892 -0
- affinity/services/opportunities.py +1330 -0
- affinity/services/persons.py +1348 -0
- affinity/services/rate_limits.py +173 -0
- affinity/services/tasks.py +193 -0
- affinity/services/v1_only.py +2445 -0
- affinity/types.py +83 -0
- affinity_sdk-0.9.5.dist-info/METADATA +622 -0
- affinity_sdk-0.9.5.dist-info/RECORD +92 -0
- affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
- affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
- affinity_sdk-0.9.5.dist-info/licenses/LICENSE +21 -0
affinity/cli/context.py
ADDED
|
@@ -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
|
+
)
|