omkit 0.0.2__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.
omkit/settings.py ADDED
@@ -0,0 +1,348 @@
1
+ """packages/omur-sdk/omkit/settings.py — Settings manager with in-memory cache, Valkey pub/sub subscriber, and callbacks.
2
+
3
+ exports: class SettingsManager
4
+ rules: The SettingsManager must maintain thread-safe cache access and ensure all database and Redis operations are properly synchronized to prevent race conditions during concurrent updates.
5
+ agent: ollama/qwen3-coder:latest | ollama | 2026-05-01 | codedna-cli | initial CodeDNA annotation pass
6
+ message:
7
+ """
8
+ # packages/omur-sdk/omkit/settings.py
9
+ """Settings manager with in-memory cache, Valkey pub/sub subscriber, and callbacks."""
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import os
15
+ from typing import Any, Callable
16
+
17
+ import redis.asyncio as aioredis
18
+
19
+ from omkit.encryption import decrypt_value
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def _backend_from_env() -> str:
25
+ v = os.getenv("SETTINGS_BACKEND", "postgres")
26
+ if v not in {"postgres", "redis"}:
27
+ raise ValueError(f"unknown SETTINGS_BACKEND: {v}")
28
+ return v
29
+
30
+
31
+ class SettingsManager:
32
+ """Drop-in settings manager for services.
33
+
34
+ Loads all settings from DB on start and then keeps the in-memory cache
35
+ fresh via either a Postgres polling task (default) or a Valkey pub/sub
36
+ subscriber (opt-in via ``SETTINGS_BACKEND=redis``).
37
+
38
+ Backward-compatible construction keeps accepting the legacy
39
+ ``(service_name, db_session_factory, valkey_url, tenant_id, ...)`` signature.
40
+ New callers can pass an asyncpg ``pool`` with ``poll_interval`` to use the
41
+ polling backend without a SQLAlchemy session factory.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ service_name: str = "omur",
47
+ db_session_factory=None,
48
+ valkey_url: str = "",
49
+ tenant_id: str = "",
50
+ encryption_key: str = "",
51
+ *,
52
+ pool=None,
53
+ poll_interval: float = 5.0,
54
+ ):
55
+ self._service_name = service_name
56
+ self._db_factory = db_session_factory
57
+ self._valkey_url = valkey_url
58
+ self._tenant_id = tenant_id
59
+ self._encryption_key = encryption_key
60
+ self._cache: dict[str, Any] = {}
61
+ self._listeners: dict[str, list[Callable]] = {}
62
+ self._subscriber_task: asyncio.Task | None = None
63
+ self._cache_path = os.environ.get("SETTINGS_CACHE_PATH", "/tmp/settings-cache.json")
64
+ self._secret_keys: set[str] = set()
65
+ self._pool = pool
66
+ self._poll_interval = poll_interval
67
+ self._poll_last_seen = None
68
+ self._poll_task: asyncio.Task | None = None
69
+ self._stop: asyncio.Event | None = None
70
+
71
+ @classmethod
72
+ def create(
73
+ cls,
74
+ service_name: str,
75
+ db_session_factory,
76
+ settings,
77
+ ) -> "SettingsManager":
78
+ """Factory that reads valkey_url, tenant_id, encryption_key from a BaseServiceSettings instance.
79
+
80
+ Rules: The `service_name` must be a valid string that uniquely identifies the service; `db_session_factory` must be a callable that returns a valid database session; `settings` must have a `valkey_url` attribute, and optionally an `omur_settings_key` for encryption.
81
+ """
82
+ return cls(
83
+ service_name=service_name,
84
+ db_session_factory=db_session_factory,
85
+ valkey_url=settings.valkey_url,
86
+ tenant_id="",
87
+ encryption_key=getattr(settings, "omur_settings_key", ""),
88
+ )
89
+
90
+ def get(self, key: str, default: Any = None) -> Any:
91
+ """Read from in-memory cache. No I/O.
92
+
93
+ Rules: The function only retrieves values from an in-memory cache; it does not handle missing keys gracefully if the default is not provided, and assumes the cache has already been populated.
94
+ """
95
+ return self._cache.get(key, default)
96
+
97
+ async def get_secret(self, key: str) -> str | None:
98
+ """Decrypt and return a secret value. Reads from DB, not cache.
99
+
100
+ Rules: The function assumes that the database table `app_settings` exists with columns `value_json`, `is_secret`, and `key`; the `decrypt_value` function must be defined and properly handle decryption of the stored values.
101
+ """
102
+ from sqlalchemy import text
103
+
104
+ async with self._db_factory() as session:
105
+ result = await session.execute(
106
+ text("SELECT value_json, is_secret FROM app_settings WHERE key = :key"),
107
+ {"key": key},
108
+ )
109
+ row = result.one_or_none()
110
+ if not row or not row.is_secret:
111
+ return None
112
+ try:
113
+ return decrypt_value(str(row.value_json), self._encryption_key)
114
+ except Exception:
115
+ logger.warning("Failed to decrypt secret %s", key)
116
+ return None
117
+
118
+ def on_change(self, key: str, callback: Callable):
119
+ """Register a callback for when a specific setting changes.
120
+
121
+ Rules: Callbacks registered via `on_change` are expected to be synchronous and should not block or raise exceptions, as they are invoked directly during setting updates.
122
+ """
123
+ self._listeners.setdefault(key, []).append(callback)
124
+
125
+ async def start(self):
126
+ """Load all settings from DB into cache, validate, and start the
127
+
128
+ Rules: The `start` method requires that either `_pool` is set (for asyncpg) or `_valkey_url` is provided (for Redis); if neither is present, it defaults to a PostgreSQL-based polling mechanism.
129
+ configured live-update worker."""
130
+ if self._stop is None:
131
+ self._stop = asyncio.Event()
132
+ if self._pool is not None:
133
+ # Asyncpg pool path — use polling by default.
134
+ await self._load_from_pool()
135
+ else:
136
+ await self._load_from_db()
137
+ self._validate()
138
+
139
+ backend = _backend_from_env() if self._pool is not None else ("redis" if self._valkey_url else "postgres")
140
+ if backend == "redis" and self._valkey_url:
141
+ self._subscriber_task = asyncio.create_task(self._subscribe())
142
+ elif self._pool is not None:
143
+ self._poll_task = asyncio.create_task(self._poll_loop())
144
+ logger.info(
145
+ "[%s] SettingsManager started, backend=%s, %d settings cached",
146
+ self._service_name, backend, len(self._cache),
147
+ )
148
+
149
+ def _validate(self):
150
+ """Log warnings for settings with invalid or empty values that may cause runtime errors."""
151
+ for key, value in self._cache.items():
152
+ if value is None:
153
+ logger.warning("[%s] Setting '%s' is null", self._service_name, key)
154
+ elif isinstance(value, str) and value.strip() == "" and "api_key" not in key and "token" not in key:
155
+ logger.warning("[%s] Setting '%s' is empty", self._service_name, key)
156
+ elif isinstance(value, str):
157
+ # Check for common misconfigurations: JSON strings that should be parsed
158
+ stripped = value.strip()
159
+ if (stripped.startswith("{") and stripped.endswith("}")) or (stripped.startswith("[") and stripped.endswith("]")):
160
+ try:
161
+ json.loads(stripped)
162
+ except json.JSONDecodeError:
163
+ logger.warning("[%s] Setting '%s' looks like invalid JSON", self._service_name, key)
164
+
165
+ async def stop(self):
166
+ """Stop any background live-update workers.
167
+
168
+ Rules: Calling `stop` without a prior call to `start` will result in no-op behavior, but it's important to ensure that `start` was called and that background tasks were initialized before attempting to stop them.
169
+ """
170
+ if self._stop is None:
171
+ # start() was never called; nothing to stop.
172
+ return
173
+ self._stop.set()
174
+ if self._subscriber_task:
175
+ self._subscriber_task.cancel()
176
+ try:
177
+ await self._subscriber_task
178
+ except asyncio.CancelledError:
179
+ pass
180
+ if self._poll_task:
181
+ self._poll_task.cancel()
182
+ try:
183
+ await self._poll_task
184
+ except asyncio.CancelledError:
185
+ pass
186
+
187
+ async def _load_from_pool(self):
188
+ """Load all non-secret settings via the asyncpg pool."""
189
+ try:
190
+ async with self._pool.acquire() as conn:
191
+ rows = await conn.fetch(
192
+ "SELECT key, value_json, is_secret, updated_at FROM app_settings"
193
+ )
194
+ latest = self._poll_last_seen
195
+ for r in rows:
196
+ if latest is None or r["updated_at"] > latest:
197
+ latest = r["updated_at"]
198
+ if r["is_secret"]:
199
+ self._secret_keys.add(r["key"])
200
+ continue
201
+ value = r["value_json"]
202
+ if isinstance(value, (bytes, bytearray)):
203
+ value = value.decode()
204
+ if isinstance(value, str):
205
+ try:
206
+ value = json.loads(value)
207
+ except json.JSONDecodeError:
208
+ pass
209
+ self._cache[r["key"]] = value
210
+ if latest is not None:
211
+ self._poll_last_seen = latest
212
+ except Exception as e:
213
+ logger.warning("[%s] pool load failed: %s", self._service_name, e)
214
+
215
+ async def _poll_loop(self):
216
+ """Background task: re-fetch rows with updated_at > last_seen."""
217
+ while not self._stop.is_set():
218
+ try:
219
+ await asyncio.wait_for(self._stop.wait(), timeout=self._poll_interval)
220
+ except asyncio.TimeoutError:
221
+ pass
222
+ if self._stop.is_set():
223
+ return
224
+ await self._poll_once()
225
+
226
+ async def _poll_once(self):
227
+ try:
228
+ async with self._pool.acquire() as conn:
229
+ rows = await conn.fetch(
230
+ "SELECT key, value_json, is_secret, updated_at FROM app_settings "
231
+ "WHERE updated_at > $1 ORDER BY updated_at ASC",
232
+ self._poll_last_seen,
233
+ )
234
+ latest = self._poll_last_seen
235
+ for r in rows:
236
+ if latest is None or r["updated_at"] > latest:
237
+ latest = r["updated_at"]
238
+ if r["is_secret"]:
239
+ continue
240
+ value = r["value_json"]
241
+ if isinstance(value, (bytes, bytearray)):
242
+ value = value.decode()
243
+ if isinstance(value, str):
244
+ try:
245
+ value = json.loads(value)
246
+ except json.JSONDecodeError:
247
+ pass
248
+ self._cache[r["key"]] = value
249
+ for callback in self._listeners.get(r["key"], []):
250
+ try:
251
+ if asyncio.iscoroutinefunction(callback):
252
+ await callback(value)
253
+ else:
254
+ callback(value)
255
+ except Exception as e:
256
+ logger.error("Settings callback error for %s: %s", r["key"], e)
257
+ if latest is not None:
258
+ self._poll_last_seen = latest
259
+ except Exception as e:
260
+ logger.warning("[%s] poll failed: %s", self._service_name, e)
261
+
262
+ def _write_cache(self):
263
+ """Write non-secret settings to local cache file atomically."""
264
+ try:
265
+ secret_keys = getattr(self, "_secret_keys", set())
266
+ data = {k: v for k, v in self._cache.items() if k not in secret_keys}
267
+ tmp_path = self._cache_path + ".tmp"
268
+ with open(tmp_path, "w") as f:
269
+ json.dump(data, f)
270
+ os.chmod(tmp_path, 0o600)
271
+ os.replace(tmp_path, self._cache_path)
272
+ except Exception as e:
273
+ logger.warning("Failed to write settings cache: %s", e)
274
+
275
+ def _read_cache(self):
276
+ """Read settings from local cache file (fallback when DB unavailable)."""
277
+ try:
278
+ with open(self._cache_path) as f:
279
+ self._cache.update(json.load(f))
280
+ logger.info("[%s] Loaded %d settings from cache", self._service_name, len(self._cache))
281
+ except FileNotFoundError:
282
+ pass
283
+ except Exception as e:
284
+ logger.warning("Failed to read settings cache: %s", e)
285
+
286
+ async def _load_from_db(self):
287
+ """Load all non-secret settings into cache."""
288
+ from sqlalchemy import text
289
+
290
+ try:
291
+ async with self._db_factory() as session:
292
+ result = await session.execute(
293
+ text("SELECT key, value_json, is_secret FROM app_settings"),
294
+ )
295
+ for row in result.all():
296
+ if row.is_secret:
297
+ self._secret_keys.add(row.key)
298
+ else:
299
+ self._cache[row.key] = row.value_json
300
+ self._write_cache()
301
+ except Exception as e:
302
+ logger.warning("[%s] DB load failed, trying cache: %s", self._service_name, e)
303
+ self._read_cache()
304
+
305
+ async def _subscribe(self):
306
+ """Subscribe to Valkey pub/sub channel for setting changes."""
307
+ channel = f"omur:settings:{self._tenant_id}"
308
+ try:
309
+ client = aioredis.from_url(self._valkey_url, decode_responses=True)
310
+ pubsub = client.pubsub()
311
+ await pubsub.subscribe(channel)
312
+ logger.info("[%s] Subscribed to %s", self._service_name, channel)
313
+
314
+ async for message in pubsub.listen():
315
+ if message["type"] == "message":
316
+ await self._handle_message(message["data"])
317
+ except asyncio.CancelledError:
318
+ return
319
+ except Exception as e:
320
+ logger.error("[%s] Valkey subscriber error: %s", self._service_name, e)
321
+ await asyncio.sleep(5)
322
+ self._subscriber_task = asyncio.create_task(self._subscribe())
323
+
324
+ async def _handle_message(self, data: str):
325
+ """Process a pub/sub message: update cache, fire callbacks."""
326
+ try:
327
+ payload = json.loads(data)
328
+ except json.JSONDecodeError:
329
+ return
330
+
331
+ key = payload.get("key")
332
+ if not key:
333
+ return
334
+
335
+ if "value" in payload:
336
+ self._cache[key] = payload["value"]
337
+ elif payload.get("changed"):
338
+ self._cache.pop(key, None)
339
+ self._write_cache()
340
+
341
+ for callback in self._listeners.get(key, []):
342
+ try:
343
+ if asyncio.iscoroutinefunction(callback):
344
+ await callback(payload.get("value"))
345
+ else:
346
+ callback(payload.get("value"))
347
+ except Exception as e:
348
+ logger.error("Settings callback error for %s: %s", key, e)
omkit/sync_notifier.py ADDED
@@ -0,0 +1,110 @@
1
+ """packages/omur-sdk/omkit/sync_notifier.py — Fire-and-forget notifier for solid-sync Pod synchronization.
2
+
3
+ Usage:
4
+ notifier = SyncNotifier(base_url="http://solid-sync:8000", token="...")
5
+ await notifier.notify("medication", "med-001", {...data...})
6
+
7
+ Notifications are best-effort: failures are logged but never block the caller.
8
+
9
+ exports: class SyncNotifier
10
+ rules: The SyncNotifier module must maintain a persistent HTTP client connection for all notification operations and cannot be instantiated without a valid base_url and token. All notification methods are non-blocking and use asyncio task creation, requiring the event loop to be properly initialized. The module is designed for single-threaded use and does not support concurrent access patterns.
11
+ agent: ollama/qwen3-coder:latest | ollama | 2026-05-01 | codedna-cli | initial CodeDNA annotation pass
12
+ message:
13
+ """
14
+
15
+ import asyncio
16
+ import structlog
17
+ import httpx
18
+
19
+ log = structlog.get_logger()
20
+
21
+
22
+ class SyncNotifier:
23
+ """Sends async sync notifications to solid-sync service."""
24
+
25
+ def __init__(self, base_url: str, token: str):
26
+ self._base_url = base_url.rstrip("/")
27
+ self._token = token
28
+ self._client: httpx.AsyncClient | None = None
29
+
30
+ def _get_client(self) -> httpx.AsyncClient:
31
+ if self._client is None or self._client.is_closed:
32
+ self._client = httpx.AsyncClient(timeout=10.0)
33
+ return self._client
34
+
35
+ async def notify(self, resource_type: str, resource_id: str, data: dict) -> None:
36
+ """Fire-and-forget sync notification. Never raises.
37
+
38
+ Rules: resource_type and resource_id should not be None or empty strings, as they are used to identify resources in the sync system
39
+ """
40
+ asyncio.create_task(self._send(resource_type, resource_id, data))
41
+
42
+ async def notify_delete(self, resource_type: str, resource_id: str) -> None:
43
+ """Fire-and-forget delete notification. Never raises.
44
+
45
+ Rules: resource_type and resource_id should not be None or empty strings, as they are used to identify resources in the sync system
46
+ """
47
+ asyncio.create_task(self._send_delete(resource_type, resource_id))
48
+
49
+ async def notify_metrics(self, metric_name: str, date: str, rows: list[dict]) -> None:
50
+ """Fire-and-forget metrics sync notification. Never raises.
51
+
52
+ Rules: metric_name should not be None or empty string, date should be a valid date string, and rows should be a non-empty list of dictionaries
53
+ """
54
+ asyncio.create_task(self._send_metrics(metric_name, date, rows))
55
+
56
+ async def _send(self, resource_type: str, resource_id: str, data: dict) -> None:
57
+ try:
58
+ client = self._get_client()
59
+ resp = await client.post(
60
+ f"{self._base_url}/sync/{resource_type}/{resource_id}",
61
+ json=data,
62
+ headers={"X-Service-Token": self._token},
63
+ )
64
+ if resp.status_code == 202:
65
+ log.debug("sync_notifier.queued", type=resource_type, id=resource_id)
66
+ else:
67
+ log.warning("sync_notifier.unexpected_status", type=resource_type,
68
+ id=resource_id, status=resp.status_code)
69
+ except Exception as e:
70
+ log.warning("sync_notifier.failed", type=resource_type,
71
+ id=resource_id, error=str(e))
72
+
73
+ async def _send_delete(self, resource_type: str, resource_id: str) -> None:
74
+ try:
75
+ client = self._get_client()
76
+ resp = await client.delete(
77
+ f"{self._base_url}/sync/{resource_type}/{resource_id}",
78
+ headers={"X-Service-Token": self._token},
79
+ )
80
+ if resp.status_code == 202:
81
+ log.debug("sync_notifier.delete_queued", type=resource_type, id=resource_id)
82
+ else:
83
+ log.warning("sync_notifier.delete_unexpected_status", type=resource_type,
84
+ id=resource_id, status=resp.status_code)
85
+ except Exception as e:
86
+ log.warning("sync_notifier.delete_failed", type=resource_type,
87
+ id=resource_id, error=str(e))
88
+
89
+ async def _send_metrics(self, metric_name: str, date: str, rows: list[dict]) -> None:
90
+ try:
91
+ client = self._get_client()
92
+ resp = await client.post(
93
+ f"{self._base_url}/sync/metrics",
94
+ json={"metric_name": metric_name, "date": date, "rows": rows},
95
+ headers={"X-Service-Token": self._token},
96
+ )
97
+ if resp.status_code == 202:
98
+ log.debug("sync_notifier.metrics_queued", metric=metric_name, date=date)
99
+ else:
100
+ log.warning("sync_notifier.metrics_unexpected_status",
101
+ metric=metric_name, status=resp.status_code)
102
+ except Exception as e:
103
+ log.warning("sync_notifier.metrics_failed", metric=metric_name, error=str(e))
104
+
105
+ async def close(self) -> None:
106
+ """
107
+ Rules: close should only be called when the client has been initialized, otherwise it will raise an AttributeError
108
+ """
109
+ if self._client and not self._client.is_closed:
110
+ await self._client.aclose()