terp-core 0.1.0__tar.gz

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.
Files changed (37) hide show
  1. terp_core-0.1.0/.gitignore +47 -0
  2. terp_core-0.1.0/PKG-INFO +11 -0
  3. terp_core-0.1.0/pyproject.toml +26 -0
  4. terp_core-0.1.0/src/terp/core/__init__.py +272 -0
  5. terp_core-0.1.0/src/terp/core/_internal/__init__.py +11 -0
  6. terp_core-0.1.0/src/terp/core/_internal/discovery.py +148 -0
  7. terp_core-0.1.0/src/terp/core/_internal/engine.py +73 -0
  8. terp_core-0.1.0/src/terp/core/_internal/job_runtime.py +113 -0
  9. terp_core-0.1.0/src/terp/core/_internal/middleware.py +597 -0
  10. terp_core-0.1.0/src/terp/core/_internal/session_guard.py +407 -0
  11. terp_core-0.1.0/src/terp/core/app.py +1214 -0
  12. terp_core-0.1.0/src/terp/core/audit.py +313 -0
  13. terp_core-0.1.0/src/terp/core/base_models.py +252 -0
  14. terp_core-0.1.0/src/terp/core/base_service.py +415 -0
  15. terp_core-0.1.0/src/terp/core/cache.py +162 -0
  16. terp_core-0.1.0/src/terp/core/config.py +166 -0
  17. terp_core-0.1.0/src/terp/core/control_plane.py +101 -0
  18. terp_core-0.1.0/src/terp/core/crud.py +115 -0
  19. terp_core-0.1.0/src/terp/core/db.py +38 -0
  20. terp_core-0.1.0/src/terp/core/errors.py +128 -0
  21. terp_core-0.1.0/src/terp/core/events.py +279 -0
  22. terp_core-0.1.0/src/terp/core/health.py +59 -0
  23. terp_core-0.1.0/src/terp/core/idempotency.py +248 -0
  24. terp_core-0.1.0/src/terp/core/jobs.py +498 -0
  25. terp_core-0.1.0/src/terp/core/logging.py +204 -0
  26. terp_core-0.1.0/src/terp/core/migrations.py +254 -0
  27. terp_core-0.1.0/src/terp/core/module_spec.py +184 -0
  28. terp_core-0.1.0/src/terp/core/object_authz.py +133 -0
  29. terp_core-0.1.0/src/terp/core/pagination.py +206 -0
  30. terp_core-0.1.0/src/terp/core/passwords.py +189 -0
  31. terp_core-0.1.0/src/terp/core/permissions.py +201 -0
  32. terp_core-0.1.0/src/terp/core/py.typed +0 -0
  33. terp_core-0.1.0/src/terp/core/scheduling.py +208 -0
  34. terp_core-0.1.0/src/terp/core/scoping.py +80 -0
  35. terp_core-0.1.0/src/terp/core/secrets.py +173 -0
  36. terp_core-0.1.0/src/terp/core/security.py +256 -0
  37. terp_core-0.1.0/src/terp/core/throttling.py +143 -0
