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.
- {servicekit-1.0.0 → servicekit-2.0.0}/PKG-INFO +34 -3
- {servicekit-1.0.0 → servicekit-2.0.0}/README.md +33 -2
- servicekit-2.0.0/pyproject.toml +180 -0
- servicekit-1.0.0/pyproject.toml → servicekit-2.0.0/pyproject.toml.orig +4 -1
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/__init__.py +2 -1
- servicekit-2.0.0/src/servicekit/alembic/env.py +76 -0
- servicekit-2.0.0/src/servicekit/alembic/script.py.mako +26 -0
- servicekit-2.0.0/src/servicekit/alembic/versions/20251010_0927_4d869b5fb06e_initial_schema.py +27 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/__init__.py +3 -13
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/app.py +19 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/crud.py +14 -13
- servicekit-2.0.0/src/servicekit/api/dependencies.py +55 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/middleware.py +68 -11
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/monitoring.py +17 -6
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/registration.py +51 -54
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/routers/health.py +10 -3
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/routers/job.py +4 -4
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/service_builder.py +211 -108
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/utilities.py +10 -1
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/database.py +31 -5
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/exceptions.py +1 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/manager.py +39 -9
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/repository.py +26 -6
- servicekit-2.0.0/src/servicekit/scheduler.py +500 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/schemas.py +7 -5
- servicekit-1.0.0/src/servicekit/api/dependencies.py +0 -69
- servicekit-1.0.0/src/servicekit/scheduler.py +0 -302
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/apps/landing/index.html +0 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/apps/landing/manifest.json +0 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/auth.py +0 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/pagination.py +0 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/router.py +0 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/routers/__init__.py +0 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/routers/metrics.py +0 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/routers/system.py +0 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/api/sse.py +0 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/gunicorn.conf.py +0 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/logging.py +0 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/models.py +0 -0
- {servicekit-1.0.0 → servicekit-2.0.0}/src/servicekit/py.typed +0 -0
- {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:
|
|
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 #
|
|
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
|
|
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 #
|
|
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
|
|
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 = "
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
203
|
+
"""List entities; pagination is opt-in and requires both page (>= 1) and size (1-100).
|
|
203
204
|
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|