servicekit 2.0.0__tar.gz → 2.0.2__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.0 → servicekit-2.0.2}/PKG-INFO +5 -4
  2. {servicekit-2.0.0 → servicekit-2.0.2}/README.md +4 -3
  3. {servicekit-2.0.0 → servicekit-2.0.2}/pyproject.toml +1 -1
  4. {servicekit-2.0.0 → servicekit-2.0.2}/pyproject.toml.orig +1 -1
  5. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/__init__.py +2 -0
  6. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/__init__.py +2 -1
  7. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/crud.py +25 -5
  8. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/middleware.py +10 -3
  9. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/routers/job.py +26 -14
  10. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/service_builder.py +8 -4
  11. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/exceptions.py +15 -0
  12. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/manager.py +6 -2
  13. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/alembic/env.py +0 -0
  14. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/alembic/script.py.mako +0 -0
  15. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/alembic/versions/20251010_0927_4d869b5fb06e_initial_schema.py +0 -0
  16. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/app.py +0 -0
  17. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/apps/landing/index.html +0 -0
  18. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/apps/landing/manifest.json +0 -0
  19. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/auth.py +0 -0
  20. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/dependencies.py +0 -0
  21. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/monitoring.py +0 -0
  22. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/pagination.py +0 -0
  23. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/registration.py +0 -0
  24. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/router.py +0 -0
  25. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/routers/__init__.py +0 -0
  26. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/routers/health.py +0 -0
  27. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/routers/metrics.py +0 -0
  28. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/routers/system.py +0 -0
  29. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/sse.py +0 -0
  30. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/api/utilities.py +0 -0
  31. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/database.py +0 -0
  32. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/gunicorn.conf.py +0 -0
  33. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/logging.py +0 -0
  34. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/models.py +0 -0
  35. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/py.typed +0 -0
  36. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/repository.py +0 -0
  37. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/scheduler.py +0 -0
  38. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/schemas.py +0 -0
  39. {servicekit-2.0.0 → servicekit-2.0.2}/src/servicekit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: servicekit
3
- Version: 2.0.0
3
+ Version: 2.0.2
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
@@ -179,9 +179,10 @@ router = CrudRouter.create(
179
179
  )
180
180
  ```
181
181
 
182
- `POST` creates only and returns `409 Conflict` for an ID that already exists. `PUT` applies the fields sent: an
183
- omitted field keeps its value, an explicit `null` clears a nullable field. Collection listings are ordered by ID,
184
- and pagination is opt-in through `page` (>= 1) and `size` (1-100), which return `422` when out of range.
182
+ `POST` creates only and returns `409 Conflict` for an ID that already exists or for any other database constraint
183
+ violation. `PUT` applies the fields sent: an omitted field keeps its value, an explicit `null` clears a nullable
184
+ field. Collection listings are ordered by ID, and pagination is opt-in through `page` (>= 1) and `size` (1-100),
185
+ which return `422` when out of range.
185
186
 
186
187
 
187
188
  ## Examples
@@ -143,9 +143,10 @@ router = CrudRouter.create(
143
143
  )
144
144
  ```
145
145
 
146
- `POST` creates only and returns `409 Conflict` for an ID that already exists. `PUT` applies the fields sent: an
147
- omitted field keeps its value, an explicit `null` clears a nullable field. Collection listings are ordered by ID,
148
- and pagination is opt-in through `page` (>= 1) and `size` (1-100), which return `422` when out of range.
146
+ `POST` creates only and returns `409 Conflict` for an ID that already exists or for any other database constraint
147
+ violation. `PUT` applies the fields sent: an omitted field keeps its value, an explicit `null` clears a nullable
148
+ field. Collection listings are ordered by ID, and pagination is opt-in through `page` (>= 1) and `size` (1-100),
149
+ which return `422` when out of range.
149
150
 
150
151
 
151
152
  ## Examples
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "servicekit"
3
- version = "2.0.0"
3
+ version = "2.0.2"
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.0"
3
+ version = "2.0.2"
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" }]
@@ -22,6 +22,7 @@ from .exceptions import (
22
22
  ServicekitException,
23
23
  UnauthorizedError,
24
24
  ValidationError,
25
+ classify_integrity_error,
25
26
  )
