lumera 0.4.6__py3-none-any.whl → 0.9.6__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.
- lumera/__init__.py +99 -4
- lumera/_utils.py +782 -0
- lumera/automations.py +904 -0
- lumera/exceptions.py +72 -0
- lumera/files.py +97 -0
- lumera/google.py +47 -270
- lumera/integrations/__init__.py +34 -0
- lumera/integrations/google.py +338 -0
- lumera/llm.py +481 -0
- lumera/locks.py +216 -0
- lumera/pb.py +679 -0
- lumera/sdk.py +927 -380
- lumera/storage.py +270 -0
- lumera/webhooks.py +304 -0
- lumera-0.9.6.dist-info/METADATA +37 -0
- lumera-0.9.6.dist-info/RECORD +18 -0
- {lumera-0.4.6.dist-info → lumera-0.9.6.dist-info}/WHEEL +1 -1
- lumera-0.4.6.dist-info/METADATA +0 -11
- lumera-0.4.6.dist-info/RECORD +0 -7
- {lumera-0.4.6.dist-info → lumera-0.9.6.dist-info}/top_level.txt +0 -0
lumera/llm.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM operations for AI completions and embeddings.
|
|
3
|
+
|
|
4
|
+
This module provides a unified interface for LLM operations with pluggable
|
|
5
|
+
provider support. Currently implements OpenAI, with extensibility for other
|
|
6
|
+
providers (Anthropic, Google, etc.) in the future.
|
|
7
|
+
|
|
8
|
+
Available functions:
|
|
9
|
+
complete() - Single-turn LLM completion with prompt
|
|
10
|
+
chat() - Multi-turn chat completion with message history
|
|
11
|
+
embed() - Generate embeddings for text (single or batch)
|
|
12
|
+
|
|
13
|
+
Configuration:
|
|
14
|
+
OPENAI_API_KEY - Required for OpenAI provider
|
|
15
|
+
LUMERA_LLM_PROVIDER - Provider to use (default: "openai")
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
>>> from lumera import llm
|
|
19
|
+
>>> response = llm.complete("What is 2+2?", model="gpt-5.2-mini")
|
|
20
|
+
>>> print(response["content"])
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
from abc import ABC, abstractmethod
|
|
27
|
+
from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, Unpack
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
import openai
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"complete",
|
|
34
|
+
"chat",
|
|
35
|
+
"embed",
|
|
36
|
+
"Message",
|
|
37
|
+
"LLMResponse",
|
|
38
|
+
"ProviderConfig",
|
|
39
|
+
"LLMProvider",
|
|
40
|
+
"get_provider",
|
|
41
|
+
"set_provider",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Type definitions
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Message(TypedDict):
|
|
51
|
+
"""Chat message format compatible with OpenAI and other providers."""
|
|
52
|
+
|
|
53
|
+
role: Literal["system", "user", "assistant"]
|
|
54
|
+
content: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LLMResponse(TypedDict, total=False):
|
|
58
|
+
"""LLM completion response."""
|
|
59
|
+
|
|
60
|
+
content: str # Response text (always present)
|
|
61
|
+
model: str # Model used
|
|
62
|
+
usage: dict[str, int] # Token usage: prompt_tokens, completion_tokens, total_tokens
|
|
63
|
+
finish_reason: str # "stop", "length", "content_filter", etc.
|
|
64
|
+
provider: str # Provider name (e.g., "openai", "anthropic")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ProviderConfig(TypedDict, total=False):
|
|
68
|
+
"""Configuration options for LLM providers."""
|
|
69
|
+
|
|
70
|
+
api_key: NotRequired[str] # API key (overrides Lumera/env lookup)
|
|
71
|
+
provider_name: NotRequired[str] # Provider name for get_access_token (default: "openai")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Provider interface (for future extensibility)
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LLMProvider(ABC):
|
|
80
|
+
"""Abstract base class for LLM providers.
|
|
81
|
+
|
|
82
|
+
Subclass this to add support for new providers (Anthropic, Google, etc.).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
name: str = "base"
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def complete(
|
|
89
|
+
self,
|
|
90
|
+
prompt: str,
|
|
91
|
+
*,
|
|
92
|
+
model: str,
|
|
93
|
+
temperature: float,
|
|
94
|
+
max_tokens: int | None,
|
|
95
|
+
system_prompt: str | None,
|
|
96
|
+
json_mode: bool,
|
|
97
|
+
) -> LLMResponse:
|
|
98
|
+
"""Generate a completion for a single prompt."""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def chat(
|
|
103
|
+
self,
|
|
104
|
+
messages: list[Message],
|
|
105
|
+
*,
|
|
106
|
+
model: str,
|
|
107
|
+
temperature: float,
|
|
108
|
+
max_tokens: int | None,
|
|
109
|
+
json_mode: bool,
|
|
110
|
+
) -> LLMResponse:
|
|
111
|
+
"""Generate a chat completion from message history."""
|
|
112
|
+
...
|
|
113
|
+
|
|
114
|
+
@abstractmethod
|
|
115
|
+
def embed(
|
|
116
|
+
self,
|
|
117
|
+
text: str | list[str],
|
|
118
|
+
*,
|
|
119
|
+
model: str,
|
|
120
|
+
) -> list[float] | list[list[float]]:
|
|
121
|
+
"""Generate embeddings for text."""
|
|
122
|
+
...
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# OpenAI provider implementation
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class OpenAIProvider(LLMProvider):
|
|
131
|
+
"""OpenAI provider implementation using the openai Python SDK."""
|
|
132
|
+
|
|
133
|
+
name = "openai"
|
|
134
|
+
|
|
135
|
+
# Model aliases for convenience
|
|
136
|
+
MODEL_ALIASES: dict[str, str] = {
|
|
137
|
+
"gpt-5.2": "gpt-5.2",
|
|
138
|
+
"gpt-5.2-mini": "gpt-5.2-mini",
|
|
139
|
+
"gpt-5.2-nano": "gpt-5.2-nano",
|
|
140
|
+
# Embedding models
|
|
141
|
+
"text-embedding-3-small": "text-embedding-3-small",
|
|
142
|
+
"text-embedding-3-large": "text-embedding-3-large",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
DEFAULT_CHAT_MODEL = "gpt-5.2-mini"
|
|
146
|
+
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
|
|
147
|
+
DEFAULT_PROVIDER_NAME = "openai"
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
api_key: str | None = None,
|
|
152
|
+
provider_name: str | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Initialize OpenAI provider.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
api_key: OpenAI API key. If not provided, fetches from Lumera
|
|
158
|
+
using get_access_token(provider_name), or falls back
|
|
159
|
+
to OPENAI_API_KEY env var.
|
|
160
|
+
provider_name: Provider name for get_access_token lookup.
|
|
161
|
+
Defaults to "openai".
|
|
162
|
+
"""
|
|
163
|
+
self._explicit_api_key = api_key
|
|
164
|
+
self._provider_name = provider_name or self.DEFAULT_PROVIDER_NAME
|
|
165
|
+
self._client: openai.OpenAI | None = None # noqa: F821
|
|
166
|
+
|
|
167
|
+
def _get_api_key(self) -> str:
|
|
168
|
+
"""Get API key from explicit config, Lumera, or environment."""
|
|
169
|
+
# 1. Use explicitly provided key
|
|
170
|
+
if self._explicit_api_key:
|
|
171
|
+
return self._explicit_api_key
|
|
172
|
+
|
|
173
|
+
# 2. Try to fetch from Lumera platform
|
|
174
|
+
try:
|
|
175
|
+
from ._utils import get_access_token
|
|
176
|
+
|
|
177
|
+
return get_access_token(self._provider_name)
|
|
178
|
+
except Exception:
|
|
179
|
+
pass # Fall through to env var
|
|
180
|
+
|
|
181
|
+
# 3. Fall back to environment variable
|
|
182
|
+
env_key = os.environ.get("OPENAI_API_KEY")
|
|
183
|
+
if env_key:
|
|
184
|
+
return env_key
|
|
185
|
+
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"OpenAI API key not configured. Either:\n"
|
|
188
|
+
f" 1. Configure '{self._provider_name}' provider in Lumera platform\n"
|
|
189
|
+
" 2. Set OPENAI_API_KEY environment variable\n"
|
|
190
|
+
" 3. Pass api_key to set_provider()"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def client(self) -> openai.OpenAI: # noqa: F821
|
|
195
|
+
"""Lazy-initialize OpenAI client."""
|
|
196
|
+
if self._client is None:
|
|
197
|
+
try:
|
|
198
|
+
import openai
|
|
199
|
+
except ImportError as e:
|
|
200
|
+
raise ImportError(
|
|
201
|
+
"OpenAI package not installed. Install with: pip install 'lumera[full]'"
|
|
202
|
+
) from e
|
|
203
|
+
|
|
204
|
+
api_key = self._get_api_key()
|
|
205
|
+
self._client = openai.OpenAI(api_key=api_key)
|
|
206
|
+
return self._client
|
|
207
|
+
|
|
208
|
+
def _resolve_model(self, model: str) -> str:
|
|
209
|
+
"""Resolve model alias to actual model name."""
|
|
210
|
+
return self.MODEL_ALIASES.get(model, model)
|
|
211
|
+
|
|
212
|
+
def complete(
|
|
213
|
+
self,
|
|
214
|
+
prompt: str,
|
|
215
|
+
*,
|
|
216
|
+
model: str,
|
|
217
|
+
temperature: float,
|
|
218
|
+
max_tokens: int | None,
|
|
219
|
+
system_prompt: str | None,
|
|
220
|
+
json_mode: bool,
|
|
221
|
+
) -> LLMResponse:
|
|
222
|
+
"""Generate a completion using OpenAI."""
|
|
223
|
+
messages: list[Message] = []
|
|
224
|
+
if system_prompt:
|
|
225
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
226
|
+
messages.append({"role": "user", "content": prompt})
|
|
227
|
+
|
|
228
|
+
return self.chat(
|
|
229
|
+
messages,
|
|
230
|
+
model=model,
|
|
231
|
+
temperature=temperature,
|
|
232
|
+
max_tokens=max_tokens,
|
|
233
|
+
json_mode=json_mode,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def chat(
|
|
237
|
+
self,
|
|
238
|
+
messages: list[Message],
|
|
239
|
+
*,
|
|
240
|
+
model: str,
|
|
241
|
+
temperature: float,
|
|
242
|
+
max_tokens: int | None,
|
|
243
|
+
json_mode: bool,
|
|
244
|
+
) -> LLMResponse:
|
|
245
|
+
"""Generate a chat completion using OpenAI."""
|
|
246
|
+
resolved_model = self._resolve_model(model)
|
|
247
|
+
|
|
248
|
+
# Build request kwargs
|
|
249
|
+
kwargs: dict = {
|
|
250
|
+
"model": resolved_model,
|
|
251
|
+
"messages": messages, # type: ignore[arg-type]
|
|
252
|
+
"temperature": temperature,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if max_tokens is not None:
|
|
256
|
+
kwargs["max_tokens"] = max_tokens
|
|
257
|
+
|
|
258
|
+
if json_mode:
|
|
259
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
260
|
+
|
|
261
|
+
# Make API call
|
|
262
|
+
response = self.client.chat.completions.create(**kwargs)
|
|
263
|
+
|
|
264
|
+
# Extract response
|
|
265
|
+
choice = response.choices[0]
|
|
266
|
+
content = choice.message.content or ""
|
|
267
|
+
|
|
268
|
+
result: LLMResponse = {
|
|
269
|
+
"content": content,
|
|
270
|
+
"model": response.model,
|
|
271
|
+
"provider": self.name,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if choice.finish_reason:
|
|
275
|
+
result["finish_reason"] = choice.finish_reason
|
|
276
|
+
|
|
277
|
+
if response.usage:
|
|
278
|
+
result["usage"] = {
|
|
279
|
+
"prompt_tokens": response.usage.prompt_tokens,
|
|
280
|
+
"completion_tokens": response.usage.completion_tokens,
|
|
281
|
+
"total_tokens": response.usage.total_tokens,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return result
|
|
285
|
+
|
|
286
|
+
def embed(
|
|
287
|
+
self,
|
|
288
|
+
text: str | list[str],
|
|
289
|
+
*,
|
|
290
|
+
model: str,
|
|
291
|
+
) -> list[float] | list[list[float]]:
|
|
292
|
+
"""Generate embeddings using OpenAI."""
|
|
293
|
+
resolved_model = self._resolve_model(model)
|
|
294
|
+
|
|
295
|
+
# Normalize input to list
|
|
296
|
+
input_texts = [text] if isinstance(text, str) else text
|
|
297
|
+
|
|
298
|
+
response = self.client.embeddings.create(
|
|
299
|
+
model=resolved_model,
|
|
300
|
+
input=input_texts,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Extract embeddings
|
|
304
|
+
embeddings = [item.embedding for item in response.data]
|
|
305
|
+
|
|
306
|
+
# Return single embedding if single input
|
|
307
|
+
if isinstance(text, str):
|
|
308
|
+
return embeddings[0]
|
|
309
|
+
return embeddings
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
# Provider registry and module-level state
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
# Registry of available providers
|
|
317
|
+
_PROVIDERS: dict[str, type[LLMProvider]] = {
|
|
318
|
+
"openai": OpenAIProvider,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
# Current active provider instance
|
|
322
|
+
_current_provider: LLMProvider | None = None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def get_provider() -> LLMProvider:
|
|
326
|
+
"""Get the current LLM provider instance.
|
|
327
|
+
|
|
328
|
+
Returns the configured provider, initializing it if necessary.
|
|
329
|
+
Provider is determined by LUMERA_LLM_PROVIDER env var (default: "openai").
|
|
330
|
+
"""
|
|
331
|
+
global _current_provider
|
|
332
|
+
|
|
333
|
+
if _current_provider is None:
|
|
334
|
+
provider_name = os.environ.get("LUMERA_LLM_PROVIDER", "openai").lower()
|
|
335
|
+
|
|
336
|
+
if provider_name not in _PROVIDERS:
|
|
337
|
+
available = ", ".join(_PROVIDERS.keys())
|
|
338
|
+
raise ValueError(f"Unknown LLM provider: {provider_name}. Available: {available}")
|
|
339
|
+
|
|
340
|
+
provider_class = _PROVIDERS[provider_name]
|
|
341
|
+
_current_provider = provider_class()
|
|
342
|
+
|
|
343
|
+
return _current_provider
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def set_provider(provider: LLMProvider | str, **kwargs: Unpack[ProviderConfig]) -> None:
|
|
347
|
+
"""Set the active LLM provider.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
provider: Either a provider instance or provider name string.
|
|
351
|
+
**kwargs: If provider is a string, kwargs are passed to provider constructor.
|
|
352
|
+
|
|
353
|
+
Example:
|
|
354
|
+
>>> llm.set_provider("openai", api_key="sk-...")
|
|
355
|
+
>>> # Or with a custom provider instance
|
|
356
|
+
>>> llm.set_provider(MyCustomProvider())
|
|
357
|
+
"""
|
|
358
|
+
global _current_provider
|
|
359
|
+
|
|
360
|
+
if isinstance(provider, str):
|
|
361
|
+
if provider not in _PROVIDERS:
|
|
362
|
+
available = ", ".join(_PROVIDERS.keys())
|
|
363
|
+
raise ValueError(f"Unknown provider: {provider}. Available: {available}")
|
|
364
|
+
_current_provider = _PROVIDERS[provider](**kwargs)
|
|
365
|
+
else:
|
|
366
|
+
_current_provider = provider
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
# Public API functions
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def complete(
|
|
375
|
+
prompt: str,
|
|
376
|
+
*,
|
|
377
|
+
model: str = "gpt-5.2-mini",
|
|
378
|
+
temperature: float = 0.7,
|
|
379
|
+
max_tokens: int | None = None,
|
|
380
|
+
system_prompt: str | None = None,
|
|
381
|
+
json_mode: bool = False,
|
|
382
|
+
) -> LLMResponse:
|
|
383
|
+
"""Get LLM completion for a prompt.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
prompt: User prompt/question
|
|
387
|
+
model: Model to use (default: gpt-5.2-mini)
|
|
388
|
+
temperature: Sampling temperature 0.0 to 2.0 (default: 0.7)
|
|
389
|
+
max_tokens: Max tokens in response (None = model default)
|
|
390
|
+
system_prompt: Optional system message to set behavior
|
|
391
|
+
json_mode: Force JSON output (default: False)
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
LLM response with content and metadata
|
|
395
|
+
|
|
396
|
+
Example:
|
|
397
|
+
>>> response = llm.complete(
|
|
398
|
+
... prompt="Classify this deposit: ...",
|
|
399
|
+
... system_prompt="You are an expert accountant.",
|
|
400
|
+
... model="gpt-5.2-mini",
|
|
401
|
+
... json_mode=True
|
|
402
|
+
... )
|
|
403
|
+
>>> data = json.loads(response["content"])
|
|
404
|
+
"""
|
|
405
|
+
provider = get_provider()
|
|
406
|
+
return provider.complete(
|
|
407
|
+
prompt,
|
|
408
|
+
model=model,
|
|
409
|
+
temperature=temperature,
|
|
410
|
+
max_tokens=max_tokens,
|
|
411
|
+
system_prompt=system_prompt,
|
|
412
|
+
json_mode=json_mode,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def chat(
|
|
417
|
+
messages: list[Message],
|
|
418
|
+
*,
|
|
419
|
+
model: str = "gpt-5.2-mini",
|
|
420
|
+
temperature: float = 0.7,
|
|
421
|
+
max_tokens: int | None = None,
|
|
422
|
+
json_mode: bool = False,
|
|
423
|
+
) -> LLMResponse:
|
|
424
|
+
"""Multi-turn chat completion.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
messages: Conversation history with role and content
|
|
428
|
+
model: Model to use (default: gpt-5.2-mini)
|
|
429
|
+
temperature: Sampling temperature 0.0 to 2.0 (default: 0.7)
|
|
430
|
+
max_tokens: Max tokens in response (None = model default)
|
|
431
|
+
json_mode: Force JSON output (default: False)
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
LLM response with assistant's message
|
|
435
|
+
|
|
436
|
+
Example:
|
|
437
|
+
>>> response = llm.chat([
|
|
438
|
+
... {"role": "system", "content": "You are a helpful assistant."},
|
|
439
|
+
... {"role": "user", "content": "What is 2+2?"},
|
|
440
|
+
... {"role": "assistant", "content": "4"},
|
|
441
|
+
... {"role": "user", "content": "What about 3+3?"}
|
|
442
|
+
... ])
|
|
443
|
+
>>> print(response["content"])
|
|
444
|
+
"""
|
|
445
|
+
provider = get_provider()
|
|
446
|
+
return provider.chat(
|
|
447
|
+
messages,
|
|
448
|
+
model=model,
|
|
449
|
+
temperature=temperature,
|
|
450
|
+
max_tokens=max_tokens,
|
|
451
|
+
json_mode=json_mode,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def embed(
|
|
456
|
+
text: str | list[str],
|
|
457
|
+
*,
|
|
458
|
+
model: str = "text-embedding-3-small",
|
|
459
|
+
) -> list[float] | list[list[float]]:
|
|
460
|
+
"""Generate embeddings for text.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
text: Single string or list of strings to embed
|
|
464
|
+
model: Embedding model (default: text-embedding-3-small)
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
Embedding vector (for single string) or list of vectors (for list)
|
|
468
|
+
|
|
469
|
+
Example:
|
|
470
|
+
>>> embedding = llm.embed("deposit payment notice")
|
|
471
|
+
>>> # Use for similarity search, semantic matching, etc.
|
|
472
|
+
>>>
|
|
473
|
+
>>> # Batch embeddings
|
|
474
|
+
>>> embeddings = llm.embed([
|
|
475
|
+
... "payment notice",
|
|
476
|
+
... "direct deposit",
|
|
477
|
+
... "apportionment"
|
|
478
|
+
... ])
|
|
479
|
+
"""
|
|
480
|
+
provider = get_provider()
|
|
481
|
+
return provider.embed(text, model=model)
|
lumera/locks.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lock management for preventing concurrent operations.
|
|
3
|
+
|
|
4
|
+
Provides two types of locks:
|
|
5
|
+
1. Record-level locks: Lock specific records (uses platform lm_locks table)
|
|
6
|
+
2. Operation-level locks: Lock entire operations globally (requires custom collection)
|
|
7
|
+
|
|
8
|
+
Available functions:
|
|
9
|
+
claim_record_locks() - Lock specific records for processing
|
|
10
|
+
release_record_locks() - Release previously claimed record locks
|
|
11
|
+
acquire_operation_lock() - Lock an entire operation (NOT YET IMPLEMENTED)
|
|
12
|
+
release_operation_lock() - Release operation lock (NOT YET IMPLEMENTED)
|
|
13
|
+
operation_lock() - Context manager for operation locks (NOT YET IMPLEMENTED)
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
>>> from lumera import locks
|
|
17
|
+
>>> result = locks.claim_record_locks("export", "deposits", ["dep_1", "dep_2"])
|
|
18
|
+
>>> for id in result["claimed"]:
|
|
19
|
+
... process(id)
|
|
20
|
+
>>> locks.release_record_locks("export", record_ids=result["claimed"])
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"claim_record_locks",
|
|
25
|
+
"release_record_locks",
|
|
26
|
+
"acquire_operation_lock",
|
|
27
|
+
"release_operation_lock",
|
|
28
|
+
"operation_lock",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
from contextlib import contextmanager
|
|
32
|
+
from typing import Any, Iterator
|
|
33
|
+
|
|
34
|
+
# Import platform lock primitives from the main SDK module
|
|
35
|
+
from .sdk import claim_locks as _claim_locks
|
|
36
|
+
from .sdk import release_locks as _release_locks
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def claim_record_locks(
|
|
40
|
+
job_type: str,
|
|
41
|
+
collection: str,
|
|
42
|
+
record_ids: list[str],
|
|
43
|
+
*,
|
|
44
|
+
ttl_seconds: int = 900,
|
|
45
|
+
job_id: str | None = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Claim record-level locks (using platform lm_locks).
|
|
48
|
+
|
|
49
|
+
Prevents multiple workers from processing the same records concurrently.
|
|
50
|
+
Uses the platform's built-in lm_locks table.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
job_type: Workflow name (e.g., "deposit_processing")
|
|
54
|
+
collection: Collection name
|
|
55
|
+
record_ids: List of record IDs to lock
|
|
56
|
+
ttl_seconds: Lock duration in seconds (default 900 = 15 minutes)
|
|
57
|
+
job_id: Optional job identifier for grouping locks
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
{
|
|
61
|
+
"claimed": ["id1", "id2"], # Successfully locked
|
|
62
|
+
"skipped": ["id3"], # Already locked by another process
|
|
63
|
+
"ttl_seconds": 900
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
>>> result = claim_record_locks(
|
|
68
|
+
... job_type="export",
|
|
69
|
+
... collection="deposits",
|
|
70
|
+
... record_ids=["dep_1", "dep_2", "dep_3"]
|
|
71
|
+
... )
|
|
72
|
+
>>> for dep_id in result["claimed"]:
|
|
73
|
+
... process(dep_id)
|
|
74
|
+
>>> # Release when done
|
|
75
|
+
>>> release_record_locks("export", record_ids=result["claimed"])
|
|
76
|
+
"""
|
|
77
|
+
return _claim_locks(
|
|
78
|
+
job_type=job_type,
|
|
79
|
+
collection=collection,
|
|
80
|
+
record_ids=record_ids,
|
|
81
|
+
ttl_seconds=ttl_seconds,
|
|
82
|
+
job_id=job_id,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def release_record_locks(
|
|
87
|
+
job_type: str,
|
|
88
|
+
*,
|
|
89
|
+
collection: str | None = None,
|
|
90
|
+
record_ids: list[str] | None = None,
|
|
91
|
+
job_id: str | None = None,
|
|
92
|
+
) -> int:
|
|
93
|
+
"""Release record-level locks.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
job_type: Workflow name (required)
|
|
97
|
+
collection: Optional collection filter
|
|
98
|
+
record_ids: Optional specific records to release
|
|
99
|
+
job_id: Optional job identifier filter
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Number of locks released
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
>>> released = release_record_locks(
|
|
106
|
+
... job_type="export",
|
|
107
|
+
... record_ids=["dep_1", "dep_2"]
|
|
108
|
+
... )
|
|
109
|
+
>>> print(f"Released {released} locks")
|
|
110
|
+
"""
|
|
111
|
+
return _release_locks(
|
|
112
|
+
job_type=job_type, collection=collection, record_ids=record_ids, job_id=job_id
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Operation-level locks (simple key-value locks)
|
|
117
|
+
# Note: These would need a custom collection like "export_locks" to be implemented
|
|
118
|
+
# For now, providing the interface that should be implemented
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def acquire_operation_lock(
|
|
122
|
+
lock_name: str, *, ttl_seconds: int = 600, wait: bool = False, wait_timeout: int = 30
|
|
123
|
+
) -> bool:
|
|
124
|
+
"""Acquire an operation-level lock.
|
|
125
|
+
|
|
126
|
+
For preventing concurrent execution of entire operations (like exports).
|
|
127
|
+
Uses a simple key-value lock, not tied to specific records.
|
|
128
|
+
|
|
129
|
+
Note: This requires a custom locks collection to be created.
|
|
130
|
+
See the Charter Impact export_locks collection as an example.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
lock_name: Unique lock identifier (e.g., "csv_export")
|
|
134
|
+
ttl_seconds: Lock duration in seconds (default 600 = 10 minutes)
|
|
135
|
+
wait: If True, wait for lock to become available
|
|
136
|
+
wait_timeout: Max seconds to wait (if wait=True)
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
True if lock acquired, False if already held (when wait=False)
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
TimeoutError: If wait=True and timeout exceeded
|
|
143
|
+
NotImplementedError: If operation locks collection doesn't exist
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
>>> if acquire_operation_lock("csv_export"):
|
|
147
|
+
... try:
|
|
148
|
+
... perform_export()
|
|
149
|
+
... finally:
|
|
150
|
+
... release_operation_lock("csv_export")
|
|
151
|
+
... else:
|
|
152
|
+
... print("Export already in progress")
|
|
153
|
+
"""
|
|
154
|
+
raise NotImplementedError(
|
|
155
|
+
"Operation-level locks require a custom locks collection. "
|
|
156
|
+
"Create a collection like 'operation_locks' with fields: "
|
|
157
|
+
"lock_name (text, unique), held_by (text), acquired_at (date), expires_at (date). "
|
|
158
|
+
"Then implement acquire/release using pb.search/create/delete."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def release_operation_lock(lock_name: str) -> bool:
|
|
163
|
+
"""Release an operation-level lock.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
lock_name: Lock identifier
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
True if lock was released, False if wasn't held
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
NotImplementedError: If operation locks collection doesn't exist
|
|
173
|
+
|
|
174
|
+
Example:
|
|
175
|
+
>>> release_operation_lock("csv_export")
|
|
176
|
+
"""
|
|
177
|
+
raise NotImplementedError(
|
|
178
|
+
"Operation-level locks require a custom locks collection. "
|
|
179
|
+
"See acquire_operation_lock() for details."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@contextmanager
|
|
184
|
+
def operation_lock(
|
|
185
|
+
lock_name: str, *, ttl_seconds: int = 600, wait: bool = False, wait_timeout: int = 30
|
|
186
|
+
) -> Iterator[None]:
|
|
187
|
+
"""Context manager for operation locks.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
lock_name: Lock identifier
|
|
191
|
+
ttl_seconds: Lock duration in seconds
|
|
192
|
+
wait: Wait for lock if held
|
|
193
|
+
wait_timeout: Max wait time in seconds
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
NotImplementedError: If operation locks collection doesn't exist
|
|
197
|
+
TimeoutError: If wait=True and timeout exceeded
|
|
198
|
+
|
|
199
|
+
Example:
|
|
200
|
+
>>> with operation_lock("csv_export"):
|
|
201
|
+
... perform_export()
|
|
202
|
+
... # Lock automatically released on exit
|
|
203
|
+
"""
|
|
204
|
+
# This would acquire the lock
|
|
205
|
+
acquired = acquire_operation_lock(
|
|
206
|
+
lock_name, ttl_seconds=ttl_seconds, wait=wait, wait_timeout=wait_timeout
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if not acquired:
|
|
210
|
+
raise RuntimeError(f"Failed to acquire lock: {lock_name}")
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
yield
|
|
214
|
+
finally:
|
|
215
|
+
# Always release the lock
|
|
216
|
+
release_operation_lock(lock_name)
|