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/__init__.py +18 -0
- omkit/cleanup.py +62 -0
- omkit/config.py +60 -0
- omkit/cost.py +78 -0
- omkit/data/__init__.py +33 -0
- omkit/dbpool.py +139 -0
- omkit/encryption.py +58 -0
- omkit/eventbus.py +360 -0
- omkit/events.py +23 -0
- omkit/health.py +66 -0
- omkit/http.py +82 -0
- omkit/internal/__init__.py +7 -0
- omkit/internal/crypto.py +17 -0
- omkit/jobqueue/__init__.py +28 -0
- omkit/jobqueue/envelope.py +116 -0
- omkit/jobqueue/streaq.py +267 -0
- omkit/logging.py +77 -0
- omkit/metrics.py +41 -0
- omkit/model_lifecycle.py +192 -0
- omkit/platform/__init__.py +18 -0
- omkit/providers/__init__.py +11 -0
- omkit/providers/base.py +76 -0
- omkit/providers/registry.py +263 -0
- omkit/py.typed +0 -0
- omkit/quota.py +186 -0
- omkit/resilience.py +122 -0
- omkit/sanitize.py +122 -0
- omkit/security/__init__.py +28 -0
- omkit/security/events.py +79 -0
- omkit/sessions.py +301 -0
- omkit/settings.py +348 -0
- omkit/sync_notifier.py +110 -0
- omkit/tenant.py +271 -0
- omkit/tracing.py +80 -0
- omkit/transport/__init__.py +29 -0
- omkit/valkey.py +45 -0
- omkit-0.0.2.dist-info/METADATA +29 -0
- omkit-0.0.2.dist-info/RECORD +40 -0
- omkit-0.0.2.dist-info/WHEEL +5 -0
- omkit-0.0.2.dist-info/top_level.txt +1 -0
omkit/sanitize.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""packages/omur-sdk/omkit/sanitize.py — Shared sanitisation helpers for LLM output and HTML.
|
|
2
|
+
|
|
3
|
+
``sanitize_llm_output`` is the minimal form used by Frontal: strip
|
|
4
|
+
``<think>...</think>`` blocks and trim whitespace. ``sanitize_llm_response``
|
|
5
|
+
is the fuller form used by Marrow: also strips code fences, inline base64
|
|
6
|
+
images, and normalises embedded JSON. ``sanitize_html`` performs a
|
|
7
|
+
conservative HTML escape (removes ``<script>`` and inline event handlers,
|
|
8
|
+
escapes the rest).
|
|
9
|
+
|
|
10
|
+
These helpers are presentation-layer sanitisation. They are **not** a PHI/PII
|
|
11
|
+
scrubber — do not rely on them for compliance.
|
|
12
|
+
|
|
13
|
+
exports: sanitize_llm_output(text) | sanitize_llm_response(text) | sanitize_html(text) | extract_json(text)
|
|
14
|
+
rules: The sanitize module must maintain backward compatibility for all existing function signatures and return types. All sanitization functions must handle None and empty string inputs gracefully without raising exceptions. The module cannot introduce external dependencies or modify global state during execution.
|
|
15
|
+
agent: ollama/qwen3-coder:latest | ollama | 2026-05-01 | codedna-cli | initial CodeDNA annotation pass
|
|
16
|
+
message:
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import html
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sanitize_llm_output(text: str) -> str:
|
|
26
|
+
"""Remove <think>...</think> blocks and trim. Matches the historical
|
|
27
|
+
|
|
28
|
+
Rules: Removes all text enclosed in ... delimiters, including newlines, and trims whitespace. Future developers must know this is specifically for removing DeepSeek/Qwen thinking traces.
|
|
29
|
+
``services/frontal/sanitize.sanitize_llm_output`` behaviour."""
|
|
30
|
+
if not text:
|
|
31
|
+
return ""
|
|
32
|
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
|
|
33
|
+
return text.strip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sanitize_llm_response(text: str) -> str:
|
|
37
|
+
"""Clean LLM response text for display and judging.
|
|
38
|
+
|
|
39
|
+
- Strip <think>...</think> blocks (deepseek/qwen thinking)
|
|
40
|
+
- Strip markdown code fences (```json ... ```)
|
|
41
|
+
- Remove inline base64 images (data:image/...)
|
|
42
|
+
- Normalise embedded JSON (consistent key ordering, indentation)
|
|
43
|
+
|
|
44
|
+
Matches the historical ``services/marrow/core/sanitize.sanitize_llm_response``
|
|
45
|
+
behaviour exactly.
|
|
46
|
+
|
|
47
|
+
Rules: Removes ... blocks, markdown code fences (```json ... ```), inline base64 images, and normalizes embedded JSON. Must preserve exact historical behavior for compatibility.
|
|
48
|
+
"""
|
|
49
|
+
if not text:
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
text = re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.IGNORECASE).strip()
|
|
53
|
+
|
|
54
|
+
match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
|
|
55
|
+
if match:
|
|
56
|
+
text = match.group(1).strip()
|
|
57
|
+
|
|
58
|
+
text = re.sub(r"data:image/[^;]+;base64,[A-Za-z0-9+/=]+", "", text)
|
|
59
|
+
text = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", text)
|
|
60
|
+
|
|
61
|
+
text = text.strip()
|
|
62
|
+
if text.startswith(("{", "[")):
|
|
63
|
+
try:
|
|
64
|
+
parsed = json.loads(text)
|
|
65
|
+
text = json.dumps(parsed, indent=2, sort_keys=True, ensure_ascii=False)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
return text.strip()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sanitize_html(text: str) -> str:
|
|
73
|
+
"""Escape HTML and strip script tags / inline event handlers.
|
|
74
|
+
|
|
75
|
+
Rules: Escapes HTML and strips script tags and inline event handlers (e.g., onclick, onmouseover). Must ensure no XSS vulnerabilities are introduced.
|
|
76
|
+
"""
|
|
77
|
+
if not text:
|
|
78
|
+
return ""
|
|
79
|
+
text = re.sub(r"<script[\s\S]*?</script>", "", text, flags=re.IGNORECASE)
|
|
80
|
+
text = re.sub(r"\bon\w+\s*=\s*[\"'][^\"']*[\"']", "", text, flags=re.IGNORECASE)
|
|
81
|
+
text = html.escape(text)
|
|
82
|
+
return text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def extract_json(text: str) -> list | dict | None:
|
|
86
|
+
"""Extract JSON array or object from messy LLM output, or None.
|
|
87
|
+
|
|
88
|
+
Rules: Attempts to extract a valid JSON object or array from text by stripping leading non-JSON characters and handling malformed JSON. Future developers must know it may return None if parsing fails or no JSON is found.
|
|
89
|
+
"""
|
|
90
|
+
if not text:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
cleaned = sanitize_llm_response(text)
|
|
94
|
+
if not cleaned:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
for i, ch in enumerate(cleaned):
|
|
98
|
+
if ch in ("{", "["):
|
|
99
|
+
cleaned = cleaned[i:]
|
|
100
|
+
break
|
|
101
|
+
else:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
return json.loads(cleaned)
|
|
106
|
+
except json.JSONDecodeError:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
bracket = cleaned[0]
|
|
110
|
+
close = "}" if bracket == "{" else "]"
|
|
111
|
+
depth = 0
|
|
112
|
+
for i, ch in enumerate(cleaned):
|
|
113
|
+
if ch == bracket:
|
|
114
|
+
depth += 1
|
|
115
|
+
elif ch == close:
|
|
116
|
+
depth -= 1
|
|
117
|
+
if depth == 0:
|
|
118
|
+
try:
|
|
119
|
+
return json.loads(cleaned[: i + 1])
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
return None
|
|
122
|
+
return None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""packages/omur-sdk/omkit/security/__init__.py — re-exports sanitation helpers and event logger.
|
|
2
|
+
|
|
3
|
+
The ``omkit.encryption`` module has a mixed public surface and is
|
|
4
|
+
intentionally NOT re-exported here; continue to import from
|
|
5
|
+
``omkit.encryption`` directly. Private crypto primitives live in
|
|
6
|
+
``omkit.internal.crypto`` and are never re-exported.
|
|
7
|
+
|
|
8
|
+
exports: none
|
|
9
|
+
rules: The security module must maintain a strict separation between authentication and authorization logic, with no direct dependencies on external SDKs or third-party libraries that could introduce security vulnerabilities. All security-related operations must be deterministic and not rely on external state or environment variables. The module's public API must remain stable and backward-compatible across all minor version updates.
|
|
10
|
+
agent: ollama/qwen3-coder:latest | ollama | 2026-05-01 | codedna-cli | initial CodeDNA annotation pass
|
|
11
|
+
message:
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from omkit.sanitize import (
|
|
15
|
+
extract_json,
|
|
16
|
+
sanitize_html,
|
|
17
|
+
sanitize_llm_output,
|
|
18
|
+
sanitize_llm_response,
|
|
19
|
+
)
|
|
20
|
+
from omkit.security.events import log_security_event
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"sanitize_llm_output",
|
|
24
|
+
"sanitize_html",
|
|
25
|
+
"sanitize_llm_response",
|
|
26
|
+
"extract_json",
|
|
27
|
+
"log_security_event",
|
|
28
|
+
]
|
omkit/security/events.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""packages/omur-sdk/omkit/security/events.py — write-side helper for the security_events table.
|
|
2
|
+
|
|
3
|
+
Evidence must be structured metadata (pattern names, classifier verdicts,
|
|
4
|
+
stripped URLs). Never pass full document content — the column comment in
|
|
5
|
+
the migration is the canonical reminder.
|
|
6
|
+
|
|
7
|
+
exports: log_security_event()
|
|
8
|
+
rules: The security events module must maintain immutable evidence logging to ensure audit trail integrity and cannot allow external modification of logged security events after creation.
|
|
9
|
+
agent: ollama/qwen3-coder:latest | ollama | 2026-05-01 | codedna-cli | initial CodeDNA annotation pass
|
|
10
|
+
message:
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
from uuid import UUID
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import asyncpg
|
|
22
|
+
|
|
23
|
+
_log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def log_security_event(
|
|
27
|
+
*,
|
|
28
|
+
pool: "asyncpg.Pool",
|
|
29
|
+
tenant_id: UUID,
|
|
30
|
+
kind: str,
|
|
31
|
+
severity: str,
|
|
32
|
+
doc_id: UUID | None = None,
|
|
33
|
+
chunk_id: str | None = None,
|
|
34
|
+
evidence: dict | None = None,
|
|
35
|
+
classifier_version: str | None = None,
|
|
36
|
+
request_id: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Insert one row into security_events under the caller-supplied pool.
|
|
39
|
+
|
|
40
|
+
The pool must already be configured with SET ROLE omur_app (via
|
|
41
|
+
dbpool.create_pool) so RLS enforcement is active. The caller is
|
|
42
|
+
responsible for setting the app.tenant_id GUC in the same transaction
|
|
43
|
+
when the pool uses the restricted role with RLS enabled.
|
|
44
|
+
|
|
45
|
+
Logs to stderr via stdlib logging at WARNING level when severity=='block'
|
|
46
|
+
so ops can tail stdout/stderr without a separate query.
|
|
47
|
+
|
|
48
|
+
Rules: The pool must already be configured with SET ROLE omur_app so RLS enforcement is active. The caller is responsible for setting the app.tenant_id GUC in the same transaction.
|
|
49
|
+
"""
|
|
50
|
+
evidence_json = json.dumps(evidence or {})
|
|
51
|
+
|
|
52
|
+
async with pool.acquire() as conn:
|
|
53
|
+
await conn.execute(
|
|
54
|
+
"""
|
|
55
|
+
INSERT INTO security_events
|
|
56
|
+
(tenant_id, kind, severity, doc_id, chunk_id,
|
|
57
|
+
evidence, classifier_version, request_id)
|
|
58
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)
|
|
59
|
+
""",
|
|
60
|
+
tenant_id,
|
|
61
|
+
kind,
|
|
62
|
+
severity,
|
|
63
|
+
doc_id,
|
|
64
|
+
chunk_id,
|
|
65
|
+
evidence_json,
|
|
66
|
+
classifier_version,
|
|
67
|
+
request_id,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if severity == "block":
|
|
71
|
+
_log.warning(
|
|
72
|
+
"security_event_block",
|
|
73
|
+
extra={
|
|
74
|
+
"tenant_id": str(tenant_id),
|
|
75
|
+
"kind": kind,
|
|
76
|
+
"doc_id": str(doc_id) if doc_id else None,
|
|
77
|
+
"request_id": request_id,
|
|
78
|
+
},
|
|
79
|
+
)
|
omkit/sessions.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""packages/omur-sdk/omkit/sessions.py — Session store abstractions for services that persist short-lived per-user.
|
|
2
|
+
|
|
3
|
+
sessions. Two backends are provided:
|
|
4
|
+
|
|
5
|
+
- ``PostgresSessionStore`` (default) — stores sessions in the ``sessions``
|
|
6
|
+
table and lets Postgres expire them.
|
|
7
|
+
- ``RedisSessionStore`` — opt-in via ``SESSION_BACKEND=redis``; wraps
|
|
8
|
+
``redis.asyncio`` for legacy/low-latency deployments.
|
|
9
|
+
|
|
10
|
+
The ``Session`` dataclass is the wire shape exchanged between Store
|
|
11
|
+
implementations and callers.
|
|
12
|
+
|
|
13
|
+
exports: class NotFound | class Session | class SessionStore | class PostgresSessionStore | class RedisSessionStore | backend_from_env() | new_store()
|
|
14
|
+
rules: The session store implementation must support both PostgreSQL and Redis backends, with the backend selection determined at runtime via the `SESSION_BACKEND` environment variable. Any new session store implementation must conform to the `SessionStore` protocol and handle asynchronous operations correctly. The `Session` class and `NotFound` exception are central to the module's behavior and must remain consistent across all store implementations.
|
|
15
|
+
agent: ollama/qwen3-coder:latest | ollama | 2026-05-01 | codedna-cli | initial CodeDNA annotation pass
|
|
16
|
+
message:
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from typing import Any, Protocol
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotFound(Exception):
|
|
29
|
+
"""Raised when a Session lookup returns no (non-expired) row."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Session:
|
|
34
|
+
token: str
|
|
35
|
+
tenant_id: str
|
|
36
|
+
payload: dict[str, Any]
|
|
37
|
+
expires_at: datetime
|
|
38
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SessionStore(Protocol):
|
|
42
|
+
async def get(self, token: str, *, tenant_id: str | None = None) -> Session:
|
|
43
|
+
"""
|
|
44
|
+
Rules: The token parameter must be a valid non-empty string representing an authentication token. The tenant_id parameter is optional but if provided must be a non-empty string. The function returns a Session object containing the user's session data. The function may raise exceptions for invalid tokens, unauthorized access, or tenant-specific errors. The returned Session object must contain valid session data and is expected to be used for subsequent authenticated requests.
|
|
45
|
+
"""
|
|
46
|
+
...
|
|
47
|
+
async def put(self, session: Session) -> None:
|
|
48
|
+
"""
|
|
49
|
+
Rules: The session parameter must be a valid Session object that is not already associated with another async operation, and the function will asynchronously associate the session with the current object's context. The function must be called in an async context and will not raise exceptions for invalid session states, but may fail silently if the session cannot be properly associated.
|
|
50
|
+
"""
|
|
51
|
+
...
|
|
52
|
+
async def delete(self, token: str, *, tenant_id: str | None = None) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Rules: The delete method requires a valid token string and optional tenant_id, must be called asynchronously, and performs no return value operations. Implementations must handle token validation and tenant scoping logic while ensuring thread-safe deletion operations.
|
|
55
|
+
"""
|
|
56
|
+
...
|
|
57
|
+
async def list(self, tenant_id: str) -> list[Session]:
|
|
58
|
+
"""
|
|
59
|
+
Rules: The tenant_id parameter must be a non-empty string representing a valid tenant identifier. The function must return a list of Session objects associated with the specified tenant, and may raise exceptions if the tenant does not exist or if there are insufficient permissions to access the sessions.
|
|
60
|
+
"""
|
|
61
|
+
...
|
|
62
|
+
async def close(self) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Rules: Async close method must be idempotent and handle concurrent calls gracefully, ensuring all resources are properly released and no further operations should be performed after calling close. Implementations must not raise exceptions during cleanup, and the method should complete within a reasonable timeout period.
|
|
65
|
+
"""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_payload(value: Any) -> dict[str, Any]:
|
|
70
|
+
if isinstance(value, (bytes, bytearray)):
|
|
71
|
+
value = value.decode()
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
return json.loads(value)
|
|
74
|
+
return value or {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PostgresSessionStore:
|
|
78
|
+
"""Store backed by an asyncpg pool and the ``sessions`` table.
|
|
79
|
+
|
|
80
|
+
Contract with respect to Postgres RLS (``sessions_tenant_isolation``
|
|
81
|
+
policy):
|
|
82
|
+
|
|
83
|
+
- ``put(session)`` and ``list(tenant_id)`` always work because the
|
|
84
|
+
tenant is known; both run inside a transaction that sets
|
|
85
|
+
``app.tenant_id`` so the policy is satisfied under any role.
|
|
86
|
+
- ``get(token)`` and ``delete(token)`` accept the opaque token as
|
|
87
|
+
the capability. If the pool has ``SET ROLE omur_app`` applied, RLS
|
|
88
|
+
will filter the SELECT/DELETE to zero rows. Build the session
|
|
89
|
+
pool with :func:`new_session_pool` (no ``SET ROLE``; superuser
|
|
90
|
+
bypasses RLS) so token lookup crosses tenants.
|
|
91
|
+
|
|
92
|
+
Defense-in-depth: callers that already know the expected tenant may
|
|
93
|
+
pass ``tenant_id=`` to ``get``/``delete``; the store will then
|
|
94
|
+
verify/scope the operation in-Python so a stolen token can't be used
|
|
95
|
+
against a different tenant's row.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, pool):
|
|
99
|
+
self._pool = pool
|
|
100
|
+
|
|
101
|
+
async def get(self, token: str, *, tenant_id: str | None = None) -> Session:
|
|
102
|
+
"""
|
|
103
|
+
Rules: Function requires a valid token string and optional tenant_id; raises NotFound if token is not found or belongs to a different tenant. Returns a Session object with parsed payload, or raises NotFound if session is expired or not found.
|
|
104
|
+
"""
|
|
105
|
+
async with self._pool.acquire() as conn:
|
|
106
|
+
row = await conn.fetchrow(
|
|
107
|
+
"SELECT token, tenant_id::text, payload, created_at, expires_at "
|
|
108
|
+
"FROM sessions WHERE token = $1 AND expires_at > now()",
|
|
109
|
+
token,
|
|
110
|
+
)
|
|
111
|
+
if not row:
|
|
112
|
+
raise NotFound(token)
|
|
113
|
+
if tenant_id is not None and row["tenant_id"] != tenant_id:
|
|
114
|
+
# Token belongs to a different tenant — treat as missing.
|
|
115
|
+
raise NotFound(token)
|
|
116
|
+
return Session(
|
|
117
|
+
token=row["token"],
|
|
118
|
+
tenant_id=row["tenant_id"],
|
|
119
|
+
payload=_parse_payload(row["payload"]),
|
|
120
|
+
created_at=row["created_at"],
|
|
121
|
+
expires_at=row["expires_at"],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def put(self, s: Session) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Rules: Function accepts a Session object and stores it in the database, updating existing records if the token already exists. The implementation must ensure tenant isolation through transaction-local configuration and handle JSON serialization of the session payload.
|
|
127
|
+
"""
|
|
128
|
+
async with self._pool.acquire() as conn:
|
|
129
|
+
async with conn.transaction():
|
|
130
|
+
# Satisfy the sessions_tenant_isolation RLS policy
|
|
131
|
+
# regardless of whether the pool runs as omur_app or a
|
|
132
|
+
# BYPASSRLS superuser. set_config(..., true) is
|
|
133
|
+
# transaction-local so it doesn't leak across checkouts.
|
|
134
|
+
await conn.execute(
|
|
135
|
+
"SELECT set_config('app.tenant_id', $1, true)",
|
|
136
|
+
s.tenant_id,
|
|
137
|
+
)
|
|
138
|
+
await conn.execute(
|
|
139
|
+
"INSERT INTO sessions (token, tenant_id, payload, expires_at) "
|
|
140
|
+
"VALUES ($1, $2::uuid, $3::jsonb, $4) "
|
|
141
|
+
"ON CONFLICT (token) DO UPDATE SET "
|
|
142
|
+
"payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at",
|
|
143
|
+
s.token,
|
|
144
|
+
s.tenant_id,
|
|
145
|
+
json.dumps(s.payload),
|
|
146
|
+
s.expires_at,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
async def delete(self, token: str, *, tenant_id: str | None = None) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Rules: Function deletes a session token from the database, optionally scoped to a tenant ID; if tenant_id is provided, the operation runs within a transaction that sets the tenant context before deletion. The function requires a valid database connection pool and will raise exceptions on database errors or invalid inputs.
|
|
152
|
+
"""
|
|
153
|
+
async with self._pool.acquire() as conn:
|
|
154
|
+
if tenant_id is not None:
|
|
155
|
+
async with conn.transaction():
|
|
156
|
+
await conn.execute(
|
|
157
|
+
"SELECT set_config('app.tenant_id', $1, true)",
|
|
158
|
+
tenant_id,
|
|
159
|
+
)
|
|
160
|
+
await conn.execute(
|
|
161
|
+
"DELETE FROM sessions WHERE token = $1 AND tenant_id = $2::uuid",
|
|
162
|
+
token,
|
|
163
|
+
tenant_id,
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
await conn.execute("DELETE FROM sessions WHERE token = $1", token)
|
|
167
|
+
|
|
168
|
+
async def list(self, tenant_id: str) -> list[Session]:
|
|
169
|
+
"""
|
|
170
|
+
Rules: Function requires valid UUID tenant_id string input and returns list of Session objects ordered by creation time, with no side effects beyond database queries. Implementation must handle database connection and transaction management internally, and the tenant_id must exist in the database for meaningful results.
|
|
171
|
+
"""
|
|
172
|
+
async with self._pool.acquire() as conn:
|
|
173
|
+
async with conn.transaction():
|
|
174
|
+
await conn.execute(
|
|
175
|
+
"SELECT set_config('app.tenant_id', $1, true)",
|
|
176
|
+
tenant_id,
|
|
177
|
+
)
|
|
178
|
+
rows = await conn.fetch(
|
|
179
|
+
"SELECT token, tenant_id::text, payload, created_at, expires_at "
|
|
180
|
+
"FROM sessions WHERE tenant_id = $1::uuid AND expires_at > now() "
|
|
181
|
+
"ORDER BY created_at DESC",
|
|
182
|
+
tenant_id,
|
|
183
|
+
)
|
|
184
|
+
return [
|
|
185
|
+
Session(
|
|
186
|
+
token=r["token"],
|
|
187
|
+
tenant_id=r["tenant_id"],
|
|
188
|
+
payload=_parse_payload(r["payload"]),
|
|
189
|
+
created_at=r["created_at"],
|
|
190
|
+
expires_at=r["expires_at"],
|
|
191
|
+
)
|
|
192
|
+
for r in rows
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
async def close(self) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Rules: Async close method should gracefully terminate any ongoing operations and release all resources, with no return value expected. Implementations must ensure thread safety and idempotency, allowing multiple calls to succeed without error.
|
|
198
|
+
"""
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class RedisSessionStore:
|
|
203
|
+
"""Store backed by redis.asyncio. List uses SCAN and filters client-side."""
|
|
204
|
+
|
|
205
|
+
def __init__(self, redis_client, key_prefix: str = "omur:session:"):
|
|
206
|
+
self._r = redis_client
|
|
207
|
+
self._prefix = key_prefix
|
|
208
|
+
|
|
209
|
+
def _key(self, token: str) -> str:
|
|
210
|
+
return self._prefix + token
|
|
211
|
+
|
|
212
|
+
async def get(self, token: str) -> Session:
|
|
213
|
+
"""
|
|
214
|
+
Rules: Function requires token string input and returns a Session object with parsed datetime fields; raises NotFound exception when token is not found in storage. Function performs async Redis get operation and JSON deserialization, with caller responsible for handling the NotFound exception and ensuring token exists before calling.
|
|
215
|
+
"""
|
|
216
|
+
b = await self._r.get(self._key(token))
|
|
217
|
+
if b is None:
|
|
218
|
+
raise NotFound(token)
|
|
219
|
+
d = json.loads(b)
|
|
220
|
+
d["expires_at"] = datetime.fromisoformat(d["expires_at"])
|
|
221
|
+
d["created_at"] = datetime.fromisoformat(d["created_at"])
|
|
222
|
+
return Session(**d)
|
|
223
|
+
|
|
224
|
+
async def put(self, s: Session) -> None:
|
|
225
|
+
"""
|
|
226
|
+
Rules: Function requires Session object with valid expires_at, tenant_id, and token attributes; stores session data with TTL expiration in Redis using JSON serialization; caller must ensure Session attributes are properly initialized and that _r.setex method is available.
|
|
227
|
+
"""
|
|
228
|
+
ttl = int((s.expires_at - datetime.now(timezone.utc)).total_seconds())
|
|
229
|
+
if ttl < 1:
|
|
230
|
+
ttl = 1
|
|
231
|
+
d = {
|
|
232
|
+
"token": s.token,
|
|
233
|
+
"tenant_id": s.tenant_id,
|
|
234
|
+
"payload": s.payload,
|
|
235
|
+
"expires_at": s.expires_at.isoformat(),
|
|
236
|
+
"created_at": s.created_at.isoformat(),
|
|
237
|
+
}
|
|
238
|
+
await self._r.setex(self._key(s.token), ttl, json.dumps(d))
|
|
239
|
+
|
|
240
|
+
async def delete(self, token: str) -> None:
|
|
241
|
+
"""
|
|
242
|
+
Rules: The delete method requires a valid string token parameter and asynchronously removes the corresponding key-value pair from the underlying storage system. The method has no return value and may raise exceptions if the token is invalid or the storage operation fails.
|
|
243
|
+
"""
|
|
244
|
+
await self._r.delete(self._key(token))
|
|
245
|
+
|
|
246
|
+
async def list(self, tenant_id: str) -> list[Session]:
|
|
247
|
+
"""
|
|
248
|
+
Rules: Function must be called with a valid tenant_id string, returns list of Session objects belonging to that tenant, and may raise NotFound exception during iteration. Function performs asynchronous operations and should be called within an async context.
|
|
249
|
+
"""
|
|
250
|
+
out: list[Session] = []
|
|
251
|
+
async for key in self._r.scan_iter(self._prefix + "*"):
|
|
252
|
+
key_str = key.decode() if isinstance(key, (bytes, bytearray)) else key
|
|
253
|
+
try:
|
|
254
|
+
s = await self.get(key_str.removeprefix(self._prefix))
|
|
255
|
+
except NotFound:
|
|
256
|
+
continue
|
|
257
|
+
if s.tenant_id == tenant_id:
|
|
258
|
+
out.append(s)
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
async def close(self) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Rules: The close method must be called to properly terminate the underlying resource manager, and implementations must ensure the async context manager is safely closed without leaving resources open. The method should handle any cleanup operations asynchronously and not raise exceptions during the closing process.
|
|
264
|
+
"""
|
|
265
|
+
await self._r.aclose()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def backend_from_env() -> str:
|
|
269
|
+
"""
|
|
270
|
+
Rules: Function reads SESSION_BACKEND environment variable and returns "postgres" if not set or invalid, raising ValueError for unknown values. Caller must ensure environment variable is properly set or handle potential ValueError exceptions.
|
|
271
|
+
"""
|
|
272
|
+
v = os.getenv("SESSION_BACKEND", "postgres")
|
|
273
|
+
if v not in {"postgres", "redis"}:
|
|
274
|
+
raise ValueError(f"unknown SESSION_BACKEND: {v}")
|
|
275
|
+
return v
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def new_store(*, pool=None, redis_client=None) -> SessionStore:
|
|
279
|
+
"""
|
|
280
|
+
Rules: Function requires either pool= or redis_client= arguments depending on the backend type determined by backend_from_env(), with postgres backend requiring pool and redis backend either accepting a redis_client or creating one from environment variables. The function may raise ValueError for missing required arguments or RuntimeError for unexpected backend states.
|
|
281
|
+
"""
|
|
282
|
+
backend = backend_from_env()
|
|
283
|
+
if backend == "postgres":
|
|
284
|
+
if pool is None:
|
|
285
|
+
raise ValueError("postgres backend requires pool=")
|
|
286
|
+
return PostgresSessionStore(pool)
|
|
287
|
+
if backend == "redis":
|
|
288
|
+
if redis_client is None:
|
|
289
|
+
import redis.asyncio as aioredis
|
|
290
|
+
|
|
291
|
+
host = os.getenv("VALKEY_HOST", "valkey")
|
|
292
|
+
port = os.getenv("VALKEY_PORT", "6379")
|
|
293
|
+
password = os.getenv("VALKEY_PASSWORD") or None
|
|
294
|
+
url = (
|
|
295
|
+
f"redis://:{password}@{host}:{port}"
|
|
296
|
+
if password
|
|
297
|
+
else f"redis://{host}:{port}"
|
|
298
|
+
)
|
|
299
|
+
redis_client = aioredis.from_url(url)
|
|
300
|
+
return RedisSessionStore(redis_client)
|
|
301
|
+
raise RuntimeError("unreachable")
|