@@ -0,0 +1,47 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ .venv-*/
10
+ venv/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+
17
+ # uv
18
+ uv.lock
19
+
20
+ # Node
21
+ node_modules/
22
+ .pnpm-store/
23
+ *.tsbuildinfo
24
+
25
+ # Playwright (conformance e2e) artifacts
26
+ test-results/
27
+ playwright-report/
28
+ blob-report/
29
+ playwright/.cache/
30
+ .last-run.json
31
+
32
+ # Local frontend template render checks
33
+ apps/example/_frontend_tpl_check/
34
+
35
+ # Editor / OS
36
+ .DS_Store
37
+ .idea/
38
+ *.local
39
+
40
+ # Local environment overrides — never commit (a real .env may hold SECRET_KEY).
41
+ # The tracked template is `.env.example`.
42
+ .env
43
+ .env.*
44
+ !.env.example
45
+ !.env.example.jinja
46
+ # Rendered app-declared variables (environment.schema.json) — may hold secrets.
47
+ .app.env
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-core
3
+ Version: 0.1.0
4
+ Summary: Terp platform kernel — base classes, ModuleSpec, errors, pagination, DB session seam, secure-by-default config.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: fastapi>=0.115
8
+ Requires-Dist: pydantic-settings>=2.4
9
+ Requires-Dist: sqlmodel>=0.0.22
10
+ Provides-Extra: secrets
11
+ Requires-Dist: cryptography>=48.0.1; extra == 'secrets'
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "terp-core"
7
+ version = "0.1.0"
8
+ description = "Terp platform kernel — base classes, ModuleSpec, errors, pagination, DB session seam, secure-by-default config."
9
+ requires-python = ">=3.13"
10
+ license = "Apache-2.0"
11
+ dependencies = [
12
+ "fastapi>=0.115",
13
+ "sqlmodel>=0.0.22",
14
+ "pydantic-settings>=2.4",
15
+ ]
16
+
17
+ # Config sealing (design §5.4, terp.core.secrets) loads its cipher lazily from the
18
+ # `cryptography` package; an app that never seals config never needs this extra.
19
+ [project.optional-dependencies]
20
+ secrets = ["cryptography>=48.0.1"]
21
+
22
+ # PEP 420 namespace package: `terp` is shared across distributions, so there is
23
+ # NO src/terp/__init__.py. This distribution owns only `terp.core`.
24
+ [tool.hatch.build.targets.wheel]
25
+ sources = ["src"]
26
+ only-include = ["src/terp/core"]
@@ -0,0 +1,272 @@
1
+ """terp.core — the Terp platform kernel (the public API surface).
2
+
3
+ Importing from ``terp.core`` is the only sanctioned way for a module to use the
4
+ platform. Everything under :mod:`terp.core._internal` is import-forbidden
5
+ outside core; the public names below are the semver contract.
6
+
7
+ The authoritative import namespace is ``terp.*`` — never ``platform.*`` (it
8
+ shadows a stdlib module).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from terp.core.app import BootError, PermissionEnforcer, Principal, create_app, get_principal
14
+ from terp.core.app import enforces_token_revocation, mark_token_revocation_provider
15
+ from terp.core.audit import (
16
+ AuditAction,
17
+ AuditPolicy,
18
+ AuditRecord,
19
+ DurableAuditSink,
20
+ bind_audit_actor,
21
+ current_actor_id,
22
+ is_durable_audit_sink,
23
+ )
24
+ from terp.core.base_models import (
25
+ ActorStampedMixin,
26
+ BaseSchema,
27
+ BaseTable,
28
+ BaseUpdateSchema,
29
+ OwnedMixin,
30
+ SoftDeleteMixin,
31
+ TimestampMixin,
32
+ UUIDPrimaryKeyMixin,
33
+ )
34
+ from terp.core.base_service import BaseService
35
+ from terp.core.cache import (
36
+ CacheStore,
37
+ InMemoryCacheStore,
38
+ configure_cache,
39
+ get_cache,
40
+ is_shared_cache_store,
41
+ mark_shared_cache_store,
42
+ )
43
+ from terp.core.config import Settings, get_settings, settings
44
+ from terp.core.control_plane import ControlPlane
45
+ from terp.core.crud import build_crud_router
46
+ from terp.core.db import SessionDep, get_session
47
+ from terp.core.errors import (
48
+ AppError,
49
+ AuthenticationError,
50
+ ConflictError,
51
+ InvalidTokenError,
52
+ NotFoundError,
53
+ PermissionDeniedError,
54
+ StaleDataError,
55
+ ValidationFailedError,
56
+ build_error_envelope,
57
+ )
58
+ from terp.core.idempotency import (
59
+ BeginOutcome,
60
+ IdempotencyStore,
61
+ InMemoryIdempotencyStore,
62
+ StoredResponse,
63
+ is_shared_idempotency_store,
64
+ mark_shared_idempotency_store,
65
+ )
66
+ from terp.core.events import (
67
+ EventCatalog,
68
+ EventDefinition,
69
+ EventEnvelope,
70
+ EventError,
71
+ EventVisibility,
72
+ emit,
73
+ )
74
+ from terp.core.jobs import (
75
+ InProcessJobQueue,
76
+ JobCatalog,
77
+ JobContext,
78
+ JobDefinition,
79
+ JobEnvelope,
80
+ JobError,
81
+ JobQueue,
82
+ JobVisibility,
83
+ RetryPolicy,
84
+ enqueue,
85
+ is_durable_job_queue,
86
+ mark_durable_job_queue,
87
+ register_job_tenant_context,
88
+ )
89
+ from terp.core.logging import configure_logging, get_request_id, request_id_ctx
90
+ from terp.core.migrations import (
91
+ MigrationDiscoveryError,
92
+ MigrationTree,
93
+ resolve_all_migration_trees,
94
+ resolve_migration_target,
95
+ resolve_migration_trees,
96
+ )
97
+ from terp.core.module_spec import ModuleSpec, Policy, Roles
98
+ from terp.core.object_authz import (
99
+ ObjectAuthzPredicate,
100
+ register_object_authz_predicate,
101
+ )
102
+ from terp.core.pagination import (
103
+ CursorPage,
104
+ CursorPaginationDep,
105
+ CursorPaginationParams,
106
+ Page,
107
+ PaginationDep,
108
+ PaginationParams,
109
+ )
110
+ from terp.core.passwords import PasswordPolicy, WeakPasswordError, validate_password
111
+ from terp.core.permissions import (
112
+ ADMIN,
113
+ EDITOR,
114
+ VIEWER,
115
+ AuthorizationRequirement,
116
+ Permission,
117
+ PermissionModel,
118
+ Role,
119
+ as_role,
120
+ )
121
+ from terp.core.scheduling import (
122
+ ScheduleCatalog,
123
+ ScheduleDefinition,
124
+ Scheduler,
125
+ trigger_schedule,
126
+ )
127
+ from terp.core.scoping import ScopePredicate, register_scope_predicate
128
+ from terp.core.secrets import (
129
+ SecretsError,
130
+ decrypt_config,
131
+ encrypt_config,
132
+ is_sealed_config,
133
+ mask_config,
134
+ register_decrypt_call_site,
135
+ )
136
+ from terp.core.security import (
137
+ CorsPolicy,
138
+ RateLimit,
139
+ SecurityConfig,
140
+ SecurityHeaders,
141
+ client_ip,
142
+ )
143
+ from terp.core.throttling import (
144
+ InMemoryThrottleStore,
145
+ ThrottleStore,
146
+ is_shared_throttle_store,
147
+ mark_shared_throttle_store,
148
+ )
149
+
150
+ __all__ = [
151
+ "ADMIN",
152
+ "ActorStampedMixin",
153
+ "AppError",
154
+ "AuditAction",
155
+ "AuditPolicy",
156
+ "AuditRecord",
157
+ "AuthenticationError",
158
+ "AuthorizationRequirement",
159
+ "BaseSchema",
160
+ "BaseService",
161
+ "BaseTable",
162
+ "BaseUpdateSchema",
163
+ "BeginOutcome",
164
+ "BootError",
165
+ "CacheStore",
166
+ "ConflictError",
167
+ "ControlPlane",
168
+ "CorsPolicy",
169
+ "CursorPage",
170
+ "CursorPaginationDep",
171
+ "CursorPaginationParams",
172
+ "DurableAuditSink",
173
+ "EDITOR",
174
+ "EventCatalog",
175
+ "EventDefinition",
176
+ "EventEnvelope",
177
+ "EventError",
178
+ "EventVisibility",
179
+ "IdempotencyStore",
180
+ "InMemoryCacheStore",
181
+ "InMemoryIdempotencyStore",
182
+ "InMemoryThrottleStore",
183
+ "InProcessJobQueue",
184
+ "InvalidTokenError",
185
+ "JobCatalog",
186
+ "JobContext",
187
+ "JobDefinition",
188
+ "JobEnvelope",
189
+ "JobError",
190
+ "JobQueue",
191
+ "JobVisibility",
192
+ "MigrationDiscoveryError",
193
+ "MigrationTree",
194
+ "ModuleSpec",
195
+ "NotFoundError",
196
+ "ObjectAuthzPredicate",
197
+ "OwnedMixin",
198
+ "Page",
199
+ "PaginationDep",
200
+ "PaginationParams",
201
+ "PasswordPolicy",
202
+ "Permission",
203
+ "PermissionDeniedError",
204
+ "PermissionEnforcer",
205
+ "PermissionModel",
206
+ "Policy",
207
+ "Principal",
208
+ "RateLimit",
209
+ "RetryPolicy",
210
+ "Role",
211
+ "Roles",
212
+ "ScheduleCatalog",
213
+ "ScheduleDefinition",
214
+ "Scheduler",
215
+ "ScopePredicate",
216
+ "SecretsError",
217
+ "SecurityConfig",
218
+ "SecurityHeaders",
219
+ "SessionDep",
220
+ "Settings",
221
+ "SoftDeleteMixin",
222
+ "StaleDataError",
223
+ "StoredResponse",
224
+ "ThrottleStore",
225
+ "TimestampMixin",
226
+ "UUIDPrimaryKeyMixin",
227
+ "VIEWER",
228
+ "ValidationFailedError",
229
+ "WeakPasswordError",
230
+ "as_role",
231
+ "bind_audit_actor",
232
+ "build_crud_router",
233
+ "build_error_envelope",
234
+ "client_ip",
235
+ "configure_cache",
236
+ "configure_logging",
237
+ "create_app",
238
+ "current_actor_id",
239
+ "decrypt_config",
240
+ "emit",
241
+ "encrypt_config",
242
+ "enforces_token_revocation",
243
+ "enqueue",
244
+ "get_cache",
245
+ "get_principal",
246
+ "get_request_id",
247
+ "get_session",
248
+ "get_settings",
249
+ "is_durable_audit_sink",
250
+ "is_durable_job_queue",
251
+ "is_sealed_config",
252
+ "is_shared_cache_store",
253
+ "is_shared_idempotency_store",
254
+ "is_shared_throttle_store",
255
+ "mark_durable_job_queue",
256
+ "mark_shared_cache_store",
257
+ "mark_shared_idempotency_store",
258
+ "mark_shared_throttle_store",
259
+ "mark_token_revocation_provider",
260
+ "mask_config",
261
+ "register_decrypt_call_site",
262
+ "register_job_tenant_context",
263
+ "register_object_authz_predicate",
264
+ "register_scope_predicate",
265
+ "request_id_ctx",
266
+ "resolve_all_migration_trees",
267
+ "resolve_migration_target",
268
+ "resolve_migration_trees",
269
+ "settings",
270
+ "trigger_schedule",
271
+ "validate_password",
272
+ ]
@@ -0,0 +1,11 @@
1
+ """``terp.core._internal`` — implementation details, import-forbidden outside core.
2
+
3
+ Nothing here is part of the semver public surface. Modules (and capabilities
4
+ that did not declare it) must import from :mod:`terp.core` instead. The
5
+ architecture suite fails the build on any ``terp.core._internal`` import from a
6
+ module or sibling.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__: list[str] = []
@@ -0,0 +1,148 @@
1
+ """Filesystem discovery primitive (internal, composition-root use only).
2
+
3
+ A pure, side-effect-free walk over an app's domain roots. It imports no domain
4
+ code, so it is safe to call from low-level contexts (e.g. Alembic) without
5
+ triggering import side effects. The composition root (a later phase) builds
6
+ router/model/event wiring on top of this.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.metadata
12
+ import pathlib
13
+ from collections.abc import Iterable, Sequence
14
+ from dataclasses import dataclass
15
+
16
+ from terp.core.module_spec import ModuleSpec
17
+
18
+ _CAPABILITY_ENTRY_POINT_GROUP = "terp.capabilities"
19
+
20
+ # Domain roots in dependency order (lowest layer first).
21
+ DEFAULT_DOMAIN_ROOTS: tuple[str, ...] = ("capabilities", "foundation", "modules")
22
+
23
+
24
+ class CapabilityDiscoveryError(RuntimeError):
25
+ """A capability entry point could not be loaded or is invalid (fail closed at boot).
26
+
27
+ Discovery is part of composition, so a broken, mistyped, or name-colliding
28
+ capability must stop the boot loudly — never crash with a bare traceback, mount
29
+ a shadowing duplicate, or vanish silently.
30
+ """
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class DomainPackage:
35
+ """Metadata for a single discovered domain package."""
36
+
37
+ root: str
38
+ name: str
39
+ path: pathlib.Path
40
+ import_path: str
41
+
42
+
43
+ def iter_domain_packages(
44
+ app_root: str | pathlib.Path,
45
+ *,
46
+ package: str = "app",
47
+ roots: Iterable[str] = DEFAULT_DOMAIN_ROOTS,
48
+ ) -> list[DomainPackage]:
49
+ """Return every domain package under *app_root*, grouped by root then sorted.
50
+
51
+ Directories whose name starts with ``_`` (e.g. ``_registry``) and
52
+ non-directories are skipped, keeping discovery deterministic.
53
+ """
54
+ base = pathlib.Path(app_root)
55
+ packages: list[DomainPackage] = []
56
+ for root in roots:
57
+ root_dir = base / root
58
+ if not root_dir.is_dir():
59
+ continue
60
+ for child in sorted(root_dir.iterdir()):
61
+ if not child.is_dir() or child.name.startswith("_"):
62
+ continue
63
+ packages.append(
64
+ DomainPackage(
65
+ root=root,
66
+ name=child.name,
67
+ path=child,
68
+ import_path=f"{package}.{root}.{child.name}",
69
+ )
70
+ )
71
+ return packages
72
+
73
+
74
+ def iter_capability_specs(names: Sequence[str] | None = None) -> list[ModuleSpec]:
75
+ """Load installed capability ``ModuleSpec`` entry points.
76
+
77
+ Capabilities self-register by declaring a ``terp.capabilities`` entry point
78
+ that resolves to a :class:`~terp.core.ModuleSpec`. This lets ``create_app``
79
+ mount a capability's router (and register its models) without any edit to a
80
+ composition root.
81
+
82
+ When *names* is supplied, only those entry points' specs are returned. This
83
+ keeps discovery profile-shaped: an app may install optional capability
84
+ packages for libraries or tooling without exposing every installed routed
85
+ surface. Every installed entry point in the group is still loaded and
86
+ validated first — filtering selects what mounts, never what is checked —
87
+ so the duplicate-name guard cannot be bypassed by a filtered profile.
88
+
89
+ Fail-closed discovery: an entry point that fails to import, resolves to
90
+ something other than a ``ModuleSpec``, collides on entry-point name, or
91
+ collides on ``name`` with another capability raises
92
+ :class:`CapabilityDiscoveryError` — so a broken or shadowing capability stops
93
+ the boot loudly instead of crashing with a bare traceback, mounting a
94
+ duplicate router, or silently disappearing.
95
+ """
96
+ wanted = set(names) if names is not None else None
97
+ specs: list[ModuleSpec] = []
98
+ entry_points_seen: dict[str, str] = {}
99
+ provided_by: dict[str, str] = {}
100
+ installed: set[str] = set()
101
+ for entry_point in importlib.metadata.entry_points(group=_CAPABILITY_ENTRY_POINT_GROUP):
102
+ if entry_point.name in entry_points_seen:
103
+ raise CapabilityDiscoveryError(
104
+ f"capability entry point name {entry_point.name!r} is provided by two "
105
+ f"targets ({entry_points_seen[entry_point.name]!r} and {entry_point.value!r}); "
106
+ "entry point names must be unique so a capability filter cannot mount "
107
+ "multiple surfaces"
108
+ )
109
+ entry_points_seen[entry_point.name] = entry_point.value
110
+ try:
111
+ loaded = entry_point.load()
112
+ except Exception as exc: # any import-time failure must fail boot, not pass silently
113
+ raise CapabilityDiscoveryError(
114
+ f"capability entry point {entry_point.name!r} ({entry_point.value}) "
115
+ f"failed to load: {exc}"
116
+ ) from exc
117
+ if not isinstance(loaded, ModuleSpec):
118
+ raise CapabilityDiscoveryError(
119
+ f"capability entry point {entry_point.name!r} ({entry_point.value}) must "
120
+ f"resolve to a terp.core.ModuleSpec, got {type(loaded).__name__}"
121
+ )
122
+ if loaded.name in provided_by:
123
+ raise CapabilityDiscoveryError(
124
+ f"capability name {loaded.name!r} is provided by two entry points "
125
+ f"({provided_by[loaded.name]!r} and {entry_point.name!r}); capability "
126
+ "names must be unique so a router cannot be shadowed"
127
+ )
128
+ provided_by[loaded.name] = entry_point.name
129
+ installed.add(entry_point.name)
130
+ if wanted is None or entry_point.name in wanted:
131
+ specs.append(loaded)
132
+ if wanted is not None:
133
+ missing = wanted - installed
134
+ if missing:
135
+ raise CapabilityDiscoveryError(
136
+ "requested capability entry point(s) not installed: "
137
+ + ", ".join(sorted(missing))
138
+ )
139
+ return sorted(specs, key=lambda spec: spec.name)
140
+
141
+
142
+ __all__ = [
143
+ "DEFAULT_DOMAIN_ROOTS",
144
+ "CapabilityDiscoveryError",
145
+ "DomainPackage",
146
+ "iter_capability_specs",
147
+ "iter_domain_packages",
148
+ ]
@@ -0,0 +1,73 @@
1
+ """Lazy SQLAlchemy engine construction (internal).
2
+
3
+ Kept out of the public surface so the engine, pool, and URL handling can be
4
+ refactored freely. Public code reaches the database only through
5
+ :data:`terp.core.db.SessionDep`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from sqlalchemy import Engine
11
+ from sqlmodel import create_engine
12
+
13
+ from terp.core.config import settings
14
+
15
+ _engine: Engine | None = None
16
+
17
+
18
+ def _engine_options(database_url: str) -> dict[str, object]:
19
+ """Engine kwargs: connection-pool tuning for a server DB; SQLite keeps its defaults.
20
+
21
+ Pool sizing / recycling / pre-ping apply to a server database (Postgres, MySQL,
22
+ …). SQLite (dev / test, often in-memory) keeps SQLAlchemy's default pool —
23
+ ``pool_size`` / ``max_overflow`` do not apply to it, and recycling an in-memory
24
+ connection would discard the database.
25
+
26
+ A PostgreSQL database additionally receives a per-session ``statement_timeout``
27
+ (``DB_STATEMENT_TIMEOUT_MS``) on every pooled connection, so one runaway query
28
+ cannot hold a worker + connection forever; other server databases apply their
29
+ equivalent knob at the database/DSN level.
30
+
31
+ The ``per-module`` schema layout (ADR 0070) is a PostgreSQL feature: any other
32
+ dialect fails closed here, at engine construction, so a misconfigured deployment
33
+ never opens a connection against a layout its database cannot express.
34
+ """
35
+ if settings.DB_SCHEMA_LAYOUT == "per-module" and not database_url.startswith("postgresql"):
36
+ raise RuntimeError(
37
+ "DB_SCHEMA_LAYOUT='per-module' requires a PostgreSQL DATABASE_URL; "
38
+ "schemas are a PostgreSQL feature (ADR 0070)"
39
+ )
40
+ if database_url.startswith("sqlite"):
41
+ return {"echo": False}
42
+ options: dict[str, object] = {
43
+ "echo": False,
44
+ "pool_pre_ping": settings.DB_POOL_PRE_PING,
45
+ "pool_size": settings.DB_POOL_SIZE,
46
+ "max_overflow": settings.DB_MAX_OVERFLOW,
47
+ "pool_timeout": settings.DB_POOL_TIMEOUT,
48
+ "pool_recycle": settings.DB_POOL_RECYCLE,
49
+ }
50
+ if database_url.startswith("postgresql") and settings.DB_STATEMENT_TIMEOUT_MS > 0:
51
+ options["connect_args"] = {
52
+ "options": f"-c statement_timeout={settings.DB_STATEMENT_TIMEOUT_MS}"
53
+ }
54
+ return options
55
+
56
+
57
+ def get_engine() -> Engine:
58
+ """Return the process-wide engine, creating it on first use."""
59
+ global _engine
60
+ if _engine is None:
61
+ _engine = create_engine(settings.DATABASE_URL, **_engine_options(settings.DATABASE_URL))
62
+ return _engine
63
+
64
+
65
+ def reset_engine() -> None:
66
+ """Dispose and clear the cached engine (used by tests)."""
67
+ global _engine
68
+ if _engine is not None:
69
+ _engine.dispose()
70
+ _engine = None
71
+
72
+
73
+ __all__ = ["get_engine", "reset_engine"]