schemawatch-api 0.1.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.
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import sys
7
+
8
+ import uvicorn
9
+
10
+ from schemawatch_api.config import Settings
11
+
12
+
13
+ def main() -> None:
14
+ """Entry point for schemawatch-api CLI / python -m schemawatch_api."""
15
+ parser = argparse.ArgumentParser(description="Run schemawatch API server")
16
+ parser.add_argument("--host", default="127.0.0.1", help="Bind socket host")
17
+ parser.add_argument("--port", type=int, default=8000, help="Bind socket port")
18
+ parser.add_argument("--dev", action="store_true", help="Run with dev mode and reload")
19
+ args = parser.parse_args()
20
+
21
+ if args.dev:
22
+ os.environ["SCHEMAWATCH_DEV_MODE"] = "true"
23
+
24
+ try:
25
+ settings = Settings()
26
+ settings.validate_token_requirement()
27
+ except ValueError as exc:
28
+ print(f"ERROR: {exc}", file=sys.stderr)
29
+ sys.exit(1)
30
+
31
+ logging.basicConfig(level=logging.INFO)
32
+ logger = logging.getLogger("schemawatch.api")
33
+ logger.info("Starting schemawatch API bound to %s:%d", args.host, args.port)
34
+
35
+ uvicorn.run(
36
+ "schemawatch_api.app:create_app",
37
+ factory=True,
38
+ host=args.host,
39
+ port=args.port,
40
+ reload=args.dev,
41
+ )
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
schemawatch_api/app.py ADDED
@@ -0,0 +1,445 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hmac
5
+ import logging
6
+ import threading
7
+ import time
8
+ from collections.abc import AsyncGenerator
9
+ from contextlib import asynccontextmanager
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, status
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
17
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
18
+
19
+ from schemawatch_api import __version__
20
+ from schemawatch_api.config import Settings
21
+ from schemawatch_api.database import create_db_engine, create_session_factory, init_db
22
+ from schemawatch_api.schemas import (
23
+ AcceptResponse,
24
+ AckResponse,
25
+ BaselineResponse,
26
+ ConfigResponse,
27
+ DriftEventResponse,
28
+ EndpointResponse,
29
+ HealthResponse,
30
+ IngestRequest,
31
+ IngestResponse,
32
+ PaginatedResponse,
33
+ StatsResponse,
34
+ )
35
+ from schemawatch_api.service import SchemawatchService
36
+ from schemawatch_api.sse import SSEManager
37
+
38
+ bearer_scheme = HTTPBearer(auto_error=False)
39
+
40
+
41
+ def create_app(settings: Settings | None = None) -> FastAPI:
42
+ """Application factory for schemawatch FastAPI backend.
43
+
44
+ Creates and configures routes, authentication, database connection, and SSE manager.
45
+ Zero global state ensures total isolation across test runs and environments.
46
+ """
47
+ if settings is None:
48
+ settings = Settings()
49
+ settings.validate_token_requirement()
50
+
51
+ logger = logging.getLogger("schemawatch.api")
52
+ if settings.dev_mode:
53
+ logger.warning(
54
+ "SECURITY WARNING: SCHEMAWATCH_DEV_MODE is enabled. API authentication is bypassed!"
55
+ )
56
+
57
+ # Initialize persistence and engine
58
+ engine = create_db_engine(settings.database_url)
59
+ session_factory = create_session_factory(engine)
60
+ init_db(engine)
61
+
62
+ # Initialize SSE manager and core service
63
+ sse_manager = SSEManager()
64
+ service = SchemawatchService(
65
+ settings=settings,
66
+ session_factory=session_factory,
67
+ sse_manager=sse_manager,
68
+ )
69
+
70
+ @asynccontextmanager
71
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
72
+ # Associate running asyncio event loop with service for thread-safe SSE bridge
73
+ loop = asyncio.get_running_loop()
74
+ service.set_loop(loop)
75
+ yield
76
+
77
+ app = FastAPI(
78
+ title="schemawatch API",
79
+ description="Open-source API contract drift monitor REST API",
80
+ version=__version__,
81
+ lifespan=lifespan,
82
+ debug=False,
83
+ )
84
+
85
+ # Attach service and settings to app state
86
+ app.state.settings = settings
87
+ app.state.service = service
88
+ app.state.engine = engine
89
+
90
+ # Security Headers Middleware (CSP, X-Content-Type-Options, Referrer-Policy, X-Frame-Options)
91
+ @app.middleware("http")
92
+ async def add_security_headers(request: Request, call_next: Any) -> Response:
93
+ response: Response = await call_next(request)
94
+ response.headers["Content-Security-Policy"] = (
95
+ "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'"
96
+ )
97
+ response.headers["X-Content-Type-Options"] = "nosniff"
98
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
99
+ response.headers["X-Frame-Options"] = "DENY"
100
+ return response
101
+
102
+ # Global Exception Handler (prevents leaking internal tracebacks in 500 responses)
103
+ @app.exception_handler(Exception)
104
+ async def handle_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
105
+ logger.exception("Internal unhandled error: %s", exc)
106
+ return JSONResponse(
107
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
108
+ content={"detail": "Internal server error"},
109
+ )
110
+
111
+ # Configure CORS if allowed origins specified
112
+ if settings.cors_origins:
113
+ app.add_middleware(
114
+ CORSMiddleware,
115
+ allow_origins=settings.cors_origins,
116
+ allow_credentials=True,
117
+ allow_methods=["*"],
118
+ allow_headers=["*"],
119
+ )
120
+
121
+ # Ingest rate limiting state
122
+ ingest_rate_limits: dict[str, list[float]] = {}
123
+ rate_limit_lock = threading.Lock()
124
+ max_ingest_per_minute = 600
125
+
126
+ # ---------------------------------------------------------
127
+ # Authentication Dependency
128
+ # ---------------------------------------------------------
129
+ def authenticate(
130
+ request: Request,
131
+ auth: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
132
+ ) -> None:
133
+ """Verify API token using constant-time comparison.
134
+
135
+ Enforces Authorization: Bearer <token>.
136
+ Tokens are never accepted in query strings or logged.
137
+ """
138
+ if settings.dev_mode and not settings.api_token:
139
+ return
140
+
141
+ if (
142
+ not auth
143
+ or not auth.credentials
144
+ or not hmac.compare_digest(auth.credentials, settings.api_token)
145
+ ):
146
+ raise HTTPException(
147
+ status_code=status.HTTP_401_UNAUTHORIZED,
148
+ detail="Unauthorized: invalid or missing API token",
149
+ headers={"WWW-Authenticate": "Bearer"},
150
+ )
151
+
152
+ # ---------------------------------------------------------
153
+ # Routes
154
+ # ---------------------------------------------------------
155
+ @app.get(
156
+ "/healthz",
157
+ response_model=HealthResponse,
158
+ summary="Service Health Check",
159
+ operation_id="getHealth",
160
+ tags=["System"],
161
+ )
162
+ def healthz() -> HealthResponse:
163
+ """Public health check endpoint (no authentication required)."""
164
+ return HealthResponse(status="ok", version=__version__)
165
+
166
+ @app.get(
167
+ "/api/config",
168
+ response_model=ConfigResponse,
169
+ summary="Get Public System Configuration",
170
+ operation_id="getConfig",
171
+ dependencies=[Depends(authenticate)],
172
+ tags=["Configuration"],
173
+ )
174
+ def get_config() -> ConfigResponse:
175
+ """Get read-only configuration booleans. Never returns tokens, URLs, or secrets."""
176
+ db_type = "postgres" if "postgres" in settings.database_url.lower() else "sqlite"
177
+ return ConfigResponse(
178
+ slack_configured=bool(settings.slack_webhook_url),
179
+ webhook_configured=bool(settings.alert_webhook_url),
180
+ dev_mode=settings.dev_mode,
181
+ min_alert_severity=settings.min_alert_severity,
182
+ learn_samples=settings.learn_samples,
183
+ sample_rate=settings.sample_rate,
184
+ cooldown_seconds=settings.cooldown_seconds,
185
+ database_type=db_type,
186
+ )
187
+
188
+ @app.get(
189
+ "/api/stats",
190
+ response_model=StatsResponse,
191
+ summary="Get System and Alert Statistics",
192
+ operation_id="getStats",
193
+ dependencies=[Depends(authenticate)],
194
+ tags=["Statistics"],
195
+ )
196
+ def get_stats() -> StatsResponse:
197
+ """Get aggregate system statistics and hourly drift counts for the last 12 hours."""
198
+ data = service.get_stats()
199
+ return StatsResponse(**data)
200
+
201
+ @app.get(
202
+ "/api/endpoints",
203
+ response_model=PaginatedResponse[EndpointResponse],
204
+ summary="List Monitored Endpoints",
205
+ operation_id="listEndpoints",
206
+ dependencies=[Depends(authenticate)],
207
+ tags=["Endpoints"],
208
+ )
209
+ def list_endpoints(
210
+ cursor: int | None = Query(None, description="Cursor for next page"),
211
+ limit: int = Query(50, ge=1, le=100, description="Items per page"),
212
+ ) -> PaginatedResponse[EndpointResponse]:
213
+ """List all monitored third-party API endpoints with health status and alert counts."""
214
+ items, next_cursor, total = service.list_endpoints(cursor=cursor, limit=limit)
215
+ return PaginatedResponse(
216
+ items=[EndpointResponse(**item) for item in items],
217
+ next_cursor=next_cursor,
218
+ total=total,
219
+ )
220
+
221
+ @app.get(
222
+ "/api/endpoints/{id}",
223
+ response_model=EndpointResponse,
224
+ summary="Get Endpoint Details",
225
+ operation_id="getEndpoint",
226
+ dependencies=[Depends(authenticate)],
227
+ tags=["Endpoints"],
228
+ )
229
+ def get_endpoint(id: int) -> EndpointResponse:
230
+ """Get detailed metadata for a single endpoint."""
231
+ ep = service.get_endpoint(id)
232
+ if ep is None:
233
+ raise HTTPException(status_code=404, detail=f"Endpoint {id} not found")
234
+ return EndpointResponse(**ep)
235
+
236
+ @app.get(
237
+ "/api/endpoints/{id}/baseline",
238
+ response_model=BaselineResponse,
239
+ summary="Get Endpoint Baseline Schema",
240
+ operation_id="getEndpointBaseline",
241
+ dependencies=[Depends(authenticate)],
242
+ tags=["Endpoints"],
243
+ )
244
+ def get_endpoint_baseline(
245
+ id: int,
246
+ version: int | None = Query(None, description="Specific baseline version to retrieve"),
247
+ ) -> BaselineResponse:
248
+ """Retrieve the active or specified baseline schema for an endpoint."""
249
+ b = service.get_baseline(id, version=version)
250
+ if b is None:
251
+ raise HTTPException(status_code=404, detail=f"Baseline not found for endpoint {id}")
252
+ return BaselineResponse(**b)
253
+
254
+ @app.post(
255
+ "/api/endpoints/{id}/accept",
256
+ response_model=AcceptResponse,
257
+ summary="Accept Observed Schema as New Baseline",
258
+ operation_id="acceptEndpointBaseline",
259
+ dependencies=[Depends(authenticate)],
260
+ tags=["Endpoints"],
261
+ )
262
+ def accept_endpoint_baseline(id: int) -> AcceptResponse:
263
+ """Adopt latest observed schema as new baseline and acknowledge all open events."""
264
+ res = service.accept_baseline(id)
265
+ if res is None:
266
+ raise HTTPException(status_code=404, detail=f"Endpoint {id} not found")
267
+ new_version, acks_count = res
268
+ return AcceptResponse(
269
+ status="ok",
270
+ endpoint_id=id,
271
+ new_baseline_version=new_version,
272
+ events_acknowledged=acks_count,
273
+ )
274
+
275
+ @app.get(
276
+ "/api/events",
277
+ response_model=PaginatedResponse[DriftEventResponse],
278
+ summary="List Drift Events",
279
+ operation_id="listEvents",
280
+ dependencies=[Depends(authenticate)],
281
+ tags=["Events"],
282
+ )
283
+ def list_events(
284
+ severity: str | None = Query(None, description="Severity: breaking, warning, info"),
285
+ vendor: str | None = Query(None, description="Filter by vendor name"),
286
+ endpoint_id: int | None = Query(None, description="Filter by endpoint ID"),
287
+ acknowledged: bool | None = Query(None, description="Filter by acknowledgment"),
288
+ since: datetime | None = Query(None, description="Events detected after timestamp"),
289
+ cursor: int | None = Query(None, description="Cursor for next page"),
290
+ limit: int = Query(50, ge=1, le=100, description="Items per page"),
291
+ ) -> PaginatedResponse[DriftEventResponse]:
292
+ """Query contract drift events with multi-field filtering and pagination."""
293
+ items, next_cursor, total = service.list_events(
294
+ severity=severity,
295
+ vendor=vendor,
296
+ endpoint_id=endpoint_id,
297
+ acknowledged=acknowledged,
298
+ since=since,
299
+ cursor=cursor,
300
+ limit=limit,
301
+ )
302
+ return PaginatedResponse(
303
+ items=[DriftEventResponse(**item) for item in items],
304
+ next_cursor=next_cursor,
305
+ total=total,
306
+ )
307
+
308
+ @app.post(
309
+ "/api/events/{id}/ack",
310
+ response_model=AckResponse,
311
+ summary="Acknowledge Drift Event",
312
+ operation_id="acknowledgeEvent",
313
+ dependencies=[Depends(authenticate)],
314
+ tags=["Events"],
315
+ )
316
+ def acknowledge_event(id: int) -> AckResponse:
317
+ """Mark a contract drift event as acknowledged."""
318
+ res = service.set_event_acknowledgment(id, acknowledged=True)
319
+ if res is None:
320
+ raise HTTPException(status_code=404, detail=f"Event {id} not found")
321
+ return AckResponse(status="ok", event_id=id, acknowledged=True)
322
+
323
+ @app.post(
324
+ "/api/events/{id}/unack",
325
+ response_model=AckResponse,
326
+ summary="Unacknowledge Drift Event",
327
+ operation_id="unacknowledgeEvent",
328
+ dependencies=[Depends(authenticate)],
329
+ tags=["Events"],
330
+ )
331
+ def unacknowledge_event(id: int) -> AckResponse:
332
+ """Reopen an acknowledged contract drift event."""
333
+ res = service.set_event_acknowledgment(id, acknowledged=False)
334
+ if res is None:
335
+ raise HTTPException(status_code=404, detail=f"Event {id} not found")
336
+ return AckResponse(status="ok", event_id=id, acknowledged=False)
337
+
338
+ @app.post(
339
+ "/api/ingest",
340
+ response_model=IngestResponse,
341
+ summary="Ingest Sample Payload",
342
+ operation_id="ingestPayload",
343
+ dependencies=[Depends(authenticate)],
344
+ tags=["Ingest"],
345
+ )
346
+ async def ingest_payload(request: Request) -> IngestResponse:
347
+ """Ingest third-party API response payload for contract drift monitoring.
348
+
349
+ Enforces request payload size limits and verifies payload safety.
350
+ """
351
+ # Enforce body size limit
352
+ content_length = request.headers.get("content-length")
353
+ if content_length and int(content_length) > settings.max_ingest_body_bytes:
354
+ raise HTTPException(
355
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
356
+ detail=f"Payload exceeds limit of {settings.max_ingest_body_bytes} bytes",
357
+ )
358
+
359
+ body_bytes = await request.body()
360
+ if len(body_bytes) > settings.max_ingest_body_bytes:
361
+ raise HTTPException(
362
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
363
+ detail=f"Payload exceeds limit of {settings.max_ingest_body_bytes} bytes",
364
+ )
365
+
366
+ # Enforce client rate limiting on /api/ingest
367
+ client_host = request.client.host if request.client else "unknown"
368
+ now_ts = time.monotonic()
369
+ with rate_limit_lock:
370
+ history = ingest_rate_limits.setdefault(client_host, [])
371
+ history[:] = [t for t in history if now_ts - t < 60.0]
372
+ if len(history) >= max_ingest_per_minute:
373
+ raise HTTPException(
374
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
375
+ detail=f"Rate limit exceeded (max {max_ingest_per_minute}/min)",
376
+ )
377
+ history.append(now_ts)
378
+
379
+ try:
380
+ body_json = await request.json()
381
+ ingest_req = IngestRequest(**body_json)
382
+ except Exception as exc:
383
+ raise HTTPException(
384
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
385
+ detail=f"Invalid ingest JSON body: {exc}",
386
+ ) from exc
387
+
388
+ # Process payload through watchdog off the main thread
389
+ service.set_loop(asyncio.get_running_loop())
390
+ observed, changes, event_ids = await asyncio.to_thread(
391
+ service.ingest_payload,
392
+ ingest_req.vendor,
393
+ ingest_req.endpoint,
394
+ ingest_req.payload,
395
+ )
396
+
397
+ return IngestResponse(
398
+ status="ok",
399
+ observed=observed,
400
+ changes_detected=len(changes),
401
+ event_ids=event_ids,
402
+ )
403
+
404
+ @app.get(
405
+ "/api/events/stream",
406
+ summary="Real-time Server-Sent Events Stream",
407
+ operation_id="streamEvents",
408
+ dependencies=[Depends(authenticate)],
409
+ tags=["Events"],
410
+ )
411
+ async def stream_events() -> StreamingResponse:
412
+ """Subscribe to real-time contract drift alerts via SSE.
413
+
414
+ Emits 'event: drift' on new changes, with keep-alive pings every 15s.
415
+ """
416
+ service.set_loop(asyncio.get_running_loop())
417
+ return StreamingResponse(
418
+ sse_manager.subscribe(),
419
+ media_type="text/event-stream",
420
+ headers={
421
+ "Cache-Control": "no-cache",
422
+ "Connection": "keep-alive",
423
+ "X-Accel-Buffering": "no",
424
+ },
425
+ )
426
+
427
+ # ---------------------------------------------------------
428
+ # Frontend Static Files Mount & SPA Fallback (Production)
429
+ # ---------------------------------------------------------
430
+ frontend_dist = Path(__file__).resolve().parent.parent.parent / "web" / "dist"
431
+ if frontend_dist.is_dir():
432
+
433
+ @app.get("/{full_path:path}", include_in_schema=False)
434
+ def serve_frontend_spa(full_path: str) -> Response:
435
+ # If path points to an existing file in dist, serve it
436
+ target = frontend_dist / full_path
437
+ if full_path and target.is_file():
438
+ return FileResponse(target)
439
+ # Otherwise return index.html for SPA client-side routing
440
+ index_path = frontend_dist / "index.html"
441
+ if index_path.is_file():
442
+ return FileResponse(index_path)
443
+ return Response("Frontend build index.html not found", status_code=404)
444
+
445
+ return app
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import Field, field_validator
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+ VALID_MIN_SEVERITIES = frozenset({"breaking", "warning", "info"})
9
+
10
+
11
+ PLACEHOLDER_TOKEN = "your-random-secure-token-here"
12
+
13
+
14
+ class Settings(BaseSettings):
15
+ """Configuration for schemawatch API server, loaded from environment variables."""
16
+
17
+ model_config = SettingsConfigDict(
18
+ env_prefix="SCHEMAWATCH_",
19
+ env_file=".env",
20
+ extra="ignore",
21
+ )
22
+
23
+ database_url: str = Field(
24
+ default="sqlite:///./schemawatch.db",
25
+ description="Database connection URL (SQLite default, PostgreSQL supported)",
26
+ )
27
+ redis_url: str | None = Field(
28
+ default=None,
29
+ description="Optional Redis connection URL for deduplication and caching",
30
+ )
31
+ api_token: str = Field(
32
+ default="",
33
+ description="Bearer token required for API authentication unless dev_mode is true",
34
+ )
35
+ dev_mode: bool = Field(
36
+ default=False,
37
+ description="Enable developer mode (allows unauthenticated access when api_token is unset)",
38
+ )
39
+ learn_samples: int = Field(
40
+ default=5,
41
+ description="Number of initial samples required to establish a baseline",
42
+ )
43
+ sample_rate: float = Field(
44
+ default=1.0,
45
+ description="Sampling rate for post-learning observations (0.0 to 1.0)",
46
+ )
47
+ cooldown_seconds: int = Field(
48
+ default=3600,
49
+ description="Alert deduplication cooldown period in seconds",
50
+ )
51
+ slack_webhook_url: str | None = Field(
52
+ default=None,
53
+ description="Optional Slack webhook URL for drift alert dispatch",
54
+ )
55
+ alert_webhook_url: str | None = Field(
56
+ default=None,
57
+ description="Optional generic webhook URL for drift alert dispatch",
58
+ )
59
+ alert_webhook_secret: str | None = Field(
60
+ default=None,
61
+ description="Optional shared secret for HMAC-SHA256 signing of webhook alert payloads",
62
+ )
63
+ min_alert_severity: str = Field(
64
+ default="info",
65
+ description="Minimum severity required for alerts (breaking, warning, info)",
66
+ )
67
+ cors_origins: list[str] = Field(
68
+ default_factory=list,
69
+ description="Allowed CORS origins list. CORS is disabled if empty",
70
+ )
71
+ max_ingest_body_bytes: int = Field(
72
+ default=1_000_000,
73
+ description="Maximum allowed request payload size in bytes for /api/ingest",
74
+ )
75
+
76
+ @field_validator("api_token")
77
+ @classmethod
78
+ def validate_api_token(cls, v: str, info: Any) -> str:
79
+ # If DEV_MODE is not true, API_TOKEN must not be empty or placeholder
80
+ # Handled in validate_token_requirement
81
+ return v
82
+
83
+ @field_validator("min_alert_severity")
84
+ @classmethod
85
+ def validate_min_severity(cls, v: str) -> str:
86
+ v_lower = v.lower()
87
+ if v_lower not in VALID_MIN_SEVERITIES:
88
+ raise ValueError(f"min_alert_severity must be one of {sorted(VALID_MIN_SEVERITIES)}")
89
+ return v_lower
90
+
91
+ def validate_token_requirement(self) -> None:
92
+ """Check that api_token is set and secure when dev_mode is false."""
93
+ if not self.dev_mode:
94
+ token = self.api_token.strip() if self.api_token else ""
95
+ if not token or token == PLACEHOLDER_TOKEN:
96
+ raise ValueError(
97
+ "SCHEMAWATCH_API_TOKEN is required and cannot be empty or the placeholder "
98
+ f"'{PLACEHOLDER_TOKEN}' when SCHEMAWATCH_DEV_MODE is not 'true'. "
99
+ "Generate a secure token with:\n"
100
+ ' python -c "import secrets; print(secrets.token_urlsafe(32))"'
101
+ )
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import Engine, create_engine
4
+ from sqlalchemy.orm import Session, sessionmaker
5
+ from sqlalchemy.pool import StaticPool
6
+
7
+ from schemawatch_api.models import Base
8
+
9
+
10
+ def create_db_engine(database_url: str) -> Engine:
11
+ """Create a synchronous SQLAlchemy engine for SQLite or PostgreSQL."""
12
+ connect_args: dict[str, object] = {}
13
+ kwargs: dict[str, object] = {}
14
+
15
+ if database_url.startswith("sqlite"):
16
+ connect_args["check_same_thread"] = False
17
+ if ":memory:" in database_url:
18
+ kwargs["poolclass"] = StaticPool
19
+
20
+ return create_engine(database_url, connect_args=connect_args, **kwargs)
21
+
22
+
23
+ def create_session_factory(engine: Engine) -> sessionmaker[Session]:
24
+ """Create a scoped or standard sessionmaker bound to engine."""
25
+ return sessionmaker(autocommit=False, autoflush=False, bind=engine)
26
+
27
+
28
+ def init_db(engine: Engine) -> None:
29
+ """Initialize database tables."""
30
+ Base.metadata.create_all(bind=engine)