chapkit 0.4.5__py3-none-any.whl

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 (51) hide show
  1. chapkit/__init__.py +97 -0
  2. chapkit/alembic_helpers.py +138 -0
  3. chapkit/api/__init__.py +64 -0
  4. chapkit/api/dependencies.py +32 -0
  5. chapkit/api/service_builder.py +348 -0
  6. chapkit/artifact/__init__.py +18 -0
  7. chapkit/artifact/manager.py +132 -0
  8. chapkit/artifact/models.py +36 -0
  9. chapkit/artifact/repository.py +48 -0
  10. chapkit/artifact/router.py +126 -0
  11. chapkit/artifact/schemas.py +67 -0
  12. chapkit/cli/__init__.py +5 -0
  13. chapkit/cli/__main__.py +6 -0
  14. chapkit/cli/cli.py +31 -0
  15. chapkit/cli/init.py +151 -0
  16. chapkit/cli/templates/.gitignore +152 -0
  17. chapkit/cli/templates/Dockerfile.jinja2 +80 -0
  18. chapkit/cli/templates/README.md.jinja2 +132 -0
  19. chapkit/cli/templates/compose.monitoring.yml.jinja2 +88 -0
  20. chapkit/cli/templates/compose.yml.jinja2 +30 -0
  21. chapkit/cli/templates/main.py.jinja2 +135 -0
  22. chapkit/cli/templates/monitoring/grafana/dashboards/chapkit-service-metrics.json +1232 -0
  23. chapkit/cli/templates/monitoring/grafana/provisioning/dashboards/dashboard.yml +13 -0
  24. chapkit/cli/templates/monitoring/grafana/provisioning/datasources/prometheus.yml +25 -0
  25. chapkit/cli/templates/monitoring/prometheus/prometheus.yml.jinja2 +13 -0
  26. chapkit/cli/templates/pyproject.toml.jinja2 +17 -0
  27. chapkit/config/__init__.py +20 -0
  28. chapkit/config/manager.py +63 -0
  29. chapkit/config/models.py +60 -0
  30. chapkit/config/repository.py +76 -0
  31. chapkit/config/router.py +112 -0
  32. chapkit/config/schemas.py +63 -0
  33. chapkit/ml/__init__.py +29 -0
  34. chapkit/ml/manager.py +231 -0
  35. chapkit/ml/router.py +114 -0
  36. chapkit/ml/runner.py +260 -0
  37. chapkit/ml/schemas.py +98 -0
  38. chapkit/py.typed +0 -0
  39. chapkit/scheduler.py +154 -0
  40. chapkit/task/__init__.py +20 -0
  41. chapkit/task/manager.py +300 -0
  42. chapkit/task/models.py +20 -0
  43. chapkit/task/registry.py +46 -0
  44. chapkit/task/repository.py +31 -0
  45. chapkit/task/router.py +115 -0
  46. chapkit/task/schemas.py +28 -0
  47. chapkit/task/validation.py +76 -0
  48. chapkit-0.4.5.dist-info/METADATA +196 -0
  49. chapkit-0.4.5.dist-info/RECORD +51 -0
  50. chapkit-0.4.5.dist-info/WHEEL +4 -0
  51. chapkit-0.4.5.dist-info/entry_points.txt +3 -0
