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
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Session cache for cross-invocation caching in CLI pipelines.
|
|
2
|
+
|
|
3
|
+
This module provides file-based session caching to avoid redundant API calls
|
|
4
|
+
when running multiple CLI commands in a pipeline. Enable by setting the
|
|
5
|
+
AFFINITY_SESSION_CACHE environment variable to a directory path.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
export AFFINITY_SESSION_CACHE=$(affinity session start)
|
|
9
|
+
affinity list export "My List" | affinity person get
|
|
10
|
+
affinity session end
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, TypeVar
|
|
25
|
+
|
|
26
|
+
from pydantic import BaseModel, ValidationError
|
|
27
|
+
|
|
28
|
+
T = TypeVar("T", bound=BaseModel)
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SessionCacheConfig:
|
|
33
|
+
"""Configuration for session caching."""
|
|
34
|
+
|
|
35
|
+
DEFAULT_TTL = 600 # 10 minutes - longer than in-memory default
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self.cache_dir: Path | None = self._get_cache_dir()
|
|
39
|
+
self.ttl: float = self._get_ttl()
|
|
40
|
+
self.enabled: bool = self._init_cache_dir()
|
|
41
|
+
self.tenant_hash: str | None = None
|
|
42
|
+
|
|
43
|
+
def _get_ttl(self) -> float:
|
|
44
|
+
"""Get TTL from environment or use default."""
|
|
45
|
+
ttl_str = os.environ.get("AFFINITY_SESSION_CACHE_TTL")
|
|
46
|
+
if ttl_str:
|
|
47
|
+
try:
|
|
48
|
+
return float(ttl_str)
|
|
49
|
+
except ValueError:
|
|
50
|
+
pass
|
|
51
|
+
return self.DEFAULT_TTL
|
|
52
|
+
|
|
53
|
+
def _get_cache_dir(self) -> Path | None:
|
|
54
|
+
"""Get cache directory from environment."""
|
|
55
|
+
cache_path = os.environ.get("AFFINITY_SESSION_CACHE")
|
|
56
|
+
if not cache_path:
|
|
57
|
+
return None
|
|
58
|
+
return Path(cache_path)
|
|
59
|
+
|
|
60
|
+
def _init_cache_dir(self) -> bool:
|
|
61
|
+
"""Initialize cache directory, creating if needed."""
|
|
62
|
+
if self.cache_dir is None:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
# Check if path exists but is not a directory
|
|
66
|
+
if self.cache_dir.exists() and not self.cache_dir.is_dir():
|
|
67
|
+
print(
|
|
68
|
+
f"Warning: AFFINITY_SESSION_CACHE '{self.cache_dir}' is not a directory",
|
|
69
|
+
file=sys.stderr,
|
|
70
|
+
)
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
if not self.cache_dir.exists():
|
|
74
|
+
# Auto-create directory with restricted permissions (owner-only)
|
|
75
|
+
# to prevent other users from reading cached API responses
|
|
76
|
+
try:
|
|
77
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
78
|
+
except OSError as e:
|
|
79
|
+
# Warn but don't fail - caching is optional
|
|
80
|
+
print(
|
|
81
|
+
f"Warning: Cannot create session cache directory '{self.cache_dir}': {e}",
|
|
82
|
+
file=sys.stderr,
|
|
83
|
+
)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
# Cleanup stale .tmp files from interrupted writes
|
|
87
|
+
self._cleanup_stale_tmp_files()
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
def _cleanup_stale_tmp_files(self) -> None:
|
|
91
|
+
"""Remove orphaned .tmp files older than TTL."""
|
|
92
|
+
if self.cache_dir is None:
|
|
93
|
+
return
|
|
94
|
+
try:
|
|
95
|
+
for tmp in self.cache_dir.glob("*.tmp"):
|
|
96
|
+
try:
|
|
97
|
+
if (time.time() - tmp.stat().st_mtime) > self.ttl:
|
|
98
|
+
tmp.unlink(missing_ok=True)
|
|
99
|
+
except OSError:
|
|
100
|
+
pass # Ignore errors on individual files
|
|
101
|
+
except OSError:
|
|
102
|
+
pass # Ignore errors listing directory
|
|
103
|
+
|
|
104
|
+
def set_tenant_hash(self, api_key: str) -> None:
|
|
105
|
+
"""Set tenant hash from API key for cache isolation."""
|
|
106
|
+
self.tenant_hash = hashlib.sha256(api_key.encode()).hexdigest()[:12]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _write_stderr(msg: str) -> None:
|
|
110
|
+
"""Write a message to stderr."""
|
|
111
|
+
print(msg, file=sys.stderr)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SessionCache:
|
|
115
|
+
"""File-based session cache for cross-invocation caching."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, config: SessionCacheConfig, *, trace: bool = False) -> None:
|
|
118
|
+
self.config = config
|
|
119
|
+
self.trace = trace
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def enabled(self) -> bool:
|
|
123
|
+
return self.config.enabled and self.config.tenant_hash is not None
|
|
124
|
+
|
|
125
|
+
def _sanitize_key(self, key: str) -> str:
|
|
126
|
+
"""Sanitize cache key for safe filename usage.
|
|
127
|
+
|
|
128
|
+
For long keys, appends a hash to prevent collisions from truncation.
|
|
129
|
+
"""
|
|
130
|
+
# Replace non-word chars (except - and .) with underscore
|
|
131
|
+
# \w = [a-zA-Z0-9_], so this keeps alphanumerics, underscore, dash, and dot
|
|
132
|
+
safe_key = re.sub(r"[^\w\-.]", "_", key)
|
|
133
|
+
# If key is long, truncate but add hash suffix to prevent collisions
|
|
134
|
+
if len(safe_key) > 180:
|
|
135
|
+
key_hash = hashlib.md5(key.encode()).hexdigest()[:8]
|
|
136
|
+
return f"{safe_key[:180]}_{key_hash}"
|
|
137
|
+
return safe_key
|
|
138
|
+
|
|
139
|
+
def _cache_path(self, key: str) -> Path:
|
|
140
|
+
"""Get full path for a cache key."""
|
|
141
|
+
if self.config.cache_dir is None:
|
|
142
|
+
raise RuntimeError("Cache directory not configured")
|
|
143
|
+
safe_key = self._sanitize_key(key)
|
|
144
|
+
filename = f"{safe_key}_{self.config.tenant_hash}.json"
|
|
145
|
+
return self.config.cache_dir / filename
|
|
146
|
+
|
|
147
|
+
def _is_expired(self, path: Path) -> bool:
|
|
148
|
+
"""Check if cache file is expired using file mtime."""
|
|
149
|
+
try:
|
|
150
|
+
mtime = path.stat().st_mtime
|
|
151
|
+
return (time.time() - mtime) > self.config.ttl
|
|
152
|
+
except OSError:
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
def get(self, key: str, model_class: type[T]) -> T | None:
|
|
156
|
+
"""Get cached value, deserializing to Pydantic model."""
|
|
157
|
+
if not self.enabled:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
path = self._cache_path(key)
|
|
161
|
+
if not path.exists():
|
|
162
|
+
logger.debug("[CACHE] MISS: %s (session)", key)
|
|
163
|
+
if self.trace:
|
|
164
|
+
_write_stderr(f"trace #- cache miss: {key}")
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
# Check TTL using file mtime (avoids parsing JSON just for expiration check)
|
|
168
|
+
if self._is_expired(path):
|
|
169
|
+
path.unlink(missing_ok=True)
|
|
170
|
+
logger.debug("[CACHE] EXPIRED: %s (session)", key)
|
|
171
|
+
if self.trace:
|
|
172
|
+
_write_stderr(f"trace #- cache expired: {key}")
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
data = json.loads(path.read_text())
|
|
177
|
+
result = model_class.model_validate(data["value"])
|
|
178
|
+
logger.debug("[CACHE] HIT: %s (session)", key)
|
|
179
|
+
if self.trace:
|
|
180
|
+
_write_stderr(f"trace #+ cache hit: {key}")
|
|
181
|
+
return result
|
|
182
|
+
except (json.JSONDecodeError, KeyError, ValidationError) as e:
|
|
183
|
+
logger.debug("[CACHE] CORRUPTED: %s - %s (session)", key, e)
|
|
184
|
+
if self.trace:
|
|
185
|
+
_write_stderr(f"trace #! cache corrupted: {key}")
|
|
186
|
+
path.unlink(missing_ok=True)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
def get_list(self, key: str, model_class: type[T]) -> list[T] | None:
|
|
190
|
+
"""Get cached list of models."""
|
|
191
|
+
if not self.enabled:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
path = self._cache_path(key)
|
|
195
|
+
if not path.exists():
|
|
196
|
+
logger.debug("[CACHE] MISS: %s (session)", key)
|
|
197
|
+
if self.trace:
|
|
198
|
+
_write_stderr(f"trace #- cache miss: {key}")
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
if self._is_expired(path):
|
|
202
|
+
path.unlink(missing_ok=True)
|
|
203
|
+
logger.debug("[CACHE] EXPIRED: %s (session)", key)
|
|
204
|
+
if self.trace:
|
|
205
|
+
_write_stderr(f"trace #- cache expired: {key}")
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
data = json.loads(path.read_text())
|
|
210
|
+
result = [model_class.model_validate(item) for item in data["value"]]
|
|
211
|
+
logger.debug("[CACHE] HIT: %s (%d items) (session)", key, len(result))
|
|
212
|
+
if self.trace:
|
|
213
|
+
_write_stderr(f"trace #+ cache hit: {key} ({len(result)} items)")
|
|
214
|
+
return result
|
|
215
|
+
except (json.JSONDecodeError, KeyError, ValidationError) as e:
|
|
216
|
+
logger.debug("[CACHE] CORRUPTED: %s - %s (session)", key, e)
|
|
217
|
+
if self.trace:
|
|
218
|
+
_write_stderr(f"trace #! cache corrupted: {key}")
|
|
219
|
+
path.unlink(missing_ok=True)
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
def set(self, key: str, value: BaseModel | Sequence[BaseModel]) -> None:
|
|
223
|
+
"""Cache a value (single model or list of models).
|
|
224
|
+
|
|
225
|
+
Uses atomic write (write to temp file, then rename) to prevent
|
|
226
|
+
corruption from concurrent access in parallel pipelines.
|
|
227
|
+
"""
|
|
228
|
+
if not self.enabled:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
path = self._cache_path(key)
|
|
232
|
+
if isinstance(value, BaseModel):
|
|
233
|
+
serialized: dict[str, Any] | list[dict[str, Any]] = value.model_dump(mode="json")
|
|
234
|
+
else:
|
|
235
|
+
serialized = [v.model_dump(mode="json") for v in value]
|
|
236
|
+
|
|
237
|
+
data = {"value": serialized}
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
# Atomic write: write to temp file, then rename
|
|
241
|
+
# Using .json.tmp makes it clear which file this temp relates to
|
|
242
|
+
tmp_path = path.with_name(f"{path.name}.tmp")
|
|
243
|
+
tmp_path.write_text(json.dumps(data)) # Compact JSON for cache files
|
|
244
|
+
tmp_path.replace(path) # Atomic on POSIX
|
|
245
|
+
logger.debug("[CACHE] SET: %s (session)", key)
|
|
246
|
+
if self.trace:
|
|
247
|
+
_write_stderr(f"trace #= cache set: {key}")
|
|
248
|
+
except OSError as e:
|
|
249
|
+
# Cache write failure shouldn't crash the command
|
|
250
|
+
logger.debug("[CACHE] WRITE FAILED: %s - %s (session)", key, e)
|
|
251
|
+
if self.trace:
|
|
252
|
+
_write_stderr(f"trace #! cache write failed: {key}")
|
|
253
|
+
|
|
254
|
+
def invalidate(self, key: str) -> None:
|
|
255
|
+
"""Remove a specific cache entry."""
|
|
256
|
+
if not self.enabled:
|
|
257
|
+
return
|
|
258
|
+
path = self._cache_path(key)
|
|
259
|
+
path.unlink(missing_ok=True)
|
|
260
|
+
logger.debug("[CACHE] INVALIDATED: %s (session)", key)
|
|
261
|
+
if self.trace:
|
|
262
|
+
_write_stderr(f"trace #x cache invalidated: {key}")
|
|
263
|
+
|
|
264
|
+
def invalidate_prefix(self, prefix: str) -> None:
|
|
265
|
+
"""Remove all cache entries matching a prefix."""
|
|
266
|
+
if not self.enabled or not self.config.cache_dir:
|
|
267
|
+
return
|
|
268
|
+
safe_prefix = self._sanitize_key(prefix)
|
|
269
|
+
count = 0
|
|
270
|
+
for path in self.config.cache_dir.glob(f"{safe_prefix}*_{self.config.tenant_hash}.json"):
|
|
271
|
+
path.unlink(missing_ok=True)
|
|
272
|
+
count += 1
|
|
273
|
+
if count:
|
|
274
|
+
logger.debug("[CACHE] INVALIDATED %d entries with prefix: %s (session)", count, prefix)
|
|
275
|
+
if self.trace:
|
|
276
|
+
_write_stderr(f"trace #x cache invalidated {count} entries: {prefix}*")
|
affinity/cli/types.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Type definitions for CLI components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
# Type alias for JSON-serializable values
|
|
8
|
+
# This is a recursive type definition - JsonValue can contain itself
|
|
9
|
+
JsonValue = str | int | float | bool | None | dict[str, "JsonValue"] | list["JsonValue"]
|
|
10
|
+
JsonDict = dict[str, JsonValue]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_json_serializable(data: Any, path: str = "root") -> None:
|
|
14
|
+
"""
|
|
15
|
+
Validate that data contains only JSON-serializable types.
|
|
16
|
+
|
|
17
|
+
This function recursively traverses the data structure and ensures
|
|
18
|
+
all values are JSON-safe (str, int, float, bool, None, dict, or list).
|
|
19
|
+
Non-JSON-safe types like datetime, UUID, or Pydantic models will raise
|
|
20
|
+
TypeError.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
data: The data structure to validate
|
|
24
|
+
path: Current path in the data structure (for error messages)
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
TypeError: If non-JSON-safe types are found
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
>>> validate_json_serializable({"id": 123, "name": "John"})
|
|
31
|
+
# No error - all types are JSON-safe
|
|
32
|
+
|
|
33
|
+
>>> from datetime import datetime
|
|
34
|
+
>>> validate_json_serializable({"created": datetime.now()})
|
|
35
|
+
Traceback (most recent call last):
|
|
36
|
+
...
|
|
37
|
+
TypeError: Non-JSON-serializable type at root.created: datetime
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(data, dict):
|
|
40
|
+
for key, value in data.items():
|
|
41
|
+
validate_json_serializable(value, f"{path}.{key}")
|
|
42
|
+
elif isinstance(data, list):
|
|
43
|
+
for i, item in enumerate(data):
|
|
44
|
+
validate_json_serializable(item, f"{path}[{i}]")
|
|
45
|
+
elif isinstance(data, (str, int, float, bool, type(None))):
|
|
46
|
+
# These are JSON-safe primitive types
|
|
47
|
+
pass
|
|
48
|
+
else:
|
|
49
|
+
raise TypeError(
|
|
50
|
+
f"Non-JSON-serializable type at {path}: {type(data).__name__} (value: {data!r})"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CommandOutputProtocol(Protocol):
|
|
55
|
+
"""
|
|
56
|
+
Protocol for command output validation.
|
|
57
|
+
|
|
58
|
+
This defines the expected interface for CLI command outputs,
|
|
59
|
+
ensuring they contain only JSON-serializable data.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def data(self) -> JsonDict | None:
|
|
64
|
+
"""Output data (must be JSON-serializable)."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def api_called(self) -> bool:
|
|
69
|
+
"""Whether an API call was made."""
|
|
70
|
+
...
|