26
27
  from .logging import add_request_context, clear_request_context, configure_logging, get_logger, reset_request_context
27
28
  from .manager import BaseManager, LifecycleHooks, Manager
@@ -76,6 +77,7 @@ __all__ = [
76
77
  "NotFoundError",
77
78
  "ValidationError",
78
79
  "ConflictError",
80
+ "classify_integrity_error",
79
81
  "InvalidULIDError",
80
82
  "BadRequestError",
81
83
  "UnauthorizedError",
@@ -8,7 +8,7 @@ from .middleware import add_error_handlers, add_logging_middleware, database_err
8
8
  from .pagination import PaginationParams, create_paginated_response
9
9
  from .router import Router
10
10
  from .routers import HealthRouter, HealthState, HealthStatus, JobRouter, MetricsRouter, SystemInfo, SystemRouter
11
- from .service_builder import BaseServiceBuilder, ServiceInfo
11
+ from .service_builder import BaseServiceBuilder, JobOptions, ServiceInfo
12
12
  from .sse import SSE_HEADERS, format_sse_event, format_sse_model_event
13
13
  from .utilities import build_location_url, run_app
14
14
 
@@ -19,6 +19,7 @@ __all__ = [
19
19
  "CrudPermissions",
20
20
  # Service builder
21
21
  "BaseServiceBuilder",
22
+ "JobOptions",
22
23
  "ServiceInfo",
23
24
  # App system
24
25
  "App",
@@ -9,7 +9,22 @@ from ulid import ULID
9
9
 
10
10
  from servicekit.api.router import Router
11
11
  from servicekit.manager import Manager
12
- from servicekit.schemas import PaginatedResponse
12
+ from servicekit.schemas import PaginatedResponse, ProblemDetail
13
+
14
+ # OpenAPI response documentation shared by CRUD routes
15
+ _CONFLICT_RESPONSES: dict[int | str, dict[str, Any]] = {
16
+ status.HTTP_409_CONFLICT: {
17
+ "model": ProblemDetail,
18
+ "description": "An entity with the supplied id already exists or a database constraint was violated",
19
+ }
20
+ }
21
+
22
+ _NOT_FOUND_RESPONSES: dict[int | str, dict[str, Any]] = {
23
+ status.HTTP_404_NOT_FOUND: {
24
+ "model": ProblemDetail,
25
+ "description": "No entity exists with the supplied id",
26
+ }
27
+ }
13
28
 
14
29
  # Type alias for manager factory function
15
30
  type ManagerFactory[InSchemaT: BaseModel, OutSchemaT: BaseModel] = Callable[..., Manager[InSchemaT, OutSchemaT, ULID]]
@@ -171,7 +186,12 @@ class CrudRouter[InSchemaT: BaseModel, OutSchemaT: BaseModel](Router):
171
186
  entity_out_annotation: Any = self.entity_out_type
172
187
  router_prefix = self.router.prefix
173
188
 
174
- @self.router.post("", status_code=status.HTTP_201_CREATED, response_model=entity_out_annotation)
189
+ @self.router.post(
190
+ "",
191
+ status_code=status.HTTP_201_CREATED,
192
+ response_model=entity_out_annotation,
193
+ responses=_CONFLICT_RESPONSES,
194
+ )
175
195
  async def create(
176
196
  entity_in: InSchemaT,
177
197
  request: Request,
@@ -218,7 +238,7 @@ class CrudRouter[InSchemaT: BaseModel, OutSchemaT: BaseModel](Router):
218
238
  entity_out_annotation: Any = self.entity_out_type
219
239
  router_prefix = self.router.prefix
220
240
 
221
- @self.router.get("/{entity_id}", response_model=entity_out_annotation)
241
+ @self.router.get("/{entity_id}", response_model=entity_out_annotation, responses=_NOT_FOUND_RESPONSES)
222
242
  async def find_by_id(
223
243
  entity_id: str,
224
244
  manager: Manager[InSchemaT, OutSchemaT, ULID] = manager_dependency,
@@ -242,7 +262,7 @@ class CrudRouter[InSchemaT: BaseModel, OutSchemaT: BaseModel](Router):
242
262
  entity_out_annotation: Any = self.entity_out_type
243
263
  router_prefix = self.router.prefix
244
264
 
245
- @self.router.put("/{entity_id}", response_model=entity_out_annotation)
265
+ @self.router.put("/{entity_id}", response_model=entity_out_annotation, responses=_NOT_FOUND_RESPONSES)
246
266
  async def update(
247
267
  entity_id: str,
248
268
  entity_in: InSchemaT,
@@ -266,7 +286,7 @@ class CrudRouter[InSchemaT: BaseModel, OutSchemaT: BaseModel](Router):
266
286
  def _register_delete_route(self, manager_dependency: Any, manager_annotation: Any) -> None:
267
287
  router_prefix = self.router.prefix
268
288
 
269
- @self.router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
289
+ @self.router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT, responses=_NOT_FOUND_RESPONSES)
270
290
  async def delete_by_id(
271
291
  entity_id: str,
272
292
  manager: Manager[InSchemaT, OutSchemaT, ULID] = manager_dependency,
@@ -5,12 +5,13 @@ from typing import Any, Awaitable, Callable
5
5
 
6
6
  from fastapi import Request, Response, status
7
7
  from fastapi.encoders import jsonable_encoder
8
+ from fastapi.exceptions import RequestValidationError
8
9
  from fastapi.responses import JSONResponse
9
10
  from pydantic import ValidationError as PydanticValidationError
10
11
  from starlette.middleware.base import BaseHTTPMiddleware
11
12
  from ulid import ULID
12
13
 
13
- from servicekit.exceptions import ErrorType, ServicekitException
14
+ from servicekit.exceptions import ErrorType, ServicekitException, classify_integrity_error
14
15
  from servicekit.logging import add_request_context, get_logger, reset_request_context
15
16
  from servicekit.schemas import ProblemDetail
16
17
 
@@ -112,6 +113,8 @@ async def database_error_handler(request: Request, exc: Exception) -> JSONRespon
112
113
  )
113
114
 
114
115
  if isinstance(exc, IntegrityError):
116
+ constraint = classify_integrity_error(exc)
117
+ constraint_extension: dict[str, Any] = {"constraint": constraint} if constraint is not None else {}
115
118
  problem = ProblemDetail(
116
119
  type=ErrorType.CONFLICT,
117
120
  title="Conflict",
@@ -119,6 +122,7 @@ async def database_error_handler(request: Request, exc: Exception) -> JSONRespon
119
122
  detail="The request conflicts with existing data",
120
123
  instance=str(request.url),
121
124
  trace_id=trace_id,
125
+ **constraint_extension,
122
126
  )
123
127
  else:
124
128
  problem = ProblemDetail(
@@ -138,9 +142,11 @@ async def database_error_handler(request: Request, exc: Exception) -> JSONRespon
138
142
 
139
143
 
140
144
  async def validation_error_handler(request: Request, exc: Exception) -> JSONResponse:
141
- """Handle validation errors and return RFC 9457 Problem Details with structured errors."""
145
+ """Handle Pydantic and FastAPI request validation errors as RFC 9457 Problem Details with structured errors."""
142
146
  trace_id = str(ULID())
143
- errors = jsonable_encoder(exc.errors()) if isinstance(exc, PydanticValidationError) else None
147
+ errors = (
148
+ jsonable_encoder(exc.errors()) if isinstance(exc, (PydanticValidationError, RequestValidationError)) else None
149
+ )
144
150
 
145
151
  logger.warning(
146
152
  "validation.error",
@@ -213,6 +219,7 @@ def add_error_handlers(app: Any) -> None:
213
219
  app.add_exception_handler(SQLAlchemyError, database_error_handler)
214
220
  app.add_exception_handler(IntegrityError, database_error_handler)
215
221
  app.add_exception_handler(PydanticValidationError, validation_error_handler)
222
+ app.add_exception_handler(RequestValidationError, validation_error_handler)
216
223
 
217
224
 
218
225
  def add_logging_middleware(app: Any) -> None:
@@ -7,12 +7,13 @@ from collections.abc import AsyncGenerator, Callable
7
7
  from typing import Annotated, Any
8
8
 
9
9
  import ulid
10
- from fastapi import Depends, HTTPException, Query, status
10
+ from fastapi import Depends, Query, status
11
11
  from fastapi.responses import Response, StreamingResponse
12
12
  from pydantic import TypeAdapter
13
13
 
14
14
  from servicekit.api.router import Router
15
15
  from servicekit.api.sse import SSE_HEADERS, format_sse_model_event
16
+ from servicekit.exceptions import InvalidULIDError, NotFoundError
16
17
  from servicekit.scheduler import Scheduler
17
18
  from servicekit.schemas import JobRecord, JobStatus
18
19
 
@@ -57,23 +58,23 @@ class JobRouter(Router):
57
58
  job_id: str,
58
59
  scheduler: Scheduler = scheduler_dependency,
59
60
  ) -> JobRecord:
61
+ ulid_id = self._parse_job_id(job_id)
60
62
  try:
61
- ulid_id = ULID.from_str(job_id)
62
63
  return await scheduler.get_record(ulid_id)
63
- except (ValueError, KeyError):
64
- raise HTTPException(status_code=404, detail="Job not found")
64
+ except KeyError as error:
65
+ raise self._job_not_found(job_id) from error
65
66
 
66
67
  @self.router.delete("/{job_id}", summary="Cancel and delete job", status_code=status.HTTP_204_NO_CONTENT)
67
68
  async def delete_job(
68
69
  job_id: str,
69
70
  scheduler: Scheduler = scheduler_dependency,
70
71
  ) -> Response:
72
+ ulid_id = self._parse_job_id(job_id)
71
73
  try:
72
- ulid_id = ULID.from_str(job_id)
73
74
  await scheduler.delete(ulid_id)
74
- return Response(status_code=status.HTTP_204_NO_CONTENT)
75
- except (ValueError, KeyError):
76
- raise HTTPException(status_code=404, detail="Job not found")
75
+ except KeyError as error:
76
+ raise self._job_not_found(job_id) from error
77
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
77
78
 
78
79
  @self.router.get(
79
80
  "/{job_id}/$stream",
@@ -87,16 +88,13 @@ class JobRouter(Router):
87
88
  ) -> StreamingResponse:
88
89
  """Stream real-time job status updates using Server-Sent Events."""
89
90
  # Validate job_id format
90
- try:
91
- ulid_id = ULID.from_str(job_id)
92
- except ValueError:
93
- raise HTTPException(status_code=400, detail="Invalid job ID format")
91
+ ulid_id = self._parse_job_id(job_id)
94
92
 
95
93
  # Check job exists before starting stream
96
94
  try:
97
95
  await scheduler.get_record(ulid_id)
98
- except KeyError:
99
- raise HTTPException(status_code=404, detail="Job not found")
96
+ except KeyError as error:
97
+ raise self._job_not_found(job_id) from error
100
98
 
101
99
  # SSE event generator
102
100
  async def event_stream() -> AsyncGenerator[bytes, None]:
@@ -124,3 +122,17 @@ class JobRouter(Router):
124
122
  media_type="text/event-stream",
125
123
  headers=SSE_HEADERS,
126
124
  )
125
+
126
+ def _parse_job_id(self, job_id: str) -> ULID:
127
+ """Parse a job ID as a ULID, raising InvalidULIDError when malformed."""
128
+ try:
129
+ return ULID.from_str(job_id)
130
+ except ValueError as error:
131
+ raise InvalidULIDError(
132
+ f"Invalid job ID format: {job_id}",
133
+ instance=f"{self.router.prefix}/{job_id}",
134
+ ) from error
135
+
136
+ def _job_not_found(self, job_id: str) -> NotFoundError:
137
+ """Build the not-found error for a missing job."""
138
+ return NotFoundError(f"Job with id {job_id} not found", instance=f"{self.router.prefix}/{job_id}")
@@ -54,7 +54,7 @@ class _SystemOptions:
54
54
 
55
55
 
56
56
  @dataclass(frozen=True)
57
- class _JobOptions:
57
+ class JobOptions:
58
58
  """Configuration for job scheduler endpoints."""
59
59
 
60
60
  prefix: str
@@ -63,6 +63,10 @@ class _JobOptions:
63
63
  shutdown_timeout: float
64
64
 
65
65
 
66
+ # Backwards-compatible alias for the previously private name
67
+ _JobOptions = JobOptions
68
+
69
+
66
70
  @dataclass(frozen=True)
67
71
  class _AuthOptions:
68
72
  """Configuration for API key authentication."""
@@ -153,7 +157,7 @@ class BaseServiceBuilder:
153
157
  self._include_logging = include_logging
154
158
  self._health_options: _HealthOptions | None = None
155
159
  self._system_options: _SystemOptions | None = None
156
- self._job_options: _JobOptions | None = None
160
+ self._job_options: JobOptions | None = None
157
161
  self._auth_options: _AuthOptions | None = None
158
162
  self._monitoring_options: _MonitoringOptions | None = None
159
163
  self._registration_options: _RegistrationOptions | None = None
@@ -247,7 +251,7 @@ class BaseServiceBuilder:
247
251
  shutdown_timeout: float = 10.0,
248
252
  ) -> Self:
249
253
  """Add job scheduler endpoints."""
250
- self._job_options = _JobOptions(
254
+ self._job_options = JobOptions(
251
255
  prefix=prefix,
252
256
  tags=list(tags) if tags is not None else ["Jobs"],
253
257
  max_concurrency=max_concurrency,
@@ -776,7 +780,7 @@ class BaseServiceBuilder:
776
780
 
777
781
  return lifespan
778
782
 
779
- def _create_scheduler(self, job_options: _JobOptions) -> Scheduler:
783
+ def _create_scheduler(self, job_options: JobOptions) -> Scheduler:
780
784
  """Create the scheduler used by the application (override in subclasses)."""
781
785
  from servicekit.scheduler import InMemoryScheduler
782
786
 
@@ -137,3 +137,18 @@ class ForbiddenError(ServicekitException):
137
137
  instance=instance,
138
138
  **extensions,
139
139
  )
140
+
141
+
142
+ def classify_integrity_error(error: Exception) -> str | None:
143
+ """Classify a database integrity error as a safe constraint name, or None when unrecognized."""
144
+ driver_error = getattr(error, "orig", None)
145
+ message = str(driver_error if driver_error is not None else error).upper()
146
+ if "FOREIGN KEY" in message:
147
+ return "foreign_key"
148
+ if "UNIQUE" in message:
149
+ return "unique"
150
+ if "NOT NULL" in message:
151
+ return "not_null"
152
+ if "CHECK" in message:
153
+ return "check"
154
+ return None
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Iterable, Sequence
8
8
  from pydantic import BaseModel
9
9
  from sqlalchemy.exc import IntegrityError
10
10
 
11
- from servicekit.exceptions import ConflictError
11
+ from servicekit.exceptions import ConflictError, classify_integrity_error
12
12
  from servicekit.repository import BaseRepository
13
13
 
14
14
  if TYPE_CHECKING:
@@ -154,7 +154,11 @@ class BaseManager[ModelT, InSchemaT: BaseModel, OutSchemaT: BaseModel, IdT](
154
154
  await self.repo.commit()
155
155
  except IntegrityError as e:
156
156
  await self.repo.rollback()
157
- raise ConflictError(f"Entity with id {entity_id} already exists") from e
157
+ if entity_id is not None and await self.repo.exists_by_id(entity_id):
158
+ raise ConflictError(f"Entity with id {entity_id} already exists") from e
159
+ constraint = classify_integrity_error(e)
160
+ extensions = {"constraint": constraint} if constraint is not None else {}
161
+ raise ConflictError("Entity violates a database constraint", **extensions) from e
158
162
  await self.repo.refresh_many([entity])
159
163
  await self.post_save(entity)
160
164
  return self._to_output_schema(entity)