servicekit 2.0.2__tar.gz → 2.0.3__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.
- {servicekit-2.0.2 → servicekit-2.0.3}/PKG-INFO +1 -1
- {servicekit-2.0.2 → servicekit-2.0.3}/pyproject.toml +1 -1
- {servicekit-2.0.2 → servicekit-2.0.3}/pyproject.toml.orig +1 -1
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/service_builder.py +114 -39
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/utilities.py +9 -7
- {servicekit-2.0.2 → servicekit-2.0.3}/README.md +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/__init__.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/alembic/env.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/alembic/script.py.mako +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/alembic/versions/20251010_0927_4d869b5fb06e_initial_schema.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/__init__.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/app.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/apps/landing/index.html +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/apps/landing/manifest.json +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/auth.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/crud.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/dependencies.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/middleware.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/monitoring.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/pagination.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/registration.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/router.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/routers/__init__.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/routers/health.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/routers/job.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/routers/metrics.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/routers/system.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/sse.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/database.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/exceptions.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/gunicorn.conf.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/logging.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/manager.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/models.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/py.typed +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/repository.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/scheduler.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/schemas.py +0 -0
- {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: servicekit
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.3
|
|
4
4
|
Summary: Async SQLAlchemy framework with FastAPI integration - reusable foundation for building data services
|
|
5
5
|
Keywords: fastapi,sqlalchemy,async,database,rest-api,crud,framework
|
|
6
6
|
Author: Morten Hansen
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "servicekit"
|
|
3
|
-
version = "2.0.
|
|
3
|
+
version = "2.0.3"
|
|
4
4
|
description = "Async SQLAlchemy framework with FastAPI integration - reusable foundation for building data services"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Morten Hansen", email = "morten@dhis2.org" }]
|
|
@@ -5,8 +5,10 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import os
|
|
7
7
|
import re
|
|
8
|
+
import secrets
|
|
8
9
|
import signal
|
|
9
10
|
import sys
|
|
11
|
+
from collections.abc import Sequence
|
|
10
12
|
from contextlib import asynccontextmanager
|
|
11
13
|
from dataclasses import dataclass
|
|
12
14
|
from pathlib import Path
|
|
@@ -15,6 +17,7 @@ from typing import Any, AsyncContextManager, AsyncGenerator, Awaitable, Callable
|
|
|
15
17
|
from fastapi import APIRouter, FastAPI
|
|
16
18
|
from pydantic import BaseModel, ConfigDict, field_validator
|
|
17
19
|
from sqlalchemy import text
|
|
20
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
18
21
|
|
|
19
22
|
from servicekit import Database, SqliteDatabase
|
|
20
23
|
from servicekit.logging import configure_logging, get_logger
|
|
@@ -34,6 +37,10 @@ type LifecycleHook = Callable[[FastAPI], Awaitable[None]]
|
|
|
34
37
|
type DependencyOverride = Callable[..., object]
|
|
35
38
|
type LifespanFactory = Callable[[FastAPI], AsyncContextManager[None]]
|
|
36
39
|
|
|
40
|
+
# Header the readiness probe sends and expects echoed back, so it only accepts its own app
|
|
41
|
+
_READINESS_TOKEN_HEADER = "x-servicekit-readiness-token"
|
|
42
|
+
_DEFAULT_LOCAL_PORT = 8000
|
|
43
|
+
|
|
37
44
|
|
|
38
45
|
@dataclass(frozen=True)
|
|
39
46
|
class _HealthOptions:
|
|
@@ -107,6 +114,8 @@ class _RegistrationOptions:
|
|
|
107
114
|
service_key: str | None
|
|
108
115
|
service_key_env: str
|
|
109
116
|
re_register_grace_period: float
|
|
117
|
+
local_port: int | None = None
|
|
118
|
+
local_port_env: str = "PORT"
|
|
110
119
|
|
|
111
120
|
|
|
112
121
|
class ServiceInfo(BaseModel):
|
|
@@ -341,8 +350,15 @@ class BaseServiceBuilder:
|
|
|
341
350
|
service_key: str | None = None,
|
|
342
351
|
service_key_env: str = "SERVICEKIT_REGISTRATION_KEY",
|
|
343
352
|
re_register_grace_period: float = 30.0,
|
|
353
|
+
local_port: int | None = None,
|
|
354
|
+
local_port_env: str = "PORT",
|
|
344
355
|
) -> Self:
|
|
345
|
-
"""Enable service registration with orchestrator for service discovery.
|
|
356
|
+
"""Enable service registration with orchestrator for service discovery.
|
|
357
|
+
|
|
358
|
+
``host``/``port`` (or ``host_env``/``port_env``) form the address advertised to the orchestrator.
|
|
359
|
+
``local_port`` (or ``local_port_env``) is the port the app binds locally, probed for readiness
|
|
360
|
+
before registering; set it when the advertised port differs from the bind port.
|
|
361
|
+
"""
|
|
346
362
|
self._registration_options = _RegistrationOptions(
|
|
347
363
|
orchestrator_url=orchestrator_url,
|
|
348
364
|
host=host,
|
|
@@ -360,6 +376,8 @@ class BaseServiceBuilder:
|
|
|
360
376
|
service_key=service_key,
|
|
361
377
|
service_key_env=service_key_env,
|
|
362
378
|
re_register_grace_period=re_register_grace_period,
|
|
379
|
+
local_port=local_port,
|
|
380
|
+
local_port_env=local_port_env,
|
|
363
381
|
)
|
|
364
382
|
return self
|
|
365
383
|
|
|
@@ -534,6 +552,12 @@ class BaseServiceBuilder:
|
|
|
534
552
|
for dependency, override in self._dependency_overrides.items():
|
|
535
553
|
app.dependency_overrides[dependency] = override
|
|
536
554
|
|
|
555
|
+
# Last, so it wraps any build_middleware_stack patch made above (OpenTelemetry instrumentation
|
|
556
|
+
# expects the stack it builds to start with ServerErrorMiddleware)
|
|
557
|
+
if self._registration_options is not None:
|
|
558
|
+
app.state.readiness_token = secrets.token_hex(16)
|
|
559
|
+
app.build_middleware_stack = _wrap_with_readiness_token(app) # type: ignore[method-assign]
|
|
560
|
+
|
|
537
561
|
return app
|
|
538
562
|
|
|
539
563
|
# --------------------------------------------------------------------- Extension points
|
|
@@ -882,49 +906,99 @@ class BaseServiceBuilder:
|
|
|
882
906
|
return cls(info=info, **kwargs).build()
|
|
883
907
|
|
|
884
908
|
|
|
885
|
-
|
|
886
|
-
"""
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
909
|
+
class _ReadinessTokenMiddleware:
|
|
910
|
+
"""Echo the readiness token header on responses to requests that present the matching token."""
|
|
911
|
+
|
|
912
|
+
def __init__(self, app: ASGIApp, *, token: str) -> None:
|
|
913
|
+
"""Wrap the ASGI app with the per-instance readiness token."""
|
|
914
|
+
self.app = app
|
|
915
|
+
self.token = token
|
|
916
|
+
|
|
917
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
918
|
+
"""Pass the request through, tagging the response when the probe token matches."""
|
|
919
|
+
if scope["type"] != "http" or not self._has_token(scope):
|
|
920
|
+
await self.app(scope, receive, send)
|
|
921
|
+
return
|
|
922
|
+
|
|
923
|
+
async def send_with_token(message: Message) -> None:
|
|
924
|
+
"""Append the token header to the response start message."""
|
|
925
|
+
if message["type"] == "http.response.start":
|
|
926
|
+
headers = list(message.get("headers", []))
|
|
927
|
+
headers.append((_READINESS_TOKEN_HEADER.encode("latin-1"), self.token.encode("latin-1")))
|
|
928
|
+
message = {**message, "headers": headers}
|
|
929
|
+
await send(message)
|
|
930
|
+
|
|
931
|
+
await self.app(scope, receive, send_with_token)
|
|
932
|
+
|
|
933
|
+
def _has_token(self, scope: Scope) -> bool:
|
|
934
|
+
"""Return whether the request carries this instance's readiness token."""
|
|
935
|
+
expected = (_READINESS_TOKEN_HEADER.encode("latin-1"), self.token.encode("latin-1"))
|
|
936
|
+
return any((name.lower(), value) == expected for name, value in scope.get("headers", []))
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _wrap_with_readiness_token(app: FastAPI) -> Callable[[], ASGIApp]:
|
|
940
|
+
"""Wrap the app's middleware stack builder so readiness token tagging is always the outermost layer."""
|
|
941
|
+
build_middleware_stack = app.build_middleware_stack
|
|
942
|
+
|
|
943
|
+
def build_with_readiness_token() -> ASGIApp:
|
|
944
|
+
"""Build the regular stack, including middleware added after build(), inside the token middleware."""
|
|
945
|
+
return _ReadinessTokenMiddleware(build_middleware_stack(), token=app.state.readiness_token)
|
|
946
|
+
|
|
947
|
+
return build_with_readiness_token
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _parse_port(value: str | None) -> int | None:
|
|
951
|
+
"""Parse a port from an environment value, returning None when unset or invalid."""
|
|
952
|
+
if not value:
|
|
953
|
+
return None
|
|
954
|
+
try:
|
|
955
|
+
return int(value)
|
|
956
|
+
except ValueError:
|
|
957
|
+
return None
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _local_port_candidates(options: _RegistrationOptions) -> list[int]:
|
|
961
|
+
"""Resolve the local ports to probe for readiness, most specific first."""
|
|
962
|
+
if options.local_port is not None:
|
|
963
|
+
return [options.local_port]
|
|
964
|
+
advertised_port = options.port if options.port is not None else _parse_port(os.getenv(options.port_env))
|
|
965
|
+
candidates = [_parse_port(os.getenv(options.local_port_env)), advertised_port, _DEFAULT_LOCAL_PORT]
|
|
966
|
+
return list(dict.fromkeys(port for port in candidates if port is not None))
|
|
896
967
|
|
|
897
968
|
|
|
898
969
|
async def _wait_until_ready(
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
970
|
+
ports: Sequence[int],
|
|
971
|
+
*,
|
|
972
|
+
token: str,
|
|
973
|
+
health_path: str | None = None,
|
|
974
|
+
poll_interval: float = 0.5,
|
|
975
|
+
timeout: float = 30.0,
|
|
976
|
+
) -> int | None:
|
|
977
|
+
"""Poll the candidate local ports until this app serves requests, returning the ready port or None."""
|
|
902
978
|
import httpx
|
|
903
979
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
else:
|
|
908
|
-
url = f"http://127.0.0.1:{port}/"
|
|
909
|
-
check_kind = "tcp"
|
|
980
|
+
path = health_path or "/"
|
|
981
|
+
check_kind = "health" if health_path else "tcp"
|
|
982
|
+
headers = {_READINESS_TOKEN_HEADER: token}
|
|
910
983
|
|
|
911
984
|
deadline = asyncio.get_event_loop().time() + timeout
|
|
912
985
|
while asyncio.get_event_loop().time() < deadline:
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
986
|
+
for port in ports:
|
|
987
|
+
try:
|
|
988
|
+
async with httpx.AsyncClient() as client:
|
|
989
|
+
response = await client.get(f"http://127.0.0.1:{port}{path}", headers=headers, timeout=2.0)
|
|
990
|
+
except Exception:
|
|
991
|
+
continue
|
|
992
|
+
# Something else listening on a candidate port (e.g. the orchestrator itself) is not us
|
|
993
|
+
if response.headers.get(_READINESS_TOKEN_HEADER) != token:
|
|
994
|
+
continue
|
|
995
|
+
# In tcp mode any response means the server is accepting connections
|
|
996
|
+
if not health_path or response.status_code == 200:
|
|
997
|
+
return port
|
|
924
998
|
await asyncio.sleep(poll_interval)
|
|
925
999
|
|
|
926
|
-
logger.warning("registration.readiness_timeout",
|
|
927
|
-
return
|
|
1000
|
+
logger.warning("registration.readiness_timeout", ports=list(ports), check=check_kind, timeout=timeout)
|
|
1001
|
+
return None
|
|
928
1002
|
|
|
929
1003
|
|
|
930
1004
|
def _fail_registration(app: FastAPI, reason: str) -> None:
|
|
@@ -992,18 +1066,19 @@ async def _register_after_ready(
|
|
|
992
1066
|
options: _RegistrationOptions, info: BaseModel, app: FastAPI, health_path: str | None
|
|
993
1067
|
) -> None:
|
|
994
1068
|
"""Wait for the app to be ready, then register with the orchestrator."""
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
if
|
|
1069
|
+
ports = _local_port_candidates(options)
|
|
1070
|
+
ready_port = await _wait_until_ready(ports, token=app.state.readiness_token, health_path=health_path)
|
|
1071
|
+
if ready_port is None:
|
|
998
1072
|
if options.fail_on_error:
|
|
999
|
-
_fail_registration(app, f"App on
|
|
1073
|
+
_fail_registration(app, f"App never became ready on local ports {ports}, cannot register")
|
|
1000
1074
|
else:
|
|
1001
1075
|
logger.error(
|
|
1002
1076
|
"registration.aborted",
|
|
1003
|
-
|
|
1077
|
+
ports=ports,
|
|
1004
1078
|
message="App never became ready, skipping registration",
|
|
1005
1079
|
)
|
|
1006
1080
|
return
|
|
1081
|
+
logger.debug("registration.ready", port=ready_port)
|
|
1007
1082
|
|
|
1008
1083
|
# Shield registration from cancellation so that if the POST succeeds,
|
|
1009
1084
|
# app.state.registration_info is always written before the task exits.
|
|
@@ -41,9 +41,10 @@ def run_app(
|
|
|
41
41
|
Args:
|
|
42
42
|
app: FastAPI app instance OR string "module:app" path
|
|
43
43
|
host: Server host (default: "127.0.0.1", env: HOST)
|
|
44
|
-
port: Server port (default: 8000, env: PORT). The resolved value is
|
|
45
|
-
exported as
|
|
46
|
-
|
|
44
|
+
port: Server port (default: 8000, env: PORT). The resolved value is
|
|
45
|
+
exported as PORT so service registration probes the port uvicorn
|
|
46
|
+
binds, and as SERVICEKIT_PORT (unless already set) so it is also
|
|
47
|
+
the advertised port by default.
|
|
47
48
|
workers: Number of worker processes (default: 1, env: WORKERS)
|
|
48
49
|
reload: Enable auto-reload (default: True for string, False for instance)
|
|
49
50
|
log_level: Logging level (default: from LOG_LEVEL env var or "info")
|
|
@@ -71,10 +72,11 @@ def run_app(
|
|
|
71
72
|
reload = False
|
|
72
73
|
|
|
73
74
|
# Make the bind port visible to service registration, which runs inside the
|
|
74
|
-
# app lifespan
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
# proxy) still wins.
|
|
75
|
+
# app lifespan with no knowledge of where uvicorn actually bound. PORT is the
|
|
76
|
+
# local port its readiness probe tries first. SERVICEKIT_PORT is the port
|
|
77
|
+
# advertised to the orchestrator; setdefault means an explicitly set value
|
|
78
|
+
# (e.g. a published host port or a port behind a proxy) still wins.
|
|
79
|
+
os.environ["PORT"] = str(resolved_port)
|
|
78
80
|
os.environ.setdefault("SERVICEKIT_PORT", str(resolved_port))
|
|
79
81
|
|
|
80
82
|
uvicorn.run(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|