memoryintelligence 0.1.0__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.
@@ -0,0 +1,71 @@
1
+ """memoryintelligence: the official Python client for Memory Intelligence.
2
+
3
+ Ask once. Never explain again.
4
+
5
+ from memoryintelligence import MemoryIntelligence
6
+
7
+ mi = MemoryIntelligence.from_env() # reads MI_API_KEY
8
+ note = mi.capture("Deploy is Friday at 3pm.", source="standup")
9
+
10
+ for hit in mi.ask("when is the deploy?"):
11
+ print(hit.score, hit.summary)
12
+
13
+ assert mi.verify(note.id).valid # every memory has a receipt
14
+
15
+ Eight verbs on the canonical API: capture, ask, get, list, verify, explain,
16
+ forget, batch, upload. Results come back as small typed objects, not envelopes.
17
+ """
18
+ from ._version import __version__
19
+
20
+ from ._simple import (
21
+ MemoryIntelligence,
22
+ MI,
23
+ Memory,
24
+ Match,
25
+ Proof,
26
+ Explanation,
27
+ Receipt,
28
+ )
29
+ from ._errors import (
30
+ MIError,
31
+ ConfigurationError,
32
+ AuthenticationError,
33
+ RateLimitError,
34
+ NotFoundError,
35
+ ValidationError,
36
+ ServerError,
37
+ ConnectionError,
38
+ TimeoutError,
39
+ ConflictError,
40
+ PaymentRequiredError,
41
+ PermissionError,
42
+ )
43
+
44
+ __author__ = "Memory Intelligence"
45
+ __license__ = "MIT"
46
+
47
+ __all__ = [
48
+ "__version__",
49
+ # Client
50
+ "MemoryIntelligence",
51
+ "MI",
52
+ # Result types
53
+ "Memory",
54
+ "Match",
55
+ "Proof",
56
+ "Explanation",
57
+ "Receipt",
58
+ # Exceptions
59
+ "MIError",
60
+ "ConfigurationError",
61
+ "AuthenticationError",
62
+ "RateLimitError",
63
+ "NotFoundError",
64
+ "ValidationError",
65
+ "ServerError",
66
+ "ConnectionError",
67
+ "TimeoutError",
68
+ "ConflictError",
69
+ "PaymentRequiredError",
70
+ "PermissionError",
71
+ ]
@@ -0,0 +1,316 @@
1
+ """Memory Intelligence SDK - Authentication.
2
+
3
+ API key resolution and validation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import re
10
+ from urllib.parse import urlparse
11
+
12
+ from ._errors import ConfigurationError
13
+
14
+ # Try to load python-dotenv if available (optional dependency)
15
+ _dotenv_loaded = False
16
+ try:
17
+ from dotenv import load_dotenv
18
+ # Load .env file from current directory (won't override existing env vars)
19
+ load_dotenv(override=False)
20
+ _dotenv_loaded = True
21
+ except ImportError:
22
+ pass # python-dotenv not installed, skip .env loading
23
+
24
+ # Production API URL
25
+ DEFAULT_API_URL = "https://api.memoryintelligence.io"
26
+
27
+ # Valid API key prefixes
28
+ LIVE_KEY_PREFIX = "mi_sk_live_"
29
+ TEST_KEY_PREFIX = "mi_sk_test_"
30
+ BETA_KEY_PREFIX = "mi_sk_beta_" # beta programme keys
31
+ VALID_KEY_PREFIXES = (LIVE_KEY_PREFIX, TEST_KEY_PREFIX, BETA_KEY_PREFIX)
32
+
33
+ # Minimum key length (prefix + some randomness)
34
+ MIN_KEY_LENGTH = 32
35
+
36
+ # URL patterns that indicate localhost/local development
37
+ LOCALHOST_PATTERNS = [
38
+ r"localhost",
39
+ r"127\.\d+\.\d+\.\d+",
40
+ r"0\.0\.0\.0",
41
+ r"::1",
42
+ r"\.local$",
43
+ r"\.internal$",
44
+ r":\d+/(?!/)" # Any port specifier (rough heuristic)
45
+ ]
46
+
47
+
48
+ def resolve_api_key(explicit_key: str | None) -> str:
49
+ """
50
+ Resolve API key from explicit argument or environment.
51
+
52
+ Priority:
53
+ 1. Explicit key passed to constructor
54
+ 2. MI_API_KEY environment variable
55
+ 3. ConfigurationError (key is required)
56
+
57
+ Args:
58
+ explicit_key: API key passed directly to constructor
59
+
60
+ Returns:
61
+ Resolved API key string
62
+
63
+ Raises:
64
+ ConfigurationError: If no API key is found
65
+
66
+ Example:
67
+ >>> resolve_api_key("mi_sk_test_abc123...")
68
+ 'mi_sk_test_abc123...'
69
+ >>> resolve_api_key(None) # With MI_API_KEY set
70
+ 'mi_sk_live_xyz789...'
71
+ """
72
+ if explicit_key is not None:
73
+ key = explicit_key.strip()
74
+ if key:
75
+ return key
76
+
77
+ env_key = os.environ.get("MI_API_KEY")
78
+ if env_key:
79
+ key = env_key.strip()
80
+ if key:
81
+ return key
82
+
83
+ raise ConfigurationError(
84
+ "API key is required. Pass it directly, set MI_API_KEY environment variable, "
85
+ "or add MI_API_KEY=your_key to a .env file (requires: pip install memoryintelligence[dotenv]). "
86
+ "Get your API key at https://memoryintelligence.io/portal"
87
+ )
88
+
89
+
90
+ def validate_api_key(api_key: str, base_url: str) -> None:
91
+ """
92
+ Validate API key format and environment compatibility.
93
+
94
+ Combines format and environment checks into one call.
95
+
96
+ Args:
97
+ api_key: API key to validate
98
+ base_url: Base URL being used (to check live key + localhost)
99
+
100
+ Raises:
101
+ ConfigurationError: If key format or environment is invalid
102
+ """
103
+ validate_key_format(api_key)
104
+ validate_key_environment(api_key, base_url)
105
+
106
+
107
+ def resolve_base_url(explicit_url: str | None) -> str:
108
+ """
109
+ Resolve base URL from explicit argument or environment.
110
+
111
+ Priority:
112
+ 1. Explicit URL passed to constructor
113
+ 2. MI_BASE_URL environment variable
114
+ 3. Production default (https://api.memoryintelligence.io)
115
+
116
+ Args:
117
+ explicit_url: Base URL passed directly to constructor
118
+
119
+ Returns:
120
+ Resolved base URL string (normalized, no trailing slash)
121
+
122
+ Raises:
123
+ ConfigurationError: If URL is malformed
124
+
125
+ Example:
126
+ >>> resolve_base_url("https://custom.example.com")
127
+ 'https://custom.example.com'
128
+ >>> resolve_base_url("https://custom.example.com/") # Trailing slash removed
129
+ 'https://custom.example.com'
130
+ """
131
+ url = None
132
+
133
+ if explicit_url is not None:
134
+ url = explicit_url.strip()
135
+ else:
136
+ env_url = os.environ.get("MI_BASE_URL")
137
+ if env_url:
138
+ url = env_url.strip()
139
+
140
+ if not url:
141
+ return DEFAULT_API_URL
142
+
143
+ # Basic URL validation
144
+ if not url.startswith(("http://", "https://")):
145
+ raise ConfigurationError(
146
+ f"Invalid base_url: must start with http:// or https://. Got: {url[:50]}..."
147
+ )
148
+
149
+ # Validate URL structure
150
+ try:
151
+ parsed = urlparse(url)
152
+ if not parsed.netloc:
153
+ raise ConfigurationError(f"Invalid base_url: missing host. Got: {url}")
154
+ except Exception as e:
155
+ raise ConfigurationError(f"Invalid base_url: {e}") from e
156
+
157
+ # Remove trailing slashes for consistency
158
+ return url.rstrip("/")
159
+
160
+
161
+ def validate_key_format(api_key: str) -> None:
162
+ """
163
+ Validate API key format.
164
+
165
+ Valid keys must:
166
+ - Start with 'mi_sk_live_' or 'mi_sk_test_'
167
+ - Be at least 32 characters long
168
+ - Not contain whitespace
169
+
170
+ Args:
171
+ api_key: API key to validate
172
+
173
+ Raises:
174
+ ConfigurationError: If key format is invalid
175
+
176
+ Example:
177
+ >>> validate_key_format("mi_sk_test_abc123xyz...")
178
+ # passes silently
179
+ >>> validate_key_format("invalid_key")
180
+ ConfigurationError: Invalid API key format...
181
+ """
182
+ if not api_key:
183
+ raise ConfigurationError("API key cannot be empty.")
184
+
185
+ # Check for whitespace
186
+ if api_key != api_key.strip():
187
+ raise ConfigurationError(
188
+ "API key contains leading or trailing whitespace. Please check your key."
189
+ )
190
+
191
+ # Check prefix
192
+ if not any(api_key.startswith(prefix) for prefix in VALID_KEY_PREFIXES):
193
+ # Show what we received (safely truncated)
194
+ displayed_key = api_key[:20] + "..." if len(api_key) > 20 else api_key
195
+ raise ConfigurationError(
196
+ f"Invalid API key format. Key must start with 'mi_sk_live_', 'mi_sk_test_', or 'mi_sk_beta_'. "
197
+ f"Got: '{displayed_key}'. Get your API key at https://memoryintelligence.io/dashboard"
198
+ )
199
+
200
+ # Check minimum length
201
+ if len(api_key) < MIN_KEY_LENGTH:
202
+ raise ConfigurationError(
203
+ f"API key too short. Expected at least {MIN_KEY_LENGTH} characters, "
204
+ f"got {len(api_key)}. Please provide a complete key."
205
+ )
206
+
207
+
208
+ def validate_key_environment(api_key: str, base_url: str) -> None:
209
+ """
210
+ Validate that live keys are not used with localhost/development URLs.
211
+
212
+ This is a safety measure to prevent accidentally using production keys
213
+ in development environments.
214
+
215
+ Args:
216
+ api_key: The API key being used
217
+ base_url: The base URL being configured
218
+
219
+ Raises:
220
+ ConfigurationError: If live key is used with localhost URL
221
+
222
+ Example:
223
+ >>> validate_key_environment(
224
+ ... "mi_sk_live_abc123...",
225
+ ... "http://localhost:8000"
226
+ ... )
227
+ ConfigurationError: Live key detected with local base_url...
228
+ """
229
+ is_live_key = api_key.startswith(LIVE_KEY_PREFIX)
230
+
231
+ # Check for local/development URLs
232
+ is_localhost = _is_localhost_url(base_url)
233
+
234
+ if is_live_key and is_localhost:
235
+ raise ConfigurationError(
236
+ "Live key detected with local base_url. "
237
+ "Use a test key for local development. "
238
+ "Test keys start with 'mi_sk_test_'. "
239
+ "Get test keys at https://memoryintelligence.io/dashboard"
240
+ )
241
+
242
+
243
+ def _is_localhost_url(url: str) -> bool:
244
+ """
245
+ Check if URL points to localhost/development environment.
246
+
247
+ Args:
248
+ url: URL string to check
249
+
250
+ Returns:
251
+ True if URL appears to be localhost/local development
252
+ """
253
+ url_lower = url.lower()
254
+
255
+ for pattern in LOCALHOST_PATTERNS:
256
+ if re.search(pattern, url_lower):
257
+ return True
258
+
259
+ return False
260
+
261
+
262
+ def is_live_key(api_key: str) -> bool:
263
+ """
264
+ Check if API key is a live/production key.
265
+
266
+ Args:
267
+ api_key: API key to check
268
+
269
+ Returns:
270
+ True if key starts with 'mi_sk_live_'
271
+
272
+ Example:
273
+ >>> is_live_key("mi_sk_live_abc123")
274
+ True
275
+ >>> is_live_key("mi_sk_test_xyz789")
276
+ False
277
+ """
278
+ return api_key.startswith(LIVE_KEY_PREFIX)
279
+
280
+
281
+ def is_test_key(api_key: str) -> bool:
282
+ """
283
+ Check if API key is a test/development key.
284
+
285
+ Args:
286
+ api_key: API key to check
287
+
288
+ Returns:
289
+ True if key starts with 'mi_sk_test_'
290
+
291
+ Example:
292
+ >>> is_test_key("mi_sk_test_xyz789")
293
+ True
294
+ >>> is_test_key("mi_sk_live_abc123")
295
+ False
296
+ """
297
+ return api_key.startswith(TEST_KEY_PREFIX)
298
+
299
+
300
+ def mask_key(api_key: str) -> str:
301
+ """
302
+ Mask API key for safe logging/display.
303
+
304
+ Args:
305
+ api_key: API key to mask
306
+
307
+ Returns:
308
+ Masked key showing only first 12 and last 4 characters
309
+
310
+ Example:
311
+ >>> mask_key("mi_sk_test_abc123xyz789")
312
+ 'mi_sk_test_abc...yz89'
313
+ """
314
+ if len(api_key) <= 16:
315
+ return "***"
316
+ return f"{api_key[:12]}...{api_key[-4:]}"
@@ -0,0 +1,232 @@
1
+ """Memory Intelligence SDK - Error types.
2
+
3
+ Complete error hierarchy for programmatic exception handling.
4
+ Every error has a `code` string attribute that matches the API error envelope.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+
10
+ class MIError(Exception):
11
+ """Base. All SDK errors inherit from this."""
12
+ code: str = "mi_error"
13
+
14
+ def __init__(self, message: str = "Memory Intelligence error"):
15
+ super().__init__(message)
16
+
17
+
18
+ class ConfigurationError(MIError):
19
+ """Invalid SDK configuration (wrong key format, live key + localhost, etc.)"""
20
+ code = "configuration_error"
21
+
22
+ def __init__(self, message: str = "Invalid configuration"):
23
+ super().__init__(message)
24
+
25
+
26
+ class LicenseError(MIError):
27
+ """License invalid, expired beyond grace period, or revoked."""
28
+ code = "license_error"
29
+ days_expired: int = 0 # 0 if not expired, N if in warning period
30
+ renew_url: str = "https://memoryintelligence.io/billing"
31
+
32
+ def __init__(
33
+ self,
34
+ message: str = "License error",
35
+ days_expired: int = 0,
36
+ renew_url: str = "https://memoryintelligence.io/billing",
37
+ ):
38
+ super().__init__(message)
39
+ self.days_expired = days_expired
40
+ self.renew_url = renew_url
41
+
42
+
43
+ class AuthenticationError(MIError):
44
+ """API key rejected by server."""
45
+ code = "authentication_error"
46
+
47
+ def __init__(self, message: str = "Authentication failed"):
48
+ super().__init__(message)
49
+
50
+
51
+ class RateLimitError(MIError):
52
+ """Rate limit exceeded."""
53
+ code = "rate_limit"
54
+ retry_after: int = 60 # seconds, from Retry-After header
55
+
56
+ def __init__(
57
+ self,
58
+ message: str = "Rate limit exceeded",
59
+ retry_after: int = 60,
60
+ ):
61
+ super().__init__(message)
62
+ self.retry_after = retry_after
63
+
64
+
65
+ class ScopeViolationError(MIError):
66
+ """Cross-scope access attempt or missing scope_id."""
67
+ code = "scope_violation"
68
+
69
+ def __init__(self, message: str = "Scope violation"):
70
+ super().__init__(message)
71
+
72
+
73
+ class PIIViolationError(MIError):
74
+ """Content rejected due to PII policy."""
75
+ code = "pii_violation"
76
+ detected_types: list = field(default_factory=list)
77
+
78
+ def __init__(
79
+ self,
80
+ message: str = "PII violation detected",
81
+ detected_types: list | None = None,
82
+ ):
83
+ super().__init__(message)
84
+ self.detected_types = detected_types or []
85
+
86
+
87
+ class GovernanceError(MIError):
88
+ """Operation violates governance policy."""
89
+ code = "governance_error"
90
+
91
+ def __init__(self, message: str = "Governance policy violation"):
92
+ super().__init__(message)
93
+
94
+
95
+ class ConflictError(MIError):
96
+ """Resource conflict (e.g., duplicate UMO)."""
97
+ code = "conflict"
98
+
99
+ def __init__(self, message: str = "Resource conflict"):
100
+ super().__init__(message)
101
+
102
+
103
+ class PaymentRequiredError(MIError):
104
+ """Payment required - license upgrade needed."""
105
+ code = "payment_required"
106
+
107
+ def __init__(self, message: str = "License upgrade required"):
108
+ super().__init__(message)
109
+
110
+
111
+ class EncryptionError(MIError):
112
+ """Encryption/decryption failure."""
113
+ code = "encryption_error"
114
+
115
+ def __init__(self, message: str = "Encryption error"):
116
+ super().__init__(message)
117
+
118
+
119
+ class PermissionError(MIError):
120
+ """Permission denied."""
121
+ code = "permission_error"
122
+
123
+ def __init__(self, message: str = "Permission denied"):
124
+ super().__init__(message)
125
+
126
+
127
+ class ProvenanceError(MIError):
128
+ """Provenance verification failed."""
129
+ code = "provenance_error"
130
+
131
+ def __init__(self, message: str = "Provenance verification failed"):
132
+ super().__init__(message)
133
+
134
+
135
+ class ValidationError(MIError):
136
+ """Request payload failed server-side validation."""
137
+ code = "validation_error"
138
+ field: str = "" # which field failed
139
+
140
+ def __init__(
141
+ self,
142
+ message: str = "Validation error",
143
+ field: str = "",
144
+ ):
145
+ super().__init__(message)
146
+ self.field = field
147
+
148
+
149
+ class PaymentRequiredError(MIError):
150
+ """Payment required - license upgrade needed."""
151
+ code = "payment_required"
152
+
153
+ def __init__(self, message: str = "License upgrade required"):
154
+ super().__init__(message)
155
+
156
+
157
+ class PermissionError(MIError):
158
+ """Permission denied for this operation."""
159
+ code = "permission_error"
160
+
161
+ def __init__(self, message: str = "Permission denied"):
162
+ super().__init__(message)
163
+
164
+
165
+ class NotFoundError(MIError):
166
+ """Resource not found."""
167
+ code = "not_found"
168
+
169
+ def __init__(self, message: str = "Resource not found"):
170
+ super().__init__(message)
171
+
172
+
173
+ class ConflictError(MIError):
174
+ """Resource conflict (e.g., duplicate UMO)."""
175
+ code = "conflict"
176
+
177
+ def __init__(self, message: str = "Resource conflict"):
178
+ super().__init__(message)
179
+
180
+
181
+ class ServerError(MIError):
182
+ """Unrecoverable server-side error."""
183
+ code = "server_error"
184
+ request_id: str = "" # from X-MI-Request-ID response header
185
+
186
+ def __init__(
187
+ self,
188
+ message: str = "Server error",
189
+ request_id: str = "",
190
+ ):
191
+ super().__init__(message)
192
+ self.request_id = request_id
193
+
194
+
195
+ class ConnectionError(MIError):
196
+ """Could not reach the API."""
197
+ code = "connection_error"
198
+
199
+ def __init__(self, message: str = "Could not connect to API"):
200
+ super().__init__(message)
201
+
202
+
203
+ class TimeoutError(MIError):
204
+ """Request timed out."""
205
+ code = "timeout"
206
+
207
+ def __init__(self, message: str = "Request timed out"):
208
+ super().__init__(message)
209
+
210
+
211
+ # HTTP status code to exception mapping
212
+ # Used by _http.py to raise appropriate exceptions
213
+ HTTP_STATUS_EXCEPTIONS: dict[int, type[MIError]] = {
214
+ 400: ValidationError,
215
+ 401: AuthenticationError,
216
+ 402: PaymentRequiredError,
217
+ 403: PermissionError,
218
+ 404: NotFoundError,
219
+ 409: ConflictError,
220
+ 422: ValidationError,
221
+ 429: RateLimitError,
222
+ 451: PIIViolationError,
223
+ 500: ServerError,
224
+ 502: ServerError,
225
+ 503: ServerError,
226
+ 504: ServerError,
227
+ }
228
+
229
+
230
+ def get_exception_for_status(status_code: int) -> type[MIError]:
231
+ """Get the appropriate exception class for an HTTP status code."""
232
+ return HTTP_STATUS_EXCEPTIONS.get(status_code, ServerError)