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.
- terp_core-0.1.0/.gitignore +47 -0
- terp_core-0.1.0/PKG-INFO +11 -0
- terp_core-0.1.0/pyproject.toml +26 -0
- terp_core-0.1.0/src/terp/core/__init__.py +272 -0
- terp_core-0.1.0/src/terp/core/_internal/__init__.py +11 -0
- terp_core-0.1.0/src/terp/core/_internal/discovery.py +148 -0
- terp_core-0.1.0/src/terp/core/_internal/engine.py +73 -0
- terp_core-0.1.0/src/terp/core/_internal/job_runtime.py +113 -0
- terp_core-0.1.0/src/terp/core/_internal/middleware.py +597 -0
- terp_core-0.1.0/src/terp/core/_internal/session_guard.py +407 -0
- terp_core-0.1.0/src/terp/core/app.py +1214 -0
- terp_core-0.1.0/src/terp/core/audit.py +313 -0
- terp_core-0.1.0/src/terp/core/base_models.py +252 -0
- terp_core-0.1.0/src/terp/core/base_service.py +415 -0
- terp_core-0.1.0/src/terp/core/cache.py +162 -0
- terp_core-0.1.0/src/terp/core/config.py +166 -0
- terp_core-0.1.0/src/terp/core/control_plane.py +101 -0
- terp_core-0.1.0/src/terp/core/crud.py +115 -0
- terp_core-0.1.0/src/terp/core/db.py +38 -0
- terp_core-0.1.0/src/terp/core/errors.py +128 -0
- terp_core-0.1.0/src/terp/core/events.py +279 -0
- terp_core-0.1.0/src/terp/core/health.py +59 -0
- terp_core-0.1.0/src/terp/core/idempotency.py +248 -0
- terp_core-0.1.0/src/terp/core/jobs.py +498 -0
- terp_core-0.1.0/src/terp/core/logging.py +204 -0
- terp_core-0.1.0/src/terp/core/migrations.py +254 -0
- terp_core-0.1.0/src/terp/core/module_spec.py +184 -0
- terp_core-0.1.0/src/terp/core/object_authz.py +133 -0
- terp_core-0.1.0/src/terp/core/pagination.py +206 -0
- terp_core-0.1.0/src/terp/core/passwords.py +189 -0
- terp_core-0.1.0/src/terp/core/permissions.py +201 -0
- terp_core-0.1.0/src/terp/core/py.typed +0 -0
- terp_core-0.1.0/src/terp/core/scheduling.py +208 -0
- terp_core-0.1.0/src/terp/core/scoping.py +80 -0
- terp_core-0.1.0/src/terp/core/secrets.py +173 -0
- terp_core-0.1.0/src/terp/core/security.py +256 -0
- 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
|
terp_core-0.1.0/PKG-INFO
ADDED
|
@@ -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"]
|