servicekit 1.0.1__tar.gz → 2.0.1__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.1 → servicekit-2.0.1}/PKG-INFO +35 -3
  2. {servicekit-1.0.1 → servicekit-2.0.1}/README.md +34 -2
  3. servicekit-2.0.1/pyproject.toml +180 -0
  4. servicekit-1.0.1/pyproject.toml → servicekit-2.0.1/pyproject.toml.orig +4 -1
  5. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/__init__.py +4 -1
  6. servicekit-2.0.1/src/servicekit/alembic/env.py +76 -0
  7. servicekit-2.0.1/src/servicekit/alembic/script.py.mako +26 -0
  8. servicekit-2.0.1/src/servicekit/alembic/versions/20251010_0927_4d869b5fb06e_initial_schema.py +27 -0
  9. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/__init__.py +5 -14
  10. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/app.py +19 -0
  11. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/crud.py +39 -18
  12. servicekit-2.0.1/src/servicekit/api/dependencies.py +55 -0
  13. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/middleware.py +71 -11
  14. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/monitoring.py +17 -6
  15. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/registration.py +51 -54
  16. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/routers/health.py +10 -3
  17. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/routers/job.py +29 -17
  18. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/service_builder.py +218 -111
  19. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/database.py +31 -5
  20. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/exceptions.py +16 -0
  21. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/manager.py +43 -9
  22. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/repository.py +26 -6
  23. servicekit-2.0.1/src/servicekit/scheduler.py +500 -0
  24. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/schemas.py +7 -5
  25. servicekit-1.0.1/src/servicekit/api/dependencies.py +0 -69
  26. servicekit-1.0.1/src/servicekit/scheduler.py +0 -302
  27. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/apps/landing/index.html +0 -0
  28. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/apps/landing/manifest.json +0 -0
  29. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/auth.py +0 -0
  30. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/pagination.py +0 -0
  31. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/router.py +0 -0
  32. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/routers/__init__.py +0 -0
  33. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/routers/metrics.py +0 -0
  34. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/routers/system.py +0 -0
  35. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/sse.py +0 -0
  36. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/api/utilities.py +0 -0
  37. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/gunicorn.conf.py +0 -0
  38. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/logging.py +0 -0
  39. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/models.py +0 -0
  40. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/py.typed +0 -0
  41. {servicekit-1.0.1 → servicekit-2.0.1}/src/servicekit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: servicekit
3
- Version: 1.0.1
3
+ Version: 2.0.1
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,11 @@ router = CrudRouter.create(
153
179
  )
154
180
  ```
155
181
 
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.
186
+
156
187
 
157
188
  ## Examples
158
189
 
@@ -172,7 +203,8 @@ See `docs/` for comprehensive guides and API reference.
172
203
 
173
204
  ```bash
174
205
  make test # Run tests
175
- make lint # Run linter
206
+ make format # Format code and apply lint fixes
207
+ make lint # Check formatting, linting and types
176
208
  make coverage # Test coverage
177
209
  ```
178
210
 
@@ -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,11 @@ router = CrudRouter.create(
117
143
  )
118
144
  ```
119
145
 
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.
150
+
120
151
 
121
152
  ## Examples
122
153
 
@@ -136,7 +167,8 @@ See `docs/` for comprehensive guides and API reference.
136
167
 
137
168
  ```bash
138
169
  make test # Run tests
139
- make lint # Run linter
170
+ make format # Format code and apply lint fixes
171
+ make lint # Check formatting, linting and types
140
172
  make coverage # Test coverage
141
173
  ```
142
174
 
@@ -0,0 +1,180 @@
1
+ [project]
2
+ name = "servicekit"
3
+ version = "2.0.1"
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.1"
3
+ version = "2.0.1"
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,
@@ -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
@@ -47,6 +48,7 @@ __all__ = [
47
48
  "Database",
48
49
  "SqliteDatabase",
49
50
  "SqliteDatabaseBuilder",
51
+ "get_alembic_dir",
50
52
  "Repository",
51
53
  "BaseRepository",
52
54
  "Manager",
@@ -75,6 +77,7 @@ __all__ = [
75
77
  "NotFoundError",
76
78
  "ValidationError",
77
79
  "ConflictError",
80
+ "classify_integrity_error",
78
81
  "InvalidULIDError",
79
82
  "BadRequestError",
80
83
  "UnauthorizedError",
@@ -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,22 +1,14 @@
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
18
10
  from .routers import HealthRouter, HealthState, HealthStatus, JobRouter, MetricsRouter, SystemInfo, SystemRouter
19
- from .service_builder import BaseServiceBuilder, ServiceInfo
11
+ from .service_builder import BaseServiceBuilder, JobOptions, ServiceInfo
20
12
  from .sse import SSE_HEADERS, format_sse_event, format_sse_model_event
21
13
  from .utilities import build_location_url, run_app
22
14
 
@@ -27,6 +19,7 @@ __all__ = [
27
19
  "CrudPermissions",
28
20
  # Service builder
29
21
  "BaseServiceBuilder",
22
+ "JobOptions",
30
23
  "ServiceInfo",
31
24
  # App system
32
25
  "App",
@@ -34,6 +27,7 @@ __all__ = [
34
27
  "AppLoader",
35
28
  "AppManifest",
36
29
  "AppManager",
30
+ "EntryStaticFiles",
37
31
  # Authentication
38
32
  "APIKeyMiddleware",
39
33
  "load_api_keys_from_env",
@@ -41,12 +35,9 @@ __all__ = [
41
35
  "validate_api_key_format",
42
36
  # Dependencies
43
37
  "get_app_manager",
44
- "set_app_manager",
45
38
  "get_database",
46
- "set_database",
47
39
  "get_session",
48
40
  "get_scheduler",
49
- "set_scheduler",
50
41
  # Middleware
51
42
  "add_error_handlers",
52
43
  "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