chapkit/__init__.py ADDED
@@ -0,0 +1,97 @@
1
+ """Chapkit - ML/data service modules built on servicekit."""
2
+
3
+ # CLI feature
4
+ # Scheduler feature
5
+ # Artifact feature
6
+ from .artifact import (
7
+ Artifact,
8
+ ArtifactHierarchy,
9
+ ArtifactIn,
10
+ ArtifactManager,
11
+ ArtifactOut,
12
+ ArtifactRepository,
13
+ ArtifactRouter,
14
+ ArtifactTreeNode,
15
+ )
16
+ from .cli import app as cli_app
17
+
18
+ # Config feature
19
+ from .config import (
20
+ BaseConfig,
21
+ Config,
22
+ ConfigIn,
23
+ ConfigManager,
24
+ ConfigOut,
25
+ ConfigRepository,
26
+ )
27
+
28
+ # ML feature
29
+ from .ml import (
30
+ FunctionalModelRunner,
31
+ MLManager,
32
+ MLRouter,
33
+ ModelRunnerProtocol,
34
+ PredictionArtifactData,
35
+ PredictRequest,
36
+ PredictResponse,
37
+ TrainedModelArtifactData,
38
+ TrainRequest,
39
+ TrainResponse,
40
+ )
41
+ from .scheduler import ChapkitJobRecord, ChapkitJobScheduler
42
+
43
+ # Task feature
44
+ from .task import (
45
+ Task,
46
+ TaskIn,
47
+ TaskManager,
48
+ TaskOut,
49
+ TaskRegistry,
50
+ TaskRepository,
51
+ TaskRouter,
52
+ validate_and_disable_orphaned_tasks,
53
+ )
54
+
55
+ __all__ = [
56
+ # CLI
57
+ "cli_app",
58
+ # Scheduler
59
+ "ChapkitJobRecord",
60
+ "ChapkitJobScheduler",
61
+ # Artifact
62
+ "Artifact",
63
+ "ArtifactHierarchy",
64
+ "ArtifactIn",
65
+ "ArtifactManager",
66
+ "ArtifactOut",
67
+ "ArtifactRepository",
68
+ "ArtifactRouter",
69
+ "ArtifactTreeNode",
70
+ # Config
71
+ "BaseConfig",
72
+ "Config",
73
+ "ConfigIn",
74
+ "ConfigManager",
75
+ "ConfigOut",
76
+ "ConfigRepository",
77
+ # ML
78
+ "FunctionalModelRunner",
79
+ "MLManager",
80
+ "MLRouter",
81
+ "ModelRunnerProtocol",
82
+ "PredictionArtifactData",
83
+ "PredictRequest",
84
+ "PredictResponse",
85
+ "TrainedModelArtifactData",
86
+ "TrainRequest",
87
+ "TrainResponse",
88
+ # Task
89
+ "Task",
90
+ "TaskIn",
91
+ "TaskManager",
92
+ "TaskOut",
93
+ "TaskRegistry",
94
+ "TaskRepository",
95
+ "TaskRouter",
96
+ "validate_and_disable_orphaned_tasks",
97
+ ]
@@ -0,0 +1,138 @@
1
+ """Reusable Alembic migration helpers for chapkit tables.
2
+
3
+ This module provides helper functions for creating and dropping chapkit's database tables
4
+ in Alembic migrations. Using helpers instead of raw Alembic operations provides:
5
+
6
+ - Reusability across migrations
7
+ - Consistent table definitions
8
+ - Clear documentation
9
+ - Easier maintenance
10
+
11
+ Users can create their own helper modules following this pattern for custom tables.
12
+
13
+ Example:
14
+ # In your migration file
15
+ from chapkit.alembic_helpers import create_configs_table, drop_configs_table
16
+
17
+ def upgrade() -> None:
18
+ create_configs_table(op)
19
+
20
+ def downgrade() -> None:
21
+ drop_configs_table(op)
22
+
23
+ Creating Your Own Helpers:
24
+ Follow the same pattern for your custom tables:
25
+
26
+ # myapp/alembic_helpers.py
27
+ def create_users_table(op: Any) -> None:
28
+ '''Create users table.'''
29
+ op.create_table(
30
+ 'users',
31
+ sa.Column('email', sa.String(), nullable=False),
32
+ sa.Column('name', sa.String(), nullable=False),
33
+ sa.Column('id', servicekit.types.ULIDType(length=26), nullable=False),
34
+ sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
35
+ sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
36
+ sa.Column('tags', sa.JSON(), nullable=False, server_default='[]'),
37
+ sa.PrimaryKeyConstraint('id'),
38
+ )
39
+ op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=False)
40
+
41
+ def drop_users_table(op: Any) -> None:
42
+ '''Drop users table.'''
43
+ op.drop_index(op.f('ix_users_email'), table_name='users')
44
+ op.drop_table('users')
45
+
46
+ See examples/custom_migrations/ for a complete working example.
47
+ """
48
+
49
+ from typing import Any
50
+
51
+ import servicekit.types
52
+ import sqlalchemy as sa
53
+
54
+
55
+ def create_artifacts_table(op: Any) -> None:
56
+ """Create artifacts table for hierarchical artifact storage."""
57
+ op.create_table(
58
+ "artifacts",
59
+ sa.Column("parent_id", servicekit.types.ULIDType(length=26), nullable=True),
60
+ sa.Column("data", sa.PickleType(), nullable=False),
61
+ sa.Column("level", sa.Integer(), nullable=False),
62
+ sa.Column("id", servicekit.types.ULIDType(length=26), nullable=False),
63
+ sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
64
+ sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
65
+ sa.Column("tags", sa.JSON(), nullable=False, server_default="[]"),
66
+ sa.ForeignKeyConstraint(["parent_id"], ["artifacts.id"], ondelete="SET NULL"),
67
+ sa.PrimaryKeyConstraint("id"),
68
+ )
69
+ op.create_index(op.f("ix_artifacts_level"), "artifacts", ["level"], unique=False)
70
+ op.create_index(op.f("ix_artifacts_parent_id"), "artifacts", ["parent_id"], unique=False)
71
+
72
+
73
+ def drop_artifacts_table(op: Any) -> None:
74
+ """Drop artifacts table."""
75
+ op.drop_index(op.f("ix_artifacts_parent_id"), table_name="artifacts")
76
+ op.drop_index(op.f("ix_artifacts_level"), table_name="artifacts")
77
+ op.drop_table("artifacts")
78
+
79
+
80
+ def create_configs_table(op: Any) -> None:
81
+ """Create configs table for configuration storage."""
82
+ op.create_table(
83
+ "configs",
84
+ sa.Column("name", sa.String(), nullable=False),
85
+ sa.Column("data", sa.JSON(), nullable=False),
86
+ sa.Column("id", servicekit.types.ULIDType(length=26), nullable=False),
87
+ sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
88
+ sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
89
+ sa.Column("tags", sa.JSON(), nullable=False, server_default="[]"),
90
+ sa.PrimaryKeyConstraint("id"),
91
+ )
92
+ op.create_index(op.f("ix_configs_name"), "configs", ["name"], unique=False)
93
+
94
+
95
+ def drop_configs_table(op: Any) -> None:
96
+ """Drop configs table."""
97
+ op.drop_index(op.f("ix_configs_name"), table_name="configs")
98
+ op.drop_table("configs")
99
+
100
+
101
+ def create_config_artifacts_table(op: Any) -> None:
102
+ """Create config_artifacts junction table linking configs to artifacts."""
103
+ op.create_table(
104
+ "config_artifacts",
105
+ sa.Column("config_id", servicekit.types.ULIDType(length=26), nullable=False),
106
+ sa.Column("artifact_id", servicekit.types.ULIDType(length=26), nullable=False),
107
+ sa.ForeignKeyConstraint(["artifact_id"], ["artifacts.id"], ondelete="CASCADE"),
108
+ sa.ForeignKeyConstraint(["config_id"], ["configs.id"], ondelete="CASCADE"),
109
+ sa.PrimaryKeyConstraint("config_id", "artifact_id"),
110
+ sa.UniqueConstraint("artifact_id"),
111
+ sa.UniqueConstraint("artifact_id", name="uq_artifact_id"),
112
+ )
113
+
114
+
115
+ def drop_config_artifacts_table(op: Any) -> None:
116
+ """Drop config_artifacts junction table."""
117
+ op.drop_table("config_artifacts")
118
+
119
+
120
+ def create_tasks_table(op: Any) -> None:
121
+ """Create tasks table for task execution infrastructure."""
122
+ op.create_table(
123
+ "tasks",
124
+ sa.Column("command", sa.Text(), nullable=False),
125
+ sa.Column("task_type", sa.Text(), nullable=False, server_default="shell"),
126
+ sa.Column("parameters", sa.JSON(), nullable=True),
127
+ sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
128
+ sa.Column("id", servicekit.types.ULIDType(length=26), nullable=False),
129
+ sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
130
+ sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
131
+ sa.Column("tags", sa.JSON(), nullable=False, server_default="[]"),
132
+ sa.PrimaryKeyConstraint("id"),
133
+ )
134
+
135
+
136
+ def drop_tasks_table(op: Any) -> None:
137
+ """Drop tasks table."""
138
+ op.drop_table("tasks")
@@ -0,0 +1,64 @@
1
+ """FastAPI routers and related presentation logic."""
2
+
3
+ from servicekit.api import CrudPermissions, CrudRouter, Router
4
+ from servicekit.api.middleware import (
5
+ add_error_handlers,
6
+ add_logging_middleware,
7
+ database_error_handler,
8
+ validation_error_handler,
9
+ )
10
+ from servicekit.api.routers import HealthRouter, HealthState, HealthStatus, JobRouter, SystemInfo, SystemRouter
11
+ from servicekit.api.service_builder import ServiceInfo
12
+ from servicekit.api.utilities import build_location_url, run_app
13
+ from servicekit.logging import (
14
+ add_request_context,
15
+ clear_request_context,
16
+ configure_logging,
17
+ get_logger,
18
+ reset_request_context,
19
+ )
20
+
21
+ from chapkit.artifact import ArtifactRouter
22
+ from chapkit.config import ConfigRouter
23
+
24
+ from .dependencies import get_artifact_manager, get_config_manager
25
+ from .service_builder import AssessedStatus, MLServiceBuilder, MLServiceInfo, ServiceBuilder
26
+
27
+ __all__ = [
28
+ # Base classes
29
+ "Router",
30
+ "CrudRouter",
31
+ "CrudPermissions",
32
+ # Routers
33
+ "HealthRouter",
34
+ "HealthStatus",
35
+ "HealthState",
36
+ "JobRouter",
37
+ "SystemRouter",
38
+ "SystemInfo",
39
+ "ConfigRouter",
40
+ "ArtifactRouter",
41
+ # Dependencies
42
+ "get_config_manager",
43
+ "get_artifact_manager",
44
+ # Middleware
45
+ "add_error_handlers",
46
+ "add_logging_middleware",
47
+ "database_error_handler",
48
+ "validation_error_handler",
49
+ # Logging
50
+ "configure_logging",
51
+ "get_logger",
52
+ "add_request_context",
53
+ "clear_request_context",
54
+ "reset_request_context",
55
+ # Builders
56
+ "ServiceBuilder",
57
+ "MLServiceBuilder",
58
+ "ServiceInfo",
59
+ "MLServiceInfo",
60
+ "AssessedStatus",
61
+ # Utilities
62
+ "build_location_url",
63
+ "run_app",
64
+ ]
@@ -0,0 +1,32 @@
1
+ """Feature-specific FastAPI dependency injection for managers."""
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import Depends
6
+ from servicekit.api.dependencies import get_session
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from chapkit.artifact import ArtifactManager, ArtifactRepository
10
+ from chapkit.config import BaseConfig, ConfigManager, ConfigRepository
11
+ from chapkit.ml import MLManager
12
+
13
+
14
+ async def get_config_manager(session: Annotated[AsyncSession, Depends(get_session)]) -> ConfigManager[BaseConfig]:
15
+ """Get a config manager instance for dependency injection."""
16
+ repo = ConfigRepository(session)
17
+ return ConfigManager[BaseConfig](repo, BaseConfig)
18
+
19
+
20
+ async def get_artifact_manager(session: Annotated[AsyncSession, Depends(get_session)]) -> ArtifactManager:
21
+ """Get an artifact manager instance for dependency injection."""
22
+ artifact_repo = ArtifactRepository(session)
23
+ return ArtifactManager(artifact_repo)
24
+
25
+
26
+ async def get_ml_manager() -> MLManager:
27
+ """Get an ML manager instance for dependency injection.
28
+
29
+ Note: This is a placeholder. The actual dependency is built by ServiceBuilder
30
+ with the runner in closure, then overridden via app.dependency_overrides.
31
+ """
32
+ raise RuntimeError("ML manager dependency not configured. Use ServiceBuilder.with_ml() to enable ML operations.")
@@ -0,0 +1,348 @@
1
+ """Service builder with module integration (config, artifact, ml)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass, field
8
+ from enum import StrEnum
9
+ from typing import Any, Callable, Coroutine, List, Self
10
+
11
+ from fastapi import Depends, FastAPI
12
+ from pydantic import EmailStr, HttpUrl
13
+ from servicekit.api.crud import CrudPermissions
14
+ from servicekit.api.dependencies import get_database, get_scheduler, get_session, set_scheduler
15
+ from servicekit.api.service_builder import BaseServiceBuilder, LifespanFactory, ServiceInfo
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from chapkit.artifact import (
19
+ ArtifactHierarchy,
20
+ ArtifactIn,
21
+ ArtifactManager,
22
+ ArtifactOut,
23
+ ArtifactRepository,
24
+ ArtifactRouter,
25
+ )
26
+ from chapkit.config import BaseConfig, ConfigIn, ConfigManager, ConfigOut, ConfigRepository, ConfigRouter
27
+ from chapkit.ml import MLManager, MLRouter, ModelRunnerProtocol
28
+ from chapkit.scheduler import ChapkitJobScheduler
29
+
30
+ from .dependencies import get_artifact_manager as default_get_artifact_manager
31
+ from .dependencies import get_config_manager as default_get_config_manager
32
+ from .dependencies import get_ml_manager as default_get_ml_manager
33
+
34
+ # Type alias for dependency factory functions
35
+ type DependencyFactory = Callable[..., Coroutine[Any, Any, Any]]
36
+
37
+
38
+ class AssessedStatus(StrEnum):
39
+ """Status indicating the maturity and validation level of an ML service."""
40
+
41
+ gray = "gray" # Not intended for use, deprecated, or meant for legacy use only
42
+ red = "red" # Highly experimental prototype - not validated, only for early experimentation
43
+ orange = "orange" # Shows promise on limited data, needs manual configuration and careful evaluation
44
+ yellow = "yellow" # Ready for more rigorous testing
45
+ green = "green" # Validated and ready for production use
46
+
47
+
48
+ class MLServiceInfo(ServiceInfo):
49
+ """Extended service metadata for ML services with author, organization, and assessment info."""
50
+
51
+ author: str | None = None
52
+ author_note: str | None = None
53
+ author_assessed_status: AssessedStatus | None = None
54
+ contact_email: EmailStr | None = None
55
+ organization: str | None = None
56
+ organization_logo_url: str | HttpUrl | None = None
57
+ citation_info: str | None = None
58
+ allow_free_additional_continuous_covariates: bool = False
59
+ required_covariates: List[str] = field(default_factory=list)
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class _ConfigOptions:
64
+ """Internal config options for ServiceBuilder."""
65
+
66
+ schema: type[BaseConfig]
67
+ prefix: str = "/api/v1/configs"
68
+ tags: List[str] = field(default_factory=lambda: ["Config"])
69
+ permissions: CrudPermissions = field(default_factory=CrudPermissions)
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class _ArtifactOptions:
74
+ """Internal artifact options for ServiceBuilder."""
75
+
76
+ hierarchy: ArtifactHierarchy
77
+ prefix: str = "/api/v1/artifacts"
78
+ tags: List[str] = field(default_factory=lambda: ["Artifacts"])
79
+ enable_config_linking: bool = False
80
+ permissions: CrudPermissions = field(default_factory=CrudPermissions)
81
+
82
+
83
+ @dataclass(slots=True)
84
+ class _MLOptions:
85
+ """Internal ML options for ServiceBuilder."""
86
+
87
+ runner: ModelRunnerProtocol
88
+ prefix: str = "/api/v1/ml"
89
+ tags: List[str] = field(default_factory=lambda: ["ML"])
90
+
91
+
92
+ class ServiceBuilder(BaseServiceBuilder):
93
+ """Service builder with integrated module support (config, artifact, ml)."""
94
+
95
+ def __init__(self, **kwargs: Any) -> None:
96
+ """Initialize service builder with module-specific state."""
97
+ super().__init__(**kwargs)
98
+ self._config_options: _ConfigOptions | None = None
99
+ self._artifact_options: _ArtifactOptions | None = None
100
+ self._ml_options: _MLOptions | None = None
101
+
102
+ # --------------------------------------------------------------------- Module-specific fluent methods
103
+
104
+ def with_config(
105
+ self,
106
+ schema: type[BaseConfig],
107
+ *,
108
+ prefix: str = "/api/v1/configs",
109
+ tags: List[str] | None = None,
110
+ permissions: CrudPermissions | None = None,
111
+ allow_create: bool | None = None,
112
+ allow_read: bool | None = None,
113
+ allow_update: bool | None = None,
114
+ allow_delete: bool | None = None,
115
+ ) -> Self:
116
+ base = permissions or CrudPermissions()
117
+ perms = CrudPermissions(
118
+ create=allow_create if allow_create is not None else base.create,
119
+ read=allow_read if allow_read is not None else base.read,
120
+ update=allow_update if allow_update is not None else base.update,
121
+ delete=allow_delete if allow_delete is not None else base.delete,
122
+ )
123
+ self._config_options = _ConfigOptions(
124
+ schema=schema,
125
+ prefix=prefix,
126
+ tags=list(tags) if tags else ["Config"],
127
+ permissions=perms,
128
+ )
129
+ return self
130
+
131
+ def with_artifacts(
132
+ self,
133
+ *,
134
+ hierarchy: ArtifactHierarchy,
135
+ prefix: str = "/api/v1/artifacts",
136
+ tags: List[str] | None = None,
137
+ enable_config_linking: bool = False,
138
+ permissions: CrudPermissions | None = None,
139
+ allow_create: bool | None = None,
140
+ allow_read: bool | None = None,
141
+ allow_update: bool | None = None,
142
+ allow_delete: bool | None = None,
143
+ ) -> Self:
144
+ base = permissions or CrudPermissions()
145
+ perms = CrudPermissions(
146
+ create=allow_create if allow_create is not None else base.create,
147
+ read=allow_read if allow_read is not None else base.read,
148
+ update=allow_update if allow_update is not None else base.update,
149
+ delete=allow_delete if allow_delete is not None else base.delete,
150
+ )
151
+ self._artifact_options = _ArtifactOptions(
152
+ hierarchy=hierarchy,
153
+ prefix=prefix,
154
+ tags=list(tags) if tags else ["Artifacts"],
155
+ enable_config_linking=enable_config_linking,
156
+ permissions=perms,
157
+ )
158
+ return self
159
+
160
+ def with_ml(
161
+ self,
162
+ runner: ModelRunnerProtocol,
163
+ *,
164
+ prefix: str = "/api/v1/ml",
165
+ tags: List[str] | None = None,
166
+ ) -> Self:
167
+ """Enable ML train/predict endpoints with model runner."""
168
+ self._ml_options = _MLOptions(
169
+ runner=runner,
170
+ prefix=prefix,
171
+ tags=list(tags) if tags else ["ML"],
172
+ )
173
+ return self
174
+
175
+ # --------------------------------------------------------------------- Extension point implementations
176
+
177
+ def _validate_module_configuration(self) -> None:
178
+ """Validate module-specific configuration."""
179
+ if self._artifact_options and self._artifact_options.enable_config_linking and not self._config_options:
180
+ raise ValueError(
181
+ "Artifact config-linking requires a config schema. "
182
+ "Call `with_config(...)` before enabling config linking in artifacts."
183
+ )
184
+
185
+ if self._ml_options:
186
+ if not self._config_options:
187
+ raise ValueError(
188
+ "ML operations require config for model configuration. "
189
+ "Call `with_config(...)` before `with_ml(...)`."
190
+ )
191
+ if not self._artifact_options:
192
+ raise ValueError(
193
+ "ML operations require artifacts for model storage. "
194
+ "Call `with_artifacts(...)` before `with_ml(...)`."
195
+ )
196
+ if not self._job_options:
197
+ raise ValueError(
198
+ "ML operations require job scheduler for async execution. "
199
+ "Call `with_jobs(...)` before `with_ml(...)`."
200
+ )
201
+
202
+ def _register_module_routers(self, app: FastAPI) -> None:
203
+ """Register module-specific routers (config, artifact, task)."""
204
+ if self._config_options:
205
+ config_options = self._config_options
206
+ config_schema = config_options.schema
207
+ config_dep = self._build_config_dependency(config_schema)
208
+ entity_in_type: type[ConfigIn[BaseConfig]] = ConfigIn[config_schema] # type: ignore[valid-type]
209
+ entity_out_type: type[ConfigOut[BaseConfig]] = ConfigOut[config_schema] # type: ignore[valid-type]
210
+ config_router = ConfigRouter.create(
211
+ prefix=config_options.prefix,
212
+ tags=config_options.tags,
213
+ manager_factory=config_dep,
214
+ entity_in_type=entity_in_type,
215
+ entity_out_type=entity_out_type,
216
+ permissions=config_options.permissions,
217
+ enable_artifact_operations=(
218
+ self._artifact_options is not None and self._artifact_options.enable_config_linking
219
+ ),
220
+ )
221
+ app.include_router(config_router)
222
+ app.dependency_overrides[default_get_config_manager] = config_dep
223
+
224
+ if self._artifact_options:
225
+ artifact_options = self._artifact_options
226
+ artifact_dep = self._build_artifact_dependency(
227
+ hierarchy=artifact_options.hierarchy,
228
+ include_config=artifact_options.enable_config_linking,
229
+ )
230
+ artifact_router = ArtifactRouter.create(
231
+ prefix=artifact_options.prefix,
232
+ tags=artifact_options.tags,
233
+ manager_factory=artifact_dep,
234
+ entity_in_type=ArtifactIn,
235
+ entity_out_type=ArtifactOut,
236
+ permissions=artifact_options.permissions,
237
+ enable_config_access=self._config_options is not None and artifact_options.enable_config_linking,
238
+ )
239
+ app.include_router(artifact_router)
240
+ app.dependency_overrides[default_get_artifact_manager] = artifact_dep
241
+
242
+ if self._ml_options:
243
+ ml_options = self._ml_options
244
+ ml_dep = self._build_ml_dependency()
245
+ ml_router = MLRouter.create(
246
+ prefix=ml_options.prefix,
247
+ tags=ml_options.tags,
248
+ manager_factory=ml_dep,
249
+ )
250
+ app.include_router(ml_router)
251
+ app.dependency_overrides[default_get_ml_manager] = ml_dep
252
+
253
+ # --------------------------------------------------------------------- Module dependency builders
254
+
255
+ @staticmethod
256
+ def _build_config_dependency(
257
+ schema: type[BaseConfig],
258
+ ) -> DependencyFactory:
259
+ async def _dependency(session: AsyncSession = Depends(get_session)) -> ConfigManager[BaseConfig]:
260
+ repo = ConfigRepository(session)
261
+ return ConfigManager[BaseConfig](repo, schema)
262
+
263
+ return _dependency
264
+
265
+ @staticmethod
266
+ def _build_artifact_dependency(
267
+ *,
268
+ hierarchy: ArtifactHierarchy,
269
+ include_config: bool,
270
+ ) -> DependencyFactory:
271
+ async def _dependency(session: AsyncSession = Depends(get_session)) -> ArtifactManager:
272
+ artifact_repo = ArtifactRepository(session)
273
+ return ArtifactManager(artifact_repo, hierarchy=hierarchy)
274
+
275
+ return _dependency
276
+
277
+ def _build_ml_dependency(self) -> DependencyFactory:
278
+ ml_runner = self._ml_options.runner if self._ml_options else None
279
+ config_schema = self._config_options.schema if self._config_options else None
280
+
281
+ async def _dependency() -> MLManager:
282
+ if ml_runner is None:
283
+ raise RuntimeError("ML runner not configured")
284
+ if config_schema is None:
285
+ raise RuntimeError("Config schema not configured")
286
+
287
+ runner: ModelRunnerProtocol = ml_runner
288
+ scheduler_base = get_scheduler()
289
+ # ChapkitJobScheduler extends AIOJobScheduler which extends JobScheduler
290
+ if not isinstance(scheduler_base, ChapkitJobScheduler):
291
+ raise RuntimeError("Scheduler must be ChapkitJobScheduler for ML operations")
292
+ scheduler: ChapkitJobScheduler = scheduler_base
293
+ database = get_database()
294
+ return MLManager(runner, scheduler, database, config_schema)
295
+
296
+ return _dependency
297
+
298
+ def _build_lifespan(self) -> LifespanFactory:
299
+ """Build lifespan context manager with ChapkitJobScheduler instead of AIOJobScheduler."""
300
+ # Get parent lifespan factory
301
+ parent_lifespan = super()._build_lifespan()
302
+
303
+ @asynccontextmanager
304
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
305
+ """Override scheduler creation to use ChapkitJobScheduler."""
306
+ # Call parent lifespan which handles database and most setup
307
+ async with parent_lifespan(app):
308
+ # Replace AIOJobScheduler with ChapkitJobScheduler if jobs are enabled
309
+ if self._job_options is not None:
310
+ scheduler = ChapkitJobScheduler(max_concurrency=self._job_options.max_concurrency)
311
+ set_scheduler(scheduler)
312
+ app.state.scheduler = scheduler
313
+
314
+ yield
315
+
316
+ return lifespan
317
+
318
+
319
+ class MLServiceBuilder(ServiceBuilder):
320
+ """Specialized service builder for ML services with all required components pre-configured."""
321
+
322
+ def __init__(
323
+ self,
324
+ *,
325
+ info: ServiceInfo | MLServiceInfo,
326
+ config_schema: type[BaseConfig],
327
+ hierarchy: ArtifactHierarchy,
328
+ runner: ModelRunnerProtocol,
329
+ database_url: str = "sqlite+aiosqlite:///:memory:",
330
+ include_error_handlers: bool = True,
331
+ include_logging: bool = True,
332
+ ) -> None:
333
+ """Initialize ML service builder with required ML components."""
334
+ super().__init__(
335
+ info=info,
336
+ database_url=database_url,
337
+ include_error_handlers=include_error_handlers,
338
+ include_logging=include_logging,
339
+ )
340
+
341
+ # Automatically configure required ML components
342
+ self.with_health()
343
+ self.with_system()
344
+ self.with_config(config_schema)
345
+ self.with_artifacts(hierarchy=hierarchy, enable_config_linking=True)
346
+ self.with_jobs()
347
+ self.with_landing_page()
348
+ self.with_ml(runner=runner)