servicekit 1.0.0__tar.gz → 2.0.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.
Files changed (41) hide show
  1. {servicekit-1.0.0 → servicekit-2.0.0}/PKG-INFO +34 -3
  2. {servicekit-1.0.0 → servicekit-2.0.0}/README.md +33 -2
  3. servicekit-2.0.0/pyproject.toml +180 -0
  4. servicekit-1.0.0/pyproject.toml → servicekit-2.0.0/pyproject.toml.orig +4 -1
  5. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/__init__.py +2 -1
  6. servicekit-2.0.0/src/servicekit/alembic/env.py +76 -0
  7. servicekit-2.0.0/src/servicekit/alembic/script.py.mako +26 -0
  8. servicekit-2.0.0/src/servicekit/alembic/versions/20251010_0927_4d869b5fb06e_initial_schema.py +27 -0
  9. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/__init__.py +3 -13
  10. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/app.py +19 -0
  11. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/crud.py +14 -13
  12. servicekit-2.0.0/src/servicekit/api/dependencies.py +55 -0
  13. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/middleware.py +68 -11
  14. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/monitoring.py +17 -6
  15. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/registration.py +51 -54
  16. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/routers/health.py +10 -3
  17. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/routers/job.py +4 -4
  18. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/service_builder.py +211 -108
  19. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/utilities.py +10 -1
  20. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/database.py +31 -5
  21. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/exceptions.py +1 -0
  22. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/manager.py +39 -9
  23. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/repository.py +26 -6
  24. servicekit-2.0.0/src/servicekit/scheduler.py +500 -0
  25. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/schemas.py +7 -5
  26. servicekit-1.0.0/src/servicekit/api/dependencies.py +0 -69
  27. servicekit-1.0.0/src/servicekit/scheduler.py +0 -302
  28. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/apps/landing/index.html +0 -0
  29. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/apps/landing/manifest.json +0 -0
  30. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/auth.py +0 -0
  31. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/pagination.py +0 -0
  32. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/router.py +0 -0
  33. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/routers/__init__.py +0 -0
  34. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/routers/metrics.py +0 -0
  35. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/routers/system.py +0 -0
  36. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/sse.py +0 -0
  37. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/gunicorn.conf.py +0 -0
  38. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/logging.py +0 -0
  39. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/models.py +0 -0
  40. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/py.typed +0 -0
  41. {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: servicekit
3
- Version: 1.0.0
3
+ Version: 2.0.0
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
@@ -89,7 +89,7 @@ servicekit/
89
89
  ├── repository.py # Repository, BaseRepository
90
90
  ├── manager.py # Manager, BaseManager
91
91
  ├── schemas.py # EntityIn, EntityOut, PaginatedResponse
92
- ├── scheduler.py # JobScheduler, AIOJobScheduler
92
+ ├── scheduler.py # Scheduler, InMemoryScheduler
93
93
  ├── exceptions.py # Error classes
94
94
  ├── logging.py # Structured logging
95
95
  ├── types.py # ULIDType, JsonSafe
@@ -122,6 +122,32 @@ app = (
122
122
  )
123
123
  ```
124
124
 
125
+ ### Database Migrations
126
+
127
+ File-based databases run Alembic migrations on `init()` by default; in-memory databases create tables
128
+ directly from the ORM metadata. The migration bundled with servicekit is a framework baseline: it creates
129
+ only the `alembic_version` table and no application tables. Applications that define their own entities must
130
+ either point migrations at their own migration directory or disable migrations and create tables directly.
131
+
132
+ ```python
133
+ from pathlib import Path
134
+
135
+ from servicekit import SqliteDatabaseBuilder, get_alembic_dir
136
+
137
+ # Application-owned migrations
138
+ database = (
139
+ SqliteDatabaseBuilder.from_file("app.db")
140
+ .with_migrations(enabled=True, alembic_dir=Path("alembic"))
141
+ .build()
142
+ )
143
+
144
+ # No migrations - create tables directly from ORM metadata
145
+ database = SqliteDatabaseBuilder.from_file("app.db").with_migrations(enabled=False).build()
146
+
147
+ # Path to the migration environment shipped inside the servicekit package
148
+ bundled_migrations = get_alembic_dir()
149
+ ```
150
+
125
151
  ### Repository Pattern
126
152
 
127
153
  ```python
@@ -153,6 +179,10 @@ router = CrudRouter.create(
153
179
  )
154
180
  ```
155
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.
185
+
156
186
 
157
187
  ## Examples
158
188
 
@@ -172,7 +202,8 @@ See `docs/` for comprehensive guides and API reference.
172
202
 
173
203
  ```bash
174
204
  make test # Run tests
175
- make lint # Run linter
205
+ make format # Format code and apply lint fixes
206
+ make lint # Check formatting, linting and types
176
207
  make coverage # Test coverage
177
208
  ```
178
209
 
@@ -53,7 +53,7 @@ servicekit/
53
53
  ├── repository.py # Repository, BaseRepository
54
54
  ├── manager.py # Manager, BaseManager
55
55
  ├── schemas.py # EntityIn, EntityOut, PaginatedResponse
56
- ├── scheduler.py # JobScheduler, AIOJobScheduler
56
+ ├── scheduler.py # Scheduler, InMemoryScheduler
57
57
  ├── exceptions.py # Error classes
58
58
  ├── logging.py # Structured logging
59
59
  ├── types.py # ULIDType, JsonSafe
@@ -86,6 +86,32 @@ app = (
86
86
  )
87
87
  ```
88
88
 
89
+ ### Database Migrations
90
+
91
+ File-based databases run Alembic migrations on `init()` by default; in-memory databases create tables
92
+ directly from the ORM metadata. The migration bundled with servicekit is a framework baseline: it creates
93
+ only the `alembic_version` table and no application tables. Applications that define their own entities must
94
+ either point migrations at their own migration directory or disable migrations and create tables directly.
95
+
96
+ ```python
97
+ from pathlib import Path
98
+
99
+ from servicekit import SqliteDatabaseBuilder, get_alembic_dir
100
+
101
+ # Application-owned migrations
102
+ database = (
103
+ SqliteDatabaseBuilder.from_file("app.db")
104
+ .with_migrations(enabled=True, alembic_dir=Path("alembic"))
105
+ .build()
106
+ )
107
+
108
+ # No migrations - create tables directly from ORM metadata
109
+ database = SqliteDatabaseBuilder.from_file("app.db").with_migrations(enabled=False).build()
110
+
111
+ # Path to the migration environment shipped inside the servicekit package
112
+ bundled_migrations = get_alembic_dir()
113
+ ```
114
+
89
115
  ### Repository Pattern
90
116
 
91
117
  ```python
@@ -117,6 +143,10 @@ router = CrudRouter.create(
117
143
  )
118
144
  ```
119
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.
149
+
120
150
 
121
151
  ## Examples
122
152
 
@@ -136,7 +166,8 @@ See `docs/` for comprehensive guides and API reference.
136
166
 
137
167
  ```bash
138
168
  make test # Run tests
139
- make lint # Run linter
169
+ make format # Format code and apply lint fixes
170
+ make lint # Check formatting, linting and types
140
171
  make coverage # Test coverage
141
172
  ```
142
173
 
@@ -0,0 +1,180 @@
1
+ [project]
2
+ name = "servicekit"
3
+ version = "2.0.0"
4
+ description = "Async SQLAlchemy framework with FastAPI integration - reusable foundation for building data services"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13,<3.14"
7
+ keywords = [
8
+ "fastapi",
9
+ "sqlalchemy",
10
+ "async",
11
+ "database",
12
+ "rest-api",
13
+ "crud",
14
+ "framework",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 5 - Production/Stable",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Framework :: FastAPI",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ "Topic :: Database",
24
+ ]
25
+ dependencies = [
26
+ "aiosqlite>=0.21.0",
27
+ "alembic>=1.17.0",
28
+ "fastapi[standard]>=0.136.3",
29
+ "gunicorn>=23.0.0",
30
+ "opentelemetry-api>=1.37.0",
31
+ "opentelemetry-exporter-prometheus>=0.58b0",
32
+ "opentelemetry-instrumentation-fastapi>=0.58b0",
33
+ "opentelemetry-instrumentation-sqlalchemy>=0.58b0",
34
+ "opentelemetry-sdk>=1.37.0",
35
+ "pydantic>=2.12.0",
36
+ "python-ulid>=3.1.0",
37
+ "sqlalchemy[asyncio]>=2.0.43",
38
+ "starlette>=1.0.1",
39
+ "structlog>=24.4.0",
40
+ ]
41
+
42
+ [[project.authors]]
43
+ name = "Morten Hansen"
44
+ email = "morten@dhis2.org"
45
+
46
+ [project.license]
47
+ text = "AGPL-3.0-or-later"
48
+
49
+ [project.optional-dependencies]
50
+
51
+ [project.urls]
52
+ Homepage = "https://github.com/dhis2-chap/servicekit"
53
+ Repository = "https://github.com/dhis2-chap/servicekit"
54
+ Issues = "https://github.com/dhis2-chap/servicekit/issues"
55
+ Documentation = "https://dhis2-chap.github.io/servicekit"
56
+
57
+ [dependency-groups]
58
+ dev = [
59
+ "coverage[toml]>=7.13.2",
60
+ "mypy>=1.19.1",
61
+ "pytest>=9.0.2",
62
+ "pytest-cov>=5.0.0",
63
+ "pytest-asyncio>=1.2.0",
64
+ "ruff>=0.14.14",
65
+ "pyright>=1.1.408",
66
+ "mkdocs>=1.6.0",
67
+ "mkdocs-material>=9.7.1",
68
+ "mkdocstrings[python]>=1.0.2",
69
+ "httpx2>=2.4.0",
70
+ ]
71
+
72
+ [build-system]
73
+ requires = ["uv_build>=0.9.0,<0.12.0"]
74
+ build-backend = "uv_build"
75
+
76
+ [tool.ruff]
77
+ target-version = "py313"
78
+ line-length = 120
79
+
80
+ [tool.ruff.lint]
81
+ fixable = ["ALL"]
82
+ select = [
83
+ "E",
84
+ "W",
85
+ "F",
86
+ "I",
87
+ "D",
88
+ ]
89
+ ignore = [
90
+ "D203",
91
+ "D213",
92
+ ]
93
+
94
+ [tool.ruff.lint.isort]
95
+ known-third-party = ["alembic"]
96
+
97
+ [tool.ruff.lint.pydocstyle]
98
+ convention = "google"
99
+
100
+ [tool.ruff.lint.per-file-ignores]
101
+ "tests/**/*.py" = ["D"]
102
+ "**/__init__.py" = ["D104"]
103
+ "src/**/*.py" = [
104
+ "D102",
105
+ "D105",
106
+ "D107",
107
+ ]
108
+ "examples/**/*.py" = []
109
+
110
+ [tool.ruff.format]
111
+ quote-style = "double"
112
+ indent-style = "space"
113
+ skip-magic-trailing-comma = false
114
+ docstring-code-format = true
115
+ docstring-code-line-length = "dynamic"
116
+
117
+ [tool.pytest.ini_options]
118
+ asyncio_mode = "auto"
119
+ testpaths = ["tests"]
120
+ norecursedirs = [
121
+ "examples",
122
+ ".git",
123
+ ".venv",
124
+ "__pycache__",
125
+ ]
126
+
127
+ [tool.coverage.run]
128
+ branch = true
129
+ dynamic_context = "test_function"
130
+ relative_files = true
131
+ source = ["servicekit"]
132
+
133
+ [tool.coverage.report]
134
+ exclude_also = ["if TYPE_CHECKING:"]
135
+ precision = 2
136
+ show_missing = true
137
+ skip_covered = true
138
+
139
+ [tool.mypy]
140
+ python_version = "3.13"
141
+ warn_return_any = true
142
+ warn_unused_configs = true
143
+ disallow_untyped_defs = true
144
+ check_untyped_defs = true
145
+ disallow_any_unimported = true
146
+ no_implicit_optional = true
147
+ warn_unused_ignores = true
148
+ strict_equality = true
149
+ mypy_path = ["src"]
150
+
151
+ [[tool.mypy.overrides]]
152
+ module = "tests.*"
153
+ disallow_untyped_defs = false
154
+
155
+ [[tool.mypy.overrides]]
156
+ module = "examples.*"
157
+ ignore_missing_imports = true
158
+ disallow_untyped_defs = false
159
+ ignore_errors = true
160
+
161
+ [tool.pyright]
162
+ include = [
163
+ "src",
164
+ "tests",
165
+ "examples",
166
+ ]
167
+ pythonVersion = "3.13"
168
+ typeCheckingMode = "strict"
169
+ useLibraryCodeForTypes = true
170
+ reportPrivateUsage = false
171
+ reportUnusedFunction = false
172
+ reportUnknownMemberType = false
173
+ reportUnknownArgumentType = false
174
+ reportUnknownLambdaType = false
175
+ reportUnknownParameterType = false
176
+ reportUnknownVariableType = false
177
+ reportMissingTypeArgument = false
178
+ reportMissingTypeStubs = false
179
+ reportMissingImports = "none"
180
+ reportMissingModuleSource = "none"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "servicekit"
3
- version = "1.0.0"
3
+ version = "2.0.0"
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" }]
@@ -77,6 +77,9 @@ fixable = ["ALL"]
77
77
  select = ["E", "W", "F", "I", "D"]
78
78
  ignore = ["D203", "D213"]
79
79
 
80
+ [tool.ruff.lint.isort]
81
+ known-third-party = ["alembic"]
82
+
80
83
  [tool.ruff.lint.pydocstyle]
81
84
  convention = "google"
82
85
 
@@ -11,7 +11,7 @@ except Exception:
11
11
  __version__ = "unknown"
12
12
 
13
13
  # Base infrastructure (framework-agnostic)
14
- from .database import Database, SqliteDatabase, SqliteDatabaseBuilder
14
+ from .database import Database, SqliteDatabase, SqliteDatabaseBuilder, get_alembic_dir
15
15
  from .exceptions import (
16
16
  BadRequestError,
17
17
  ConflictError,
@@ -47,6 +47,7 @@ __all__ = [
47
47
  "Database",
48
48
  "SqliteDatabase",
49
49
  "SqliteDatabaseBuilder",
50
+ "get_alembic_dir",
50
51
  "Repository",
51
52
  "BaseRepository",
52
53
  "Manager",
@@ -0,0 +1,76 @@
1
+ """Alembic environment configuration for async SQLAlchemy migrations."""
2
+
3
+ import asyncio
4
+ from logging.config import fileConfig
5
+
6
+ from alembic import context
7
+ from sqlalchemy import pool
8
+ from sqlalchemy.engine import Connection
9
+ from sqlalchemy.ext.asyncio import async_engine_from_config
10
+
11
+ # Import the Base metadata from servicekit models
12
+ from servicekit.models import Base
13
+
14
+ # This is the Alembic Config object
15
+ config = context.config
16
+
17
+ # Interpret the config file for Python logging (if present)
18
+ if config.config_file_name is not None:
19
+ fileConfig(config.config_file_name)
20
+
21
+ # Set target metadata for 'autogenerate' support
22
+ target_metadata = Base.metadata
23
+
24
+
25
+ def run_migrations_offline() -> None:
26
+ """Run migrations in 'offline' mode (generates SQL scripts)."""
27
+ url = config.get_main_option("sqlalchemy.url")
28
+ context.configure(
29
+ url=url,
30
+ target_metadata=target_metadata,
31
+ literal_binds=True,
32
+ dialect_opts={"paramstyle": "named"},
33
+ )
34
+
35
+ with context.begin_transaction():
36
+ context.run_migrations()
37
+
38
+
39
+ def do_run_migrations(connection: Connection) -> None:
40
+ """Run migrations with a connection."""
41
+ context.configure(connection=connection, target_metadata=target_metadata)
42
+
43
+ with context.begin_transaction():
44
+ context.run_migrations()
45
+
46
+
47
+ async def run_async_migrations() -> None:
48
+ """Run migrations in async mode using async engine."""
49
+ connectable = async_engine_from_config(
50
+ config.get_section(config.config_ini_section, {}),
51
+ prefix="sqlalchemy.",
52
+ poolclass=pool.NullPool,
53
+ )
54
+
55
+ async with connectable.connect() as connection:
56
+ await connection.run_sync(do_run_migrations)
57
+
58
+ await connectable.dispose()
59
+
60
+
61
+ def run_migrations_online() -> None:
62
+ """Run migrations in 'online' mode (connects to database)."""
63
+ # Since we're being called from Database.init() via run_in_executor,
64
+ # we're in a separate thread and can safely create a new event loop
65
+ loop = asyncio.new_event_loop()
66
+ asyncio.set_event_loop(loop)
67
+ try:
68
+ loop.run_until_complete(run_async_migrations())
69
+ finally:
70
+ loop.close()
71
+
72
+
73
+ if context.is_offline_mode():
74
+ run_migrations_offline()
75
+ else:
76
+ run_migrations_online()
@@ -0,0 +1,26 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+ ${imports if imports else ""}
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision = ${repr(up_revision)}
14
+ down_revision = ${repr(down_revision)}
15
+ branch_labels = ${repr(branch_labels)}
16
+ depends_on = ${repr(depends_on)}
17
+
18
+
19
+ def upgrade() -> None:
20
+ """Apply database schema changes."""
21
+ ${upgrades if upgrades else "pass"}
22
+
23
+
24
+ def downgrade() -> None:
25
+ """Revert database schema changes."""
26
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,27 @@
1
+ """Initial database schema migration - no tables for pure framework.
2
+
3
+ Note: While servicekit is a pure framework without domain tables,
4
+ the Entity base class includes common fields (id, created_at, updated_at, tags)
5
+ that all domain entities inherit. These fields are defined in the ORM models
6
+ and automatically included when domain tables are created.
7
+ """
8
+
9
+ # revision identifiers, used by Alembic.
10
+ revision = "4d869b5fb06e"
11
+ down_revision = None
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ """Apply database schema changes."""
18
+ # Servicekit is a pure framework - no domain-specific tables
19
+ # Domain tables (tasks, artifacts, configs) are provided by chapkit
20
+ # Entity base class fields (id, created_at, updated_at, tags) are
21
+ # automatically included in all domain entity tables via ORM inheritance
22
+ pass
23
+
24
+
25
+ def downgrade() -> None:
26
+ """Revert database schema changes."""
27
+ pass
@@ -1,17 +1,9 @@
1
1
  """FastAPI framework layer - routers, middleware, utilities."""
2
2
 
3
- from .app import App, AppInfo, AppLoader, AppManager, AppManifest
3
+ from .app import App, AppInfo, AppLoader, AppManager, AppManifest, EntryStaticFiles
4
4
  from .auth import APIKeyMiddleware, load_api_keys_from_env, load_api_keys_from_file, validate_api_key_format
5
5
  from .crud import CrudPermissions, CrudRouter
6
- from .dependencies import (
7
- get_app_manager,
8
- get_database,
9
- get_scheduler,
10
- get_session,
11
- set_app_manager,
12
- set_database,
13
- set_scheduler,
14
- )
6
+ from .dependencies import get_app_manager, get_database, get_scheduler, get_session
15
7
  from .middleware import add_error_handlers, add_logging_middleware, database_error_handler, validation_error_handler
16
8
  from .pagination import PaginationParams, create_paginated_response
17
9
  from .router import Router
@@ -34,6 +26,7 @@ __all__ = [
34
26
  "AppLoader",
35
27
  "AppManifest",
36
28
  "AppManager",
29
+ "EntryStaticFiles",
37
30
  # Authentication
38
31
  "APIKeyMiddleware",
39
32
  "load_api_keys_from_env",
@@ -41,12 +34,9 @@ __all__ = [
41
34
  "validate_api_key_format",
42
35
  # Dependencies
43
36
  "get_app_manager",
44
- "set_app_manager",
45
37
  "get_database",
46
- "set_database",
47
38
  "get_session",
48
39
  "get_scheduler",
49
- "set_scheduler",
50
40
  # Middleware
51
41
  "add_error_handlers",
52
42
  "add_logging_middleware",
@@ -6,8 +6,12 @@ import importlib.util
6
6
  import json
7
7
  from dataclasses import dataclass
8
8
  from pathlib import Path
9
+ from typing import Any
9
10
 
10
11
  from pydantic import BaseModel, ConfigDict, Field, field_validator
12
+ from starlette.responses import Response
13
+ from starlette.staticfiles import StaticFiles
14
+ from starlette.types import Scope
11
15
 
12
16
  from servicekit.logging import get_logger
13
17
 
@@ -63,6 +67,21 @@ class App:
63
67
  is_package: bool # True if loaded from package resources
64
68
 
65
69
 
70
+ class EntryStaticFiles(StaticFiles):
71
+ """Static file server that serves an app's configured entry file at the mount root."""
72
+
73
+ def __init__(self, *args: Any, entry: str = "index.html", **kwargs: Any) -> None:
74
+ """Initialize static files with the entry filename served for the mount root."""
75
+ super().__init__(*args, **kwargs)
76
+ self.entry = entry
77
+
78
+ async def get_response(self, path: str, scope: Scope) -> Response:
79
+ """Serve the configured entry file for the mount root, other paths unchanged."""
80
+ if path in ("", "."):
81
+ return await super().get_response(self.entry, scope)
82
+ return await super().get_response(path, scope)
83
+
84
+
66
85
  class AppLoader:
67
86
  """Loads and validates apps from filesystem or package resources."""
68
87
 
@@ -1,9 +1,9 @@
1
1
  """CRUD router base class for standard REST operations."""
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Any, Callable
4
+ from typing import Annotated, Any, Callable, cast
5
5
 
6
- from fastapi import Depends, Request, Response, status
6
+ from fastapi import Depends, Query, Request, Response, status
7
7
  from pydantic import BaseModel
8
8
  from ulid import ULID
9
9
 
@@ -180,7 +180,7 @@ class CrudRouter[InSchemaT: BaseModel, OutSchemaT: BaseModel](Router):
180
180
  ) -> OutSchemaT:
181
181
  from .utilities import build_location_url
182
182
 
183
- created_entity = await manager.save(entity_in)
183
+ created_entity = await manager.create(entity_in)
184
184
  entity_id = getattr(created_entity, "id")
185
185
  response.headers["Location"] = build_location_url(request, f"{router_prefix}/{entity_id}")
186
186
  return created_entity
@@ -190,19 +190,23 @@ class CrudRouter[InSchemaT: BaseModel, OutSchemaT: BaseModel](Router):
190
190
  create.__annotations__["return"] = entity_out_annotation
191
191
 
192
192
  def _register_find_all_route(self, manager_dependency: Any, manager_annotation: Any) -> None:
193
+ from .pagination import PaginationParams, create_paginated_response
194
+
193
195
  entity_out_annotation: Any = self.entity_out_type
194
196
  collection_response_model: Any = list[entity_out_annotation] | PaginatedResponse[entity_out_annotation]
195
197
 
196
198
  @self.router.get("", response_model=collection_response_model)
197
199
  async def find_all(
198
- page: int | None = None,
199
- size: int | None = None,
200
+ pagination: Annotated[PaginationParams, Query()],
200
201
  manager: Manager[InSchemaT, OutSchemaT, ULID] = manager_dependency,
201
202
  ) -> list[OutSchemaT] | PaginatedResponse[OutSchemaT]:
202
- from .pagination import create_paginated_response
203
+ """List entities; pagination is opt-in and requires both page (>= 1) and size (1-100).
203
204
 
204
- # Pagination is opt-in: both page and size must be provided
205
- if page is not None and size is not None:
205
+ Supplying only one of them returns the plain unpaginated list.
206
+ """
207
+ if pagination.is_paginated():
208
+ page = cast(int, pagination.page)
209
+ size = cast(int, pagination.size)
206
210
  items, total = await manager.find_paginated(page, size)
207
211
  return create_paginated_response(items, total, page, size)
208
212
  return await manager.find_all()
@@ -234,8 +238,7 @@ class CrudRouter[InSchemaT: BaseModel, OutSchemaT: BaseModel](Router):
234
238
  find_by_id.__annotations__["return"] = entity_out_annotation
235
239
 
236
240
  def _register_update_route(self, manager_dependency: Any, manager_annotation: Any) -> None:
237
- entity_in_type = self.entity_in_type
238
- entity_in_annotation: Any = entity_in_type
241
+ entity_in_annotation: Any = self.entity_in_type
239
242
  entity_out_annotation: Any = self.entity_out_type
240
243
  router_prefix = self.router.prefix
241
244
 
@@ -253,9 +256,7 @@ class CrudRouter[InSchemaT: BaseModel, OutSchemaT: BaseModel](Router):
253
256
  f"Entity with id {entity_id} not found",
254
257
  instance=f"{router_prefix}/{entity_id}",
255
258
  )
256
- entity_dict = entity_in.model_dump(exclude_unset=True)
257
- entity_dict["id"] = ulid_id
258
- entity_with_id = entity_in_type.model_validate(entity_dict)
259
+ entity_with_id = entity_in.model_copy(update={"id": ulid_id})
259
260
  return await manager.save(entity_with_id)
260
261
 
261
262
  self._annotate_manager(update, manager_annotation)
@@ -0,0 +1,55 @@
1
+ """Request-scoped FastAPI dependencies resolved from ``request.app.state``.
2
+
3
+ Every dependency here is per application: two apps running in the same process each
4
+ resolve their own database, scheduler, and app manager. Library code that runs outside
5
+ a request (for example inside a lifespan) should capture the objects it needs directly
6
+ instead of calling these getters.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import AsyncIterator
12
+ from typing import Annotated
13
+
14
+ from fastapi import Depends, Request
15
+ from sqlalchemy.ext.asyncio import AsyncSession
16
+
17
+ from servicekit import Database
18
+ from servicekit.scheduler import Scheduler
19
+
20
+ from .app import AppManager
21
+
22
+
23
+ def get_database(request: Request) -> Database:
24
+ """Get the database bound to the current application."""
25
+ database: Database | None = getattr(request.app.state, "database", None)
26
+ if database is None:
27
+ raise RuntimeError(
28
+ "Database not available on app.state.database. "
29
+ "The application lifespan must run before requests are served."
30
+ )
31
+ return database
32
+
33
+
34
+ async def get_session(database: Annotated[Database, Depends(get_database)]) -> AsyncIterator[AsyncSession]:
35
+ """Get a database session for dependency injection."""
36
+ async with database.session() as session:
37
+ yield session
38
+
39
+
40
+ def get_scheduler(request: Request) -> Scheduler:
41
+ """Get the scheduler bound to the current application."""
42
+ scheduler: Scheduler | None = getattr(request.app.state, "scheduler", None)
43
+ if scheduler is None:
44
+ raise RuntimeError(
45
+ "Scheduler not available on app.state.scheduler. Enable jobs with .with_jobs() and run the lifespan."
46
+ )
47
+ return scheduler
48
+
49
+
50
+ def get_app_manager(request: Request) -> AppManager:
51
+ """Get the app manager bound to the current application."""
52
+ app_manager: AppManager | None = getattr(request.app.state, "app_manager", None)
53
+ if app_manager is None:
54
+ raise RuntimeError("AppManager not available on app.state.app_manager. Build the app with a ServiceBuilder.")
55
+ return app_manager