servicekit 0.6.0__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.
servicekit/.DS_Store ADDED
Binary file
servicekit/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """Core framework components - generic interfaces and base classes."""
2
+
3
+ # ruff: noqa: F401
4
+
5
+ # Read version from package metadata - must be before internal imports
6
+ try:
7
+ from importlib.metadata import version as _get_version
8
+
9
+ __version__ = _get_version("servicekit")
10
+ except Exception:
11
+ __version__ = "unknown"
12
+
13
+ # Base infrastructure (framework-agnostic)
14
+ from .database import Database, SqliteDatabase, SqliteDatabaseBuilder
15
+ from .exceptions import (
16
+ BadRequestError,
17
+ ConflictError,
18
+ ErrorType,
19
+ ForbiddenError,
20
+ InvalidULIDError,
21
+ NotFoundError,
22
+ ServicekitException,
23
+ UnauthorizedError,
24
+ ValidationError,
25
+ )
26
+ from .logging import add_request_context, clear_request_context, configure_logging, get_logger, reset_request_context
27
+ from .manager import BaseManager, LifecycleHooks, Manager
28
+ from .models import Base, Entity
29
+ from .repository import BaseRepository, Repository
30
+ from .scheduler import InMemoryScheduler, Scheduler
31
+ from .schemas import (
32
+ BulkOperationError,
33
+ BulkOperationResult,
34
+ EntityIn,
35
+ EntityOut,
36
+ JobRecord,
37
+ JobStatus,
38
+ PaginatedResponse,
39
+ ProblemDetail,
40
+ )
41
+ from .types import JsonSafe, ULIDType
42
+
43
+ __all__ = [
44
+ # Version
45
+ "__version__",
46
+ # Base infrastructure
47
+ "Database",
48
+ "SqliteDatabase",
49
+ "SqliteDatabaseBuilder",
50
+ "Repository",
51
+ "BaseRepository",
52
+ "Manager",
53
+ "LifecycleHooks",
54
+ "BaseManager",
55
+ # ORM and types
56
+ "Base",
57
+ "Entity",
58
+ "ULIDType",
59
+ "JsonSafe",
60
+ # Schemas
61
+ "EntityIn",
62
+ "EntityOut",
63
+ "PaginatedResponse",
64
+ "BulkOperationResult",
65
+ "BulkOperationError",
66
+ "ProblemDetail",
67
+ "JobRecord",
68
+ "JobStatus",
69
+ # Job scheduling
70
+ "Scheduler",
71
+ "InMemoryScheduler",
72
+ # Exceptions
73
+ "ErrorType",
74
+ "ServicekitException",
75
+ "NotFoundError",
76
+ "ValidationError",
77
+ "ConflictError",
78
+ "InvalidULIDError",
79
+ "BadRequestError",
80
+ "UnauthorizedError",
81
+ "ForbiddenError",
82
+ # Logging
83
+ "configure_logging",
84
+ "get_logger",
85
+ "add_request_context",
86
+ "clear_request_context",
87
+ "reset_request_context",
88
+ ]
Binary file
@@ -0,0 +1,72 @@
1
+ """FastAPI framework layer - routers, middleware, utilities."""
2
+
3
+ from .app import App, AppInfo, AppLoader, AppManager, AppManifest
4
+ from .auth import APIKeyMiddleware, load_api_keys_from_env, load_api_keys_from_file, validate_api_key_format
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
+ )
15
+ from .middleware import add_error_handlers, add_logging_middleware, database_error_handler, validation_error_handler
16
+ from .pagination import PaginationParams, create_paginated_response
17
+ from .router import Router
18
+ from .routers import HealthRouter, HealthState, HealthStatus, JobRouter, SystemInfo, SystemRouter
19
+ from .service_builder import BaseServiceBuilder, ServiceInfo
20
+ from .sse import SSE_HEADERS, format_sse_event, format_sse_model_event
21
+ from .utilities import build_location_url, run_app
22
+
23
+ __all__ = [
24
+ # Base router classes
25
+ "Router",
26
+ "CrudRouter",
27
+ "CrudPermissions",
28
+ # Service builder
29
+ "BaseServiceBuilder",
30
+ "ServiceInfo",
31
+ # App system
32
+ "App",
33
+ "AppInfo",
34
+ "AppLoader",
35
+ "AppManifest",
36
+ "AppManager",
37
+ # Authentication
38
+ "APIKeyMiddleware",
39
+ "load_api_keys_from_env",
40
+ "load_api_keys_from_file",
41
+ "validate_api_key_format",
42
+ # Dependencies
43
+ "get_app_manager",
44
+ "set_app_manager",
45
+ "get_database",
46
+ "set_database",
47
+ "get_session",
48
+ "get_scheduler",
49
+ "set_scheduler",
50
+ # Middleware
51
+ "add_error_handlers",
52
+ "add_logging_middleware",
53
+ "database_error_handler",
54
+ "validation_error_handler",
55
+ # Pagination
56
+ "PaginationParams",
57
+ "create_paginated_response",
58
+ # System routers
59
+ "HealthRouter",
60
+ "HealthState",
61
+ "HealthStatus",
62
+ "JobRouter",
63
+ "SystemRouter",
64
+ "SystemInfo",
65
+ # SSE utilities
66
+ "SSE_HEADERS",
67
+ "format_sse_event",
68
+ "format_sse_model_event",
69
+ # Utilities
70
+ "build_location_url",
71
+ "run_app",
72
+ ]
servicekit/api/app.py ADDED
@@ -0,0 +1,225 @@
1
+ """App system for hosting static web applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import json
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
11
+
12
+ from servicekit.logging import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class AppManifest(BaseModel):
18
+ """App manifest configuration."""
19
+
20
+ model_config = ConfigDict(extra="forbid")
21
+
22
+ name: str = Field(description="Human-readable app name")
23
+ version: str = Field(description="Semantic version")
24
+ prefix: str = Field(description="URL prefix for mounting")
25
+ description: str | None = Field(default=None, description="App description")
26
+ author: str | None = Field(default=None, description="Author name")
27
+ entry: str = Field(default="index.html", description="Entry point filename")
28
+
29
+ @field_validator("prefix")
30
+ @classmethod
31
+ def validate_prefix(cls, v: str) -> str:
32
+ """Validate mount prefix format."""
33
+ if not v.startswith("/"):
34
+ raise ValueError("prefix must start with '/'")
35
+ if ".." in v:
36
+ raise ValueError("prefix cannot contain '..'")
37
+ if v.startswith("/api/") or v == "/api":
38
+ raise ValueError("prefix cannot be '/api' or start with '/api/'")
39
+ return v
40
+
41
+ @field_validator("entry")
42
+ @classmethod
43
+ def validate_entry(cls, v: str) -> str:
44
+ """Validate entry file path for security."""
45
+ if ".." in v:
46
+ raise ValueError("entry cannot contain '..'")
47
+ if v.startswith("/"):
48
+ raise ValueError("entry must be a relative path")
49
+ # Normalize and check for path traversal
50
+ normalized = Path(v).as_posix()
51
+ if normalized.startswith("../") or "/../" in normalized:
52
+ raise ValueError("entry cannot contain path traversal")
53
+ return v
54
+
55
+
56
+ @dataclass
57
+ class App:
58
+ """Represents a loaded app with manifest and directory."""
59
+
60
+ manifest: AppManifest
61
+ directory: Path
62
+ prefix: str # May differ from manifest if overridden
63
+ is_package: bool # True if loaded from package resources
64
+
65
+
66
+ class AppLoader:
67
+ """Loads and validates apps from filesystem or package resources."""
68
+
69
+ @staticmethod
70
+ def load(path: str | Path | tuple[str, str], prefix: str | None = None) -> App:
71
+ """Load and validate app from filesystem path or package resource tuple."""
72
+ # Detect source type and resolve to directory
73
+ if isinstance(path, tuple):
74
+ # Package resource
75
+ dir_path, is_package = AppLoader._resolve_package_path(path)
76
+ else:
77
+ # Filesystem path
78
+ dir_path = Path(path).resolve()
79
+ is_package = False
80
+
81
+ if not dir_path.exists():
82
+ raise FileNotFoundError(f"App directory not found: {dir_path}")
83
+ if not dir_path.is_dir():
84
+ raise NotADirectoryError(f"App path is not a directory: {dir_path}")
85
+
86
+ # Load and validate manifest
87
+ manifest_path = dir_path / "manifest.json"
88
+ if not manifest_path.exists():
89
+ raise FileNotFoundError(f"manifest.json not found in: {dir_path}")
90
+
91
+ try:
92
+ with manifest_path.open() as f:
93
+ manifest_data = json.load(f)
94
+ except json.JSONDecodeError as e:
95
+ raise ValueError(f"Invalid JSON in manifest.json: {e}") from e
96
+
97
+ manifest = AppManifest(**manifest_data)
98
+
99
+ # Validate entry file exists
100
+ entry_path = dir_path / manifest.entry
101
+ if not entry_path.exists():
102
+ raise FileNotFoundError(f"Entry file '{manifest.entry}' not found in: {dir_path}")
103
+
104
+ # Use override or manifest prefix
105
+ final_prefix = prefix if prefix is not None else manifest.prefix
106
+
107
+ # Re-validate prefix if overridden
108
+ if prefix is not None:
109
+ validated = AppManifest(
110
+ name=manifest.name,
111
+ version=manifest.version,
112
+ prefix=final_prefix,
113
+ )
114
+ final_prefix = validated.prefix
115
+
116
+ return App(
117
+ manifest=manifest,
118
+ directory=dir_path,
119
+ prefix=final_prefix,
120
+ is_package=is_package,
121
+ )
122
+
123
+ @staticmethod
124
+ def discover(path: str | Path | tuple[str, str]) -> list[App]:
125
+ """Discover all apps with manifest.json in directory."""
126
+ # Resolve directory
127
+ if isinstance(path, tuple):
128
+ dir_path, _ = AppLoader._resolve_package_path(path)
129
+ else:
130
+ dir_path = Path(path).resolve()
131
+
132
+ if not dir_path.exists():
133
+ raise FileNotFoundError(f"Apps directory not found: {dir_path}")
134
+ if not dir_path.is_dir():
135
+ raise NotADirectoryError(f"Apps path is not a directory: {dir_path}")
136
+
137
+ # Scan for subdirectories with manifest.json
138
+ apps: list[App] = []
139
+ for subdir in dir_path.iterdir():
140
+ if subdir.is_dir() and (subdir / "manifest.json").exists():
141
+ try:
142
+ # Determine if we're in a package context
143
+ if isinstance(path, tuple):
144
+ # Build tuple path for subdirectory
145
+ package_name: str = path[0]
146
+ base_path: str = path[1]
147
+ subdir_name = subdir.name
148
+ subpath = f"{base_path}/{subdir_name}" if base_path else subdir_name
149
+ app = AppLoader.load((package_name, subpath))
150
+ else:
151
+ app = AppLoader.load(subdir)
152
+ apps.append(app)
153
+ except Exception as e:
154
+ # Log but don't fail discovery for invalid apps
155
+ logger.warning(
156
+ "app.discovery.failed",
157
+ directory=str(subdir),
158
+ error=str(e),
159
+ )
160
+
161
+ return apps
162
+
163
+ @staticmethod
164
+ def _resolve_package_path(package_tuple: tuple[str, str]) -> tuple[Path, bool]:
165
+ """Resolve package resource tuple to filesystem path."""
166
+ package_name, subpath = package_tuple
167
+
168
+ # Validate subpath for security
169
+ if ".." in subpath:
170
+ raise ValueError(f"subpath cannot contain '..' (got: {subpath})")
171
+ if subpath.startswith("/"):
172
+ raise ValueError(f"subpath must be relative (got: {subpath})")
173
+
174
+ try:
175
+ spec = importlib.util.find_spec(package_name)
176
+ except (ModuleNotFoundError, ValueError) as e:
177
+ raise ValueError(f"Package '{package_name}' could not be found") from e
178
+
179
+ if spec is None or spec.origin is None:
180
+ raise ValueError(f"Package '{package_name}' could not be found")
181
+
182
+ # Resolve to package directory
183
+ package_dir = Path(spec.origin).parent
184
+ app_dir = package_dir / subpath
185
+
186
+ # Verify resolved path is still within package directory
187
+ try:
188
+ app_dir.resolve().relative_to(package_dir.resolve())
189
+ except ValueError as e:
190
+ raise ValueError(f"App path '{subpath}' escapes package directory") from e
191
+
192
+ if not app_dir.exists():
193
+ raise FileNotFoundError(f"App path '{subpath}' not found in package '{package_name}'")
194
+ if not app_dir.is_dir():
195
+ raise NotADirectoryError(f"App path '{subpath}' in package '{package_name}' is not a directory")
196
+
197
+ return app_dir, True
198
+
199
+
200
+ class AppInfo(BaseModel):
201
+ """App metadata for API responses."""
202
+
203
+ name: str = Field(description="Human-readable app name")
204
+ version: str = Field(description="Semantic version")
205
+ prefix: str = Field(description="URL prefix for mounting")
206
+ description: str | None = Field(default=None, description="App description")
207
+ author: str | None = Field(default=None, description="Author name")
208
+ entry: str = Field(description="Entry point filename")
209
+ is_package: bool = Field(description="Whether app is loaded from package resources")
210
+
211
+
212
+ class AppManager:
213
+ """Lightweight manager for app metadata queries."""
214
+
215
+ def __init__(self, apps: list[App]):
216
+ """Initialize with loaded apps."""
217
+ self._apps = apps
218
+
219
+ def list(self) -> list[App]:
220
+ """Return all installed apps."""
221
+ return self._apps
222
+
223
+ def get(self, prefix: str) -> App | None:
224
+ """Get app by mount prefix."""
225
+ return next((app for app in self._apps if app.prefix == prefix), None)
Binary file
@@ -0,0 +1,146 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Service Information</title>
7
+ <style>
8
+ body {
9
+ font-family: system-ui, -apple-system, sans-serif;
10
+ max-width: 800px;
11
+ margin: 2rem auto;
12
+ padding: 0 1rem;
13
+ background: #f5f5f5;
14
+ }
15
+ .container {
16
+ background: white;
17
+ border-radius: 8px;
18
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
19
+ padding: 2rem;
20
+ }
21
+ h1 {
22
+ margin: 0 0 0.5rem 0;
23
+ color: #333;
24
+ }
25
+ .nav-links {
26
+ display: flex;
27
+ gap: 1rem;
28
+ margin-bottom: 1.5rem;
29
+ padding-bottom: 1rem;
30
+ border-bottom: 1px solid #e5e5e5;
31
+ }
32
+ .nav-links a {
33
+ color: #2563eb;
34
+ text-decoration: none;
35
+ font-size: 0.875rem;
36
+ }
37
+ .nav-links a:hover {
38
+ text-decoration: underline;
39
+ }
40
+ .info-grid {
41
+ display: grid;
42
+ gap: 1rem;
43
+ margin-top: 1.5rem;
44
+ }
45
+ .info-item {
46
+ display: grid;
47
+ gap: 0.25rem;
48
+ }
49
+ .info-label {
50
+ font-size: 0.75rem;
51
+ text-transform: uppercase;
52
+ color: #666;
53
+ font-weight: 600;
54
+ letter-spacing: 0.05em;
55
+ }
56
+ .info-value {
57
+ color: #333;
58
+ font-size: 1rem;
59
+ }
60
+ pre {
61
+ background: #f5f5f5;
62
+ padding: 1rem;
63
+ border-radius: 4px;
64
+ overflow-x: auto;
65
+ margin: 0;
66
+ font-size: 0.875rem;
67
+ }
68
+ .loading {
69
+ text-align: center;
70
+ color: #666;
71
+ padding: 2rem;
72
+ }
73
+ .error {
74
+ color: #dc2626;
75
+ padding: 1rem;
76
+ background: #fee;
77
+ border-radius: 4px;
78
+ }
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <div class="container">
83
+ <div id="content" class="loading">Loading service information...</div>
84
+ </div>
85
+ <script>
86
+ fetch('/api/v1/info')
87
+ .then(res => res.json())
88
+ .then(info => {
89
+ const items = [];
90
+
91
+ items.push(`<h1>${info.display_name}</h1>`);
92
+ items.push(`<div class="nav-links">
93
+ <a href="/docs">📚 API Documentation</a>
94
+ <a href="/redoc">📖 ReDoc</a>
95
+ </div>`);
96
+ items.push(`<div class="info-grid">`);
97
+
98
+ items.push(`<div class="info-item">
99
+ <div class="info-label">Version</div>
100
+ <div class="info-value">${info.version}</div>
101
+ </div>`);
102
+
103
+ if (info.summary) {
104
+ items.push(`<div class="info-item">
105
+ <div class="info-label">Summary</div>
106
+ <div class="info-value">${info.summary}</div>
107
+ </div>`);
108
+ }
109
+
110
+ if (info.description) {
111
+ items.push(`<div class="info-item">
112
+ <div class="info-label">Description</div>
113
+ <div class="info-value">${info.description}</div>
114
+ </div>`);
115
+ }
116
+
117
+ if (info.contact) {
118
+ const contactJson = JSON.stringify(info.contact, null, 2);
119
+ items.push(`<div class="info-item">
120
+ <div class="info-label">Contact</div>
121
+ <div class="info-value"><pre>${contactJson}</pre></div>
122
+ </div>`);
123
+ }
124
+
125
+ if (info.license_info) {
126
+ const licenseJson = JSON.stringify(info.license_info, null, 2);
127
+ items.push(`<div class="info-item">
128
+ <div class="info-label">License</div>
129
+ <div class="info-value"><pre>${licenseJson}</pre></div>
130
+ </div>`);
131
+ }
132
+
133
+ items.push(`</div>`);
134
+ const contentEl = document.getElementById('content');
135
+ contentEl.className = '';
136
+ contentEl.innerHTML = items.join('');
137
+ })
138
+ .catch(err => {
139
+ const errorMsg = 'Failed to load service information: ' + err.message;
140
+ const contentEl = document.getElementById('content');
141
+ contentEl.className = '';
142
+ contentEl.innerHTML = `<div class="error">${errorMsg}</div>`;
143
+ });
144
+ </script>
145
+ </body>
146
+ </html>
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "Chapkit Landing Page",
3
+ "version": "1.0.0",
4
+ "prefix": "/",
5
+ "description": "Default landing page showing service information",
6
+ "author": "Chapkit Team",
7
+ "entry": "index.html"
8
+ }