boundedllm 0.4.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.
- boundedllm/__init__.py +86 -0
- boundedllm/adapters/__init__.py +6 -0
- boundedllm/adapters/http_model.py +45 -0
- boundedllm/adapters/otlp.py +104 -0
- boundedllm/adapters/sql/__init__.py +136 -0
- boundedllm/adapters/sql/alembic.ini +39 -0
- boundedllm/adapters/sql/migrations/env.py +59 -0
- boundedllm/adapters/sql/migrations/script.py.mako +24 -0
- boundedllm/adapters/sql/migrations/versions/20260916_71f5e126461a_baseline_schema.py +183 -0
- boundedllm/adapters/sql/schema.py +123 -0
- boundedllm/adapters/sql/store.py +1206 -0
- boundedllm/audit.py +156 -0
- boundedllm/authz.py +42 -0
- boundedllm/budget.py +20 -0
- boundedllm/cli.py +390 -0
- boundedllm/config.py +171 -0
- boundedllm/context.py +26 -0
- boundedllm/contrib/__init__.py +22 -0
- boundedllm/contrib/anthropic.py +122 -0
- boundedllm/contrib/ledger.py +181 -0
- boundedllm/contrib/pgvector.py +250 -0
- boundedllm/egress.py +103 -0
- boundedllm/engine.py +327 -0
- boundedllm/errors.py +33 -0
- boundedllm/identity.py +125 -0
- boundedllm/limits.py +83 -0
- boundedllm/main.py +200 -0
- boundedllm/middleware.py +87 -0
- boundedllm/model_gateway.py +52 -0
- boundedllm/models.py +122 -0
- boundedllm/network.py +27 -0
- boundedllm/normalize.py +32 -0
- boundedllm/output_firewall.py +87 -0
- boundedllm/parsing.py +26 -0
- boundedllm/ports.py +118 -0
- boundedllm/prompts.py +14 -0
- boundedllm/retrieval.py +39 -0
- boundedllm/risk.py +54 -0
- boundedllm/support/__init__.py +44 -0
- boundedllm/support/authz.py +16 -0
- boundedllm/support/gateway.py +48 -0
- boundedllm/support/models.py +49 -0
- boundedllm/support/policy.py +67 -0
- boundedllm/support/projections.py +8 -0
- boundedllm/support/schema.py +45 -0
- boundedllm/support/store.py +257 -0
- boundedllm/telemetry.py +82 -0
- boundedllm/tool_gateway.py +33 -0
- boundedllm-0.4.0.dist-info/METADATA +373 -0
- boundedllm-0.4.0.dist-info/RECORD +53 -0
- boundedllm-0.4.0.dist-info/WHEEL +4 -0
- boundedllm-0.4.0.dist-info/entry_points.txt +2 -0
- boundedllm-0.4.0.dist-info/licenses/LICENSE +202 -0
boundedllm/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Deterministic security boundaries for enterprise LLM applications.
|
|
2
|
+
|
|
3
|
+
Importing this package starts no servers, reads no credentials, and pulls in no
|
|
4
|
+
database driver. The core depends on the storage contracts in
|
|
5
|
+
``boundedllm.ports``; a host implements them over whatever it already runs, or
|
|
6
|
+
imports ``boundedllm.adapters.sql`` for a working implementation.
|
|
7
|
+
|
|
8
|
+
from boundedllm import Audit, Guard, Limits
|
|
9
|
+
from boundedllm.adapters.sql import SQLStore, sql_ports
|
|
10
|
+
|
|
11
|
+
audit = Audit(key)
|
|
12
|
+
store = SQLStore(settings, audit)
|
|
13
|
+
guard = Guard(provider=provider, signer=audit, **sql_ports(store))
|
|
14
|
+
|
|
15
|
+
What this package is for: bounding what a compromised agent can reach and do.
|
|
16
|
+
Identity, tenant isolation, retrieval ACLs, tool authorization, human consent,
|
|
17
|
+
idempotency, quotas, and a tamper-evident ledger are deterministic application
|
|
18
|
+
code here, and they hold whether or not the model has been manipulated.
|
|
19
|
+
|
|
20
|
+
What it is not: a prompt-injection detector. ``boundedllm.risk`` is a tripwire for
|
|
21
|
+
blunt override attempts, and the bundled ``PatternScanner`` is a test baseline,
|
|
22
|
+
not a DLP product. Both are signals. The boundaries above are the controls.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
__version__ = "0.4.0"
|
|
26
|
+
|
|
27
|
+
from boundedllm.audit import Audit
|
|
28
|
+
from boundedllm.engine import Guard
|
|
29
|
+
from boundedllm.errors import (
|
|
30
|
+
Conflict,
|
|
31
|
+
Denied,
|
|
32
|
+
GuardError,
|
|
33
|
+
InvalidToken,
|
|
34
|
+
LimitExceeded,
|
|
35
|
+
OutputBlocked,
|
|
36
|
+
Unavailable,
|
|
37
|
+
)
|
|
38
|
+
from boundedllm.limits import Limits
|
|
39
|
+
from boundedllm.model_gateway import ModelProvider, ModelRequest
|
|
40
|
+
from boundedllm.models import (
|
|
41
|
+
ChatRequest,
|
|
42
|
+
ChatResponse,
|
|
43
|
+
Document,
|
|
44
|
+
IngestRequest,
|
|
45
|
+
Principal,
|
|
46
|
+
ProposedToolCall,
|
|
47
|
+
ToolExecutionResult,
|
|
48
|
+
TurnFlags,
|
|
49
|
+
)
|
|
50
|
+
from boundedllm.output_firewall import Scanner
|
|
51
|
+
from boundedllm.ports import Documents, Ledger, Operations, Quotas, Signer
|
|
52
|
+
from boundedllm.telemetry import Observability, OpenTelemetryObservability
|
|
53
|
+
from boundedllm.tool_gateway import ToolExecutor
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"Audit",
|
|
57
|
+
"ChatRequest",
|
|
58
|
+
"ChatResponse",
|
|
59
|
+
"Conflict",
|
|
60
|
+
"Denied",
|
|
61
|
+
"Document",
|
|
62
|
+
"Documents",
|
|
63
|
+
"Guard",
|
|
64
|
+
"GuardError",
|
|
65
|
+
"IngestRequest",
|
|
66
|
+
"InvalidToken",
|
|
67
|
+
"Ledger",
|
|
68
|
+
"LimitExceeded",
|
|
69
|
+
"Limits",
|
|
70
|
+
"ModelProvider",
|
|
71
|
+
"ModelRequest",
|
|
72
|
+
"Observability",
|
|
73
|
+
"OpenTelemetryObservability",
|
|
74
|
+
"Operations",
|
|
75
|
+
"OutputBlocked",
|
|
76
|
+
"Principal",
|
|
77
|
+
"ProposedToolCall",
|
|
78
|
+
"Quotas",
|
|
79
|
+
"Scanner",
|
|
80
|
+
"Signer",
|
|
81
|
+
"ToolExecutionResult",
|
|
82
|
+
"ToolExecutor",
|
|
83
|
+
"TurnFlags",
|
|
84
|
+
"Unavailable",
|
|
85
|
+
"__version__",
|
|
86
|
+
]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Concrete implementations of the core ports and provider contracts.
|
|
2
|
+
|
|
3
|
+
Everything here is optional. The core depends on ``boundedllm.ports``; these
|
|
4
|
+
modules depend on SQLAlchemy, httpx, and deployment configuration, which is why
|
|
5
|
+
they are kept off the core import path.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""A generic HTTPS model transport for deployments without a vendor SDK.
|
|
2
|
+
|
|
3
|
+
Kept out of the core so importing ``boundedllm`` costs no HTTP client and no
|
|
4
|
+
deployment configuration. A host that already uses a vendor SDK should implement
|
|
5
|
+
``ModelProvider`` against it directly rather than route through this contract.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from boundedllm.config import Settings
|
|
11
|
+
from boundedllm.errors import Unavailable
|
|
12
|
+
from boundedllm.model_gateway import ModelRequest
|
|
13
|
+
from boundedllm.network import bounded_json
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HTTPModelProvider:
|
|
17
|
+
"""Generic enterprise endpoint contract, not a vendor-specific API implementation."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, settings: Settings, client: httpx.AsyncClient):
|
|
20
|
+
if not settings.model_url:
|
|
21
|
+
raise ValueError("model_url required")
|
|
22
|
+
self.settings = settings
|
|
23
|
+
self.client = client
|
|
24
|
+
|
|
25
|
+
async def complete(self, request: ModelRequest) -> str:
|
|
26
|
+
headers = {}
|
|
27
|
+
if self.settings.model_api_key:
|
|
28
|
+
headers["Authorization"] = "Bearer " + self.settings.model_api_key.get_secret_value()
|
|
29
|
+
data = await bounded_json(
|
|
30
|
+
self.client,
|
|
31
|
+
"POST",
|
|
32
|
+
self.settings.model_url,
|
|
33
|
+
max_bytes=131072,
|
|
34
|
+
headers=headers,
|
|
35
|
+
json={
|
|
36
|
+
"model": request.model,
|
|
37
|
+
"system": request.system,
|
|
38
|
+
"user": request.user,
|
|
39
|
+
"max_output_tokens": request.max_output_tokens,
|
|
40
|
+
"temperature": 0,
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
if set(data) != {"text"} or not isinstance(data["text"], str):
|
|
44
|
+
raise Unavailable("MODEL_RESPONSE_SCHEMA")
|
|
45
|
+
return data["text"]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""At-least-once delivery of signed ledger records to an OTLP/HTTP collector.
|
|
2
|
+
|
|
3
|
+
The request path writes the ledger and its outbox in one database transaction.
|
|
4
|
+
This worker drains that outbox and marks records exported only after the
|
|
5
|
+
collector acknowledges them, so a delivery failure never loses evidence and a
|
|
6
|
+
retry never loses a record. Collectors and SIEMs deduplicate on ``event_id``.
|
|
7
|
+
|
|
8
|
+
Kept out of the core because it needs an HTTP client and a concrete store.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from boundedllm.adapters.sql.store import SQLStore
|
|
18
|
+
from boundedllm.config import require_https
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SecurityEventSink(Protocol):
|
|
22
|
+
"""Small adapter surface for an OTel Collector or enterprise SIEM gateway."""
|
|
23
|
+
|
|
24
|
+
def export(self, events: list[dict]) -> None: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OpenTelemetryHTTPSink:
|
|
28
|
+
"""Send metadata-only events using OTLP/HTTP JSON to ``/v1/logs``."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, endpoint: str, authorization: str | None = None):
|
|
31
|
+
self.endpoint = require_https(endpoint)
|
|
32
|
+
self.authorization = authorization
|
|
33
|
+
|
|
34
|
+
def export(self, events: list[dict]) -> None:
|
|
35
|
+
if not events:
|
|
36
|
+
return
|
|
37
|
+
records = []
|
|
38
|
+
for envelope in events:
|
|
39
|
+
payload = json.loads(envelope["payload"])
|
|
40
|
+
attributes = [
|
|
41
|
+
{"key": "security.event_id", "value": {"stringValue": envelope["event_id"]}},
|
|
42
|
+
{"key": "security.tenant_id", "value": {"stringValue": envelope["tenant_id"]}},
|
|
43
|
+
{"key": "security.sequence", "value": {"intValue": str(envelope["sequence"])}},
|
|
44
|
+
{"key": "security.record_hash", "value": {"stringValue": envelope["record_hash"]}},
|
|
45
|
+
{"key": "security.key_id", "value": {"stringValue": envelope["key_id"]}},
|
|
46
|
+
]
|
|
47
|
+
records.append(
|
|
48
|
+
{
|
|
49
|
+
"timeUnixNano": str(int(payload["timestamp"] * 1_000_000_000)),
|
|
50
|
+
"observedTimeUnixNano": str(time.time_ns()),
|
|
51
|
+
"severityText": str(payload.get("severity", "INFO")).upper(),
|
|
52
|
+
"body": {"stringValue": envelope["payload"]},
|
|
53
|
+
"attributes": attributes,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
body = {
|
|
57
|
+
"resourceLogs": [
|
|
58
|
+
{
|
|
59
|
+
"resource": {
|
|
60
|
+
"attributes": [
|
|
61
|
+
{
|
|
62
|
+
"key": "service.name",
|
|
63
|
+
"value": {"stringValue": "boundedllm"},
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
"scopeLogs": [
|
|
68
|
+
{
|
|
69
|
+
"scope": {"name": "boundedllm.audit"},
|
|
70
|
+
"logRecords": records,
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
headers = {"Content-Type": "application/json"}
|
|
77
|
+
if self.authorization:
|
|
78
|
+
headers["Authorization"] = self.authorization
|
|
79
|
+
with httpx.Client(
|
|
80
|
+
timeout=httpx.Timeout(15, connect=5),
|
|
81
|
+
follow_redirects=False,
|
|
82
|
+
trust_env=False,
|
|
83
|
+
) as client:
|
|
84
|
+
with client.stream("POST", self.endpoint, headers=headers, json=body) as response:
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
received = 0
|
|
87
|
+
for chunk in response.iter_bytes():
|
|
88
|
+
received += len(chunk)
|
|
89
|
+
if received > 65536:
|
|
90
|
+
raise ValueError("OTLP acknowledgement exceeded 64 KiB")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def export_pending(store: SQLStore, tenant_id: str, sink: SecurityEventSink, limit: int = 100) -> int:
|
|
94
|
+
"""Deliver one bounded batch and acknowledge its exact hashes after success."""
|
|
95
|
+
events = store.pending_audit_exports(tenant_id, limit)
|
|
96
|
+
if not events:
|
|
97
|
+
return 0
|
|
98
|
+
try:
|
|
99
|
+
sink.export(events)
|
|
100
|
+
except Exception:
|
|
101
|
+
store.mark_audit_export_failed(tenant_id, events)
|
|
102
|
+
raise
|
|
103
|
+
store.mark_audit_exported(tenant_id, events)
|
|
104
|
+
return len(events)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Batteries-included SQL implementation of every core port.
|
|
2
|
+
|
|
3
|
+
Use this when you want the package to own its storage. A host that already has a
|
|
4
|
+
document store, an audit pipeline, or an authorization service should implement
|
|
5
|
+
``boundedllm.ports`` against those instead; nothing in the core knows this module
|
|
6
|
+
exists.
|
|
7
|
+
|
|
8
|
+
The store is synchronous SQLAlchemy, so this adapter is where the threading
|
|
9
|
+
policy lives. Every call crosses into a worker thread through
|
|
10
|
+
``asyncio.to_thread``, which means the default executor's thread count is the
|
|
11
|
+
real concurrency ceiling for database work. Raise it deliberately for a
|
|
12
|
+
high-throughput deployment:
|
|
13
|
+
|
|
14
|
+
import asyncio, concurrent.futures
|
|
15
|
+
loop = asyncio.get_running_loop()
|
|
16
|
+
loop.set_default_executor(concurrent.futures.ThreadPoolExecutor(max_workers=64))
|
|
17
|
+
|
|
18
|
+
Putting that decision here, rather than in the request path, is the point of the
|
|
19
|
+
port boundary: swapping in an async driver changes this file and nothing else.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from boundedllm.adapters.sql.schema import metadata
|
|
26
|
+
from boundedllm.adapters.sql.store import SQLStore
|
|
27
|
+
from boundedllm.models import ChatRequest, ChatResponse, Document, Principal, RequestContext
|
|
28
|
+
|
|
29
|
+
ALEMBIC_INI = Path(__file__).with_name("alembic.ini")
|
|
30
|
+
|
|
31
|
+
__all__ = ["ALEMBIC_INI", "SQLAdapter", "SQLStore", "metadata", "migrate", "sql_ports", "stamp"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _alembic_config():
|
|
35
|
+
from alembic.config import Config
|
|
36
|
+
|
|
37
|
+
config = Config(str(ALEMBIC_INI))
|
|
38
|
+
# script_location resolves against the ini's own directory so migrations are
|
|
39
|
+
# found inside an installed wheel, not just in a source checkout.
|
|
40
|
+
config.set_main_option("script_location", str(ALEMBIC_INI.parent / "migrations"))
|
|
41
|
+
return config
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def migrate(revision: str = "head") -> None:
|
|
45
|
+
"""Run versioned migrations against GUARD_DATABASE_URL.
|
|
46
|
+
|
|
47
|
+
This is the production path. ``SQLStore.initialize`` is a development
|
|
48
|
+
convenience that creates tables directly and leaves no reviewable history,
|
|
49
|
+
which is not something a DBA team will accept on a real database.
|
|
50
|
+
"""
|
|
51
|
+
from alembic import command
|
|
52
|
+
|
|
53
|
+
command.upgrade(_alembic_config(), revision)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def stamp(revision: str = "head") -> None:
|
|
57
|
+
"""Record a revision without running it, for a database created before Alembic."""
|
|
58
|
+
from alembic import command
|
|
59
|
+
|
|
60
|
+
command.stamp(_alembic_config(), revision)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SQLAdapter:
|
|
64
|
+
"""Implements Ledger, Quotas, Operations, and Documents over one SQL store."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, store: SQLStore):
|
|
67
|
+
self.store = store
|
|
68
|
+
|
|
69
|
+
# Ledger ---------------------------------------------------------------
|
|
70
|
+
async def event(self, ctx: RequestContext, event: str, **fields) -> None:
|
|
71
|
+
await asyncio.to_thread(self.store.event, ctx, event, **fields)
|
|
72
|
+
|
|
73
|
+
# Quotas ---------------------------------------------------------------
|
|
74
|
+
async def throttle(self, principal: Principal) -> None:
|
|
75
|
+
await asyncio.to_thread(self.store.throttle, principal)
|
|
76
|
+
|
|
77
|
+
async def reserve_model_cost(self, ctx: RequestContext) -> None:
|
|
78
|
+
await asyncio.to_thread(self.store.reserve_model_cost, ctx)
|
|
79
|
+
|
|
80
|
+
# Operations -----------------------------------------------------------
|
|
81
|
+
async def claim(self, ctx: RequestContext, request: ChatRequest) -> ChatResponse | None:
|
|
82
|
+
return await asyncio.to_thread(self.store.claim_operation, ctx, request)
|
|
83
|
+
|
|
84
|
+
async def finish(
|
|
85
|
+
self,
|
|
86
|
+
ctx: RequestContext,
|
|
87
|
+
operation_id: str,
|
|
88
|
+
response: ChatResponse | None,
|
|
89
|
+
docs: list[Document] | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
await asyncio.to_thread(self.store.finish_operation, ctx, operation_id, response, docs)
|
|
92
|
+
|
|
93
|
+
# Documents ------------------------------------------------------------
|
|
94
|
+
async def search(
|
|
95
|
+
self,
|
|
96
|
+
principal: Principal,
|
|
97
|
+
query: str,
|
|
98
|
+
limit: int,
|
|
99
|
+
max_level: int,
|
|
100
|
+
conversation_id: str,
|
|
101
|
+
attachment_ids: list[str],
|
|
102
|
+
) -> list[Document]:
|
|
103
|
+
return await asyncio.to_thread(
|
|
104
|
+
self.store.search, principal, query, limit, max_level, conversation_id, attachment_ids
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
async def quarantine(self, ctx: RequestContext, doc_id: str, signals: list[str]) -> None:
|
|
108
|
+
await asyncio.to_thread(self.store.quarantine_document, ctx, doc_id, signals)
|
|
109
|
+
|
|
110
|
+
async def attachment_results(
|
|
111
|
+
self, principal: Principal, conversation_id: str, attachment_ids: list[str]
|
|
112
|
+
) -> list[dict]:
|
|
113
|
+
return await asyncio.to_thread(
|
|
114
|
+
self.store.attachment_results, principal, conversation_id, attachment_ids
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Convenience used by host tool executors, which are async by contract.
|
|
118
|
+
async def get_account(self, principal: Principal, account_id: str):
|
|
119
|
+
return await asyncio.to_thread(self.store.get_account, principal, account_id)
|
|
120
|
+
|
|
121
|
+
async def propose_waiver(self, ctx, request, args, flags, policy) -> dict:
|
|
122
|
+
return await asyncio.to_thread(self.store.propose_waiver, ctx, request, args, flags, policy)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def sql_ports(store: SQLStore) -> dict:
|
|
126
|
+
"""Spread into ``Guard(...)`` so one adapter satisfies every port by name.
|
|
127
|
+
|
|
128
|
+
guard = Guard(provider=provider, signer=audit, **sql_ports(store))
|
|
129
|
+
"""
|
|
130
|
+
adapter = SQLAdapter(store)
|
|
131
|
+
return {
|
|
132
|
+
"ledger": adapter,
|
|
133
|
+
"quotas": adapter,
|
|
134
|
+
"operations": adapter,
|
|
135
|
+
"documents": adapter,
|
|
136
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# The database URL is resolved from GUARD_DATABASE_URL in env.py, never here, so
|
|
2
|
+
# this file carries no credentials and is safe to ship inside the wheel.
|
|
3
|
+
[alembic]
|
|
4
|
+
script_location = %(here)s/migrations
|
|
5
|
+
prepend_sys_path = .
|
|
6
|
+
file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s_%%(slug)s
|
|
7
|
+
|
|
8
|
+
[loggers]
|
|
9
|
+
keys = root,sqlalchemy,alembic
|
|
10
|
+
|
|
11
|
+
[handlers]
|
|
12
|
+
keys = console
|
|
13
|
+
|
|
14
|
+
[formatters]
|
|
15
|
+
keys = generic
|
|
16
|
+
|
|
17
|
+
[logger_root]
|
|
18
|
+
level = WARNING
|
|
19
|
+
handlers = console
|
|
20
|
+
qualname =
|
|
21
|
+
|
|
22
|
+
[logger_sqlalchemy]
|
|
23
|
+
level = WARNING
|
|
24
|
+
handlers =
|
|
25
|
+
qualname = sqlalchemy.engine
|
|
26
|
+
|
|
27
|
+
[logger_alembic]
|
|
28
|
+
level = INFO
|
|
29
|
+
handlers =
|
|
30
|
+
qualname = alembic
|
|
31
|
+
|
|
32
|
+
[handler_console]
|
|
33
|
+
class = StreamHandler
|
|
34
|
+
args = (sys.stderr,)
|
|
35
|
+
level = NOTSET
|
|
36
|
+
formatter = generic
|
|
37
|
+
|
|
38
|
+
[formatter_generic]
|
|
39
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Alembic environment for the bundled SQL adapter.
|
|
2
|
+
|
|
3
|
+
The database URL comes from ``GUARD_DATABASE_URL`` through ``Settings`` so a
|
|
4
|
+
migration run cannot be pointed somewhere the application itself would refuse,
|
|
5
|
+
and so the URL never has to be written into a config file in the repository.
|
|
6
|
+
|
|
7
|
+
Importing the support schema registers the reference domain's tables on the same
|
|
8
|
+
MetaData. A deployment that does not use that domain can remove the import; its
|
|
9
|
+
tables are then absent from autogenerate and from the migration history.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from alembic import context
|
|
13
|
+
from sqlalchemy import engine_from_config, pool
|
|
14
|
+
|
|
15
|
+
import boundedllm.support.schema # noqa: F401 registers the reference domain tables
|
|
16
|
+
from boundedllm.adapters.sql.schema import metadata
|
|
17
|
+
from boundedllm.config import Settings
|
|
18
|
+
|
|
19
|
+
config = context.config
|
|
20
|
+
target_metadata = metadata
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _url() -> str:
|
|
24
|
+
return Settings().database_url.get_secret_value()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_migrations_offline() -> None:
|
|
28
|
+
context.configure(
|
|
29
|
+
url=_url(),
|
|
30
|
+
target_metadata=target_metadata,
|
|
31
|
+
literal_binds=True,
|
|
32
|
+
dialect_opts={"paramstyle": "named"},
|
|
33
|
+
compare_type=True,
|
|
34
|
+
)
|
|
35
|
+
with context.begin_transaction():
|
|
36
|
+
context.run_migrations()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_migrations_online() -> None:
|
|
40
|
+
section = config.get_section(config.config_ini_section, {})
|
|
41
|
+
section["sqlalchemy.url"] = _url()
|
|
42
|
+
connectable = engine_from_config(section, prefix="sqlalchemy.", poolclass=pool.NullPool)
|
|
43
|
+
with connectable.connect() as connection:
|
|
44
|
+
context.configure(
|
|
45
|
+
connection=connection,
|
|
46
|
+
target_metadata=target_metadata,
|
|
47
|
+
compare_type=True,
|
|
48
|
+
# SQLite cannot ALTER most columns in place; batch mode rewrites the
|
|
49
|
+
# table instead so the same revision runs on the demo and on Postgres.
|
|
50
|
+
render_as_batch=connection.dialect.name == "sqlite",
|
|
51
|
+
)
|
|
52
|
+
with context.begin_transaction():
|
|
53
|
+
context.run_migrations()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if context.is_offline_mode():
|
|
57
|
+
run_migrations_offline()
|
|
58
|
+
else:
|
|
59
|
+
run_migrations_online()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
import sqlalchemy as sa
|
|
11
|
+
from alembic import op
|
|
12
|
+
|
|
13
|
+
revision: str = ${repr(up_revision)}
|
|
14
|
+
down_revision: str | None = ${repr(down_revision)}
|
|
15
|
+
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
|
16
|
+
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def upgrade() -> None:
|
|
20
|
+
${upgrades if upgrades else "pass"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def downgrade() -> None:
|
|
24
|
+
${downgrades if downgrades else "pass"}
|