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.
Files changed (39) hide show
  1. {servicekit-2.0.2 → servicekit-2.0.3}/PKG-INFO +1 -1
  2. {servicekit-2.0.2 → servicekit-2.0.3}/pyproject.toml +1 -1
  3. {servicekit-2.0.2 → servicekit-2.0.3}/pyproject.toml.orig +1 -1
  4. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/service_builder.py +114 -39
  5. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/utilities.py +9 -7
  6. {servicekit-2.0.2 → servicekit-2.0.3}/README.md +0 -0
  7. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/__init__.py +0 -0
  8. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/alembic/env.py +0 -0
  9. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/alembic/script.py.mako +0 -0
  10. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/alembic/versions/20251010_0927_4d869b5fb06e_initial_schema.py +0 -0
  11. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/__init__.py +0 -0
  12. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/app.py +0 -0
  13. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/apps/landing/index.html +0 -0
  14. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/apps/landing/manifest.json +0 -0
  15. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/auth.py +0 -0
  16. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/crud.py +0 -0
  17. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/dependencies.py +0 -0
  18. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/middleware.py +0 -0
  19. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/monitoring.py +0 -0
  20. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/pagination.py +0 -0
  21. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/registration.py +0 -0
  22. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/router.py +0 -0
  23. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/routers/__init__.py +0 -0
  24. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/routers/health.py +0 -0
  25. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/routers/job.py +0 -0
  26. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/routers/metrics.py +0 -0
  27. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/routers/system.py +0 -0
  28. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/api/sse.py +0 -0
  29. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/database.py +0 -0
  30. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/exceptions.py +0 -0
  31. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/gunicorn.conf.py +0 -0
  32. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/logging.py +0 -0
  33. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/manager.py +0 -0
  34. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/models.py +0 -0
  35. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/py.typed +0 -0
  36. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/repository.py +0 -0
  37. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/scheduler.py +0 -0
  38. {servicekit-2.0.2 → servicekit-2.0.3}/src/servicekit/schemas.py +0 -0
  39. {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.2
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.2"
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
  requires-python = ">=3.13,<3.14"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "servicekit"
3
- version = "2.0.2"
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
- def _resolve_port(options: _RegistrationOptions) -> int:
886
- """Resolve port from options or environment, matching register_service logic."""
887
- if options.port is not None:
888
- return options.port
889
- port_str = os.getenv(options.port_env)
890
- if port_str:
891
- try:
892
- return int(port_str)
893
- except ValueError:
894
- return 8000
895
- return 8000
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
- port: int, *, health_path: str | None = None, poll_interval: float = 0.5, timeout: float = 30.0
900
- ) -> bool:
901
- """Poll the local app until it is serving requests."""
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
- if health_path:
905
- url = f"http://127.0.0.1:{port}{health_path}"
906
- check_kind = "health"
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
- try:
914
- async with httpx.AsyncClient() as client:
915
- response = await client.get(url, timeout=2.0)
916
- if health_path:
917
- if response.status_code == 200:
918
- return True
919
- else:
920
- # Any response means the server is accepting connections
921
- return True
922
- except Exception:
923
- pass
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", port=port, check=check_kind, timeout=timeout)
927
- return False
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
- port = _resolve_port(options)
996
- ready = await _wait_until_ready(port, health_path=health_path)
997
- if not ready:
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 port {port} never became ready, cannot register")
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
- port=port,
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 also
45
- exported as SERVICEKIT_PORT (unless already set) so service
46
- registration advertises and probes the same port uvicorn binds.
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 and otherwise resolves its own port (SERVICEKIT_PORT, else
75
- # 8000) with no knowledge of where uvicorn actually bound. setdefault means an
76
- # explicitly set SERVICEKIT_PORT (e.g. an externally advertised port behind a
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