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,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
+ ...