cpkit 0.1.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.
- cpkit/README.md +82 -0
- cpkit/__init__.py +42 -0
- cpkit/admin.py +53 -0
- cpkit/app.py +130 -0
- cpkit/audit/README.md +33 -0
- cpkit/audit/__init__.py +44 -0
- cpkit/audit/events_service.py +36 -0
- cpkit/audit/recorder.py +240 -0
- cpkit/audit/repository.py +64 -0
- cpkit/audit/router.py +46 -0
- cpkit/audit/service.py +34 -0
- cpkit/audit/types.py +38 -0
- cpkit/auth/README.md +38 -0
- cpkit/auth/__init__.py +123 -0
- cpkit/auth/api_key_router.py +52 -0
- cpkit/auth/api_key_service.py +143 -0
- cpkit/auth/api_keys.py +143 -0
- cpkit/auth/bundle.py +113 -0
- cpkit/auth/claims.py +37 -0
- cpkit/auth/config.py +162 -0
- cpkit/auth/dependencies.py +155 -0
- cpkit/auth/oidc.py +703 -0
- cpkit/auth/redirects.py +12 -0
- cpkit/auth/repositories.py +181 -0
- cpkit/auth/router.py +214 -0
- cpkit/auth/secrets.py +78 -0
- cpkit/auth/types.py +49 -0
- cpkit/bundle.py +252 -0
- cpkit/cli/README.md +21 -0
- cpkit/cli/__init__.py +14 -0
- cpkit/cli/__main__.py +5 -0
- cpkit/cli/base.py +200 -0
- cpkit/cli/schema.py +30 -0
- cpkit/cli/server.py +22 -0
- cpkit/config/README.md +13 -0
- cpkit/config/__init__.py +9 -0
- cpkit/config/env.py +31 -0
- cpkit/db/README.md +24 -0
- cpkit/db/__init__.py +23 -0
- cpkit/db/postgres.py +252 -0
- cpkit/dependencies.py +73 -0
- cpkit/errors/README.md +22 -0
- cpkit/errors/__init__.py +35 -0
- cpkit/errors/http.py +45 -0
- cpkit/errors/repository.py +32 -0
- cpkit/errors/service.py +74 -0
- cpkit/jobs/README.md +29 -0
- cpkit/jobs/__init__.py +49 -0
- cpkit/jobs/maintenance.py +15 -0
- cpkit/jobs/repository.py +306 -0
- cpkit/jobs/router.py +105 -0
- cpkit/jobs/service.py +138 -0
- cpkit/jobs/types.py +67 -0
- cpkit/jobs/worker.py +200 -0
- cpkit/logging/README.md +19 -0
- cpkit/logging/__init__.py +13 -0
- cpkit/logging/context.py +29 -0
- cpkit/logging/middleware.py +55 -0
- cpkit/logging/setup.py +81 -0
- cpkit/playbooks/README.md +25 -0
- cpkit/playbooks/__init__.py +40 -0
- cpkit/playbooks/ansible.py +359 -0
- cpkit/playbooks/repository.py +95 -0
- cpkit/playbooks/router.py +95 -0
- cpkit/playbooks/service.py +232 -0
- cpkit/playbooks/types.py +43 -0
- cpkit/repository.py +63 -0
- cpkit/resources/__init__.py +17 -0
- cpkit/resources/ddl.sql +150 -0
- cpkit/settings/README.md +22 -0
- cpkit/settings/__init__.py +18 -0
- cpkit/settings/keys.py +30 -0
- cpkit/settings/repository.py +108 -0
- cpkit/settings/router.py +62 -0
- cpkit/settings/service.py +105 -0
- cpkit/settings/types.py +25 -0
- cpkit/time.py +4 -0
- cpkit/webapp/README.md +71 -0
- cpkit/webapp/__init__.py +12 -0
- cpkit/webapp/index.html +970 -0
- cpkit/webapp/script.js +1690 -0
- cpkit/webapp/style.css +1561 -0
- cpkit-0.1.0.dist-info/METADATA +105 -0
- cpkit-0.1.0.dist-info/RECORD +90 -0
- cpkit-0.1.0.dist-info/WHEEL +4 -0
- cpkit-0.1.0.dist-info/licenses/LICENSE +201 -0
- resources/README.md +16 -0
- resources/repository_maintenance_guide.md +90 -0
- resources/webapp_extension_guide.md +706 -0
- resources/webapp_extension_template.js +130 -0
cpkit/bundle.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Standard cpkit capability bundle for FastAPI applications."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Security
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from .admin import create_cpkit_admin_router
|
|
11
|
+
from .audit import (
|
|
12
|
+
AuditEventsService,
|
|
13
|
+
build_audit_log_record,
|
|
14
|
+
configure_audit_logging,
|
|
15
|
+
create_events_router,
|
|
16
|
+
log_event,
|
|
17
|
+
)
|
|
18
|
+
from .auth import ApiKeysService, AuthBundle, create_auth_bundle
|
|
19
|
+
from .db import get_pool
|
|
20
|
+
from .dependencies import configure_cpkit_dependencies
|
|
21
|
+
from .errors import ServiceError, raise_http_from_service_error
|
|
22
|
+
from .jobs import JobsService, QueueMessage, create_jobs_router, create_queue_worker
|
|
23
|
+
from .jobs.worker import QueueHandler
|
|
24
|
+
from .playbooks import PlaybooksService
|
|
25
|
+
from .repository import get_repo
|
|
26
|
+
from .settings import SettingsService
|
|
27
|
+
|
|
28
|
+
AuditHook = Callable[[Any, str, str, dict[str, Any] | None], None]
|
|
29
|
+
AuditRecordFactory = Callable[..., Any]
|
|
30
|
+
PayloadParser = Callable[[Any, dict[str, Any]], Any]
|
|
31
|
+
QueueMessageParser = Callable[[QueueMessage], Any]
|
|
32
|
+
QueueHandlerResolver = Callable[[QueueMessage], QueueHandler | None]
|
|
33
|
+
CommandModelMap = Mapping[Any, type[BaseModel]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class CpkitBundle:
|
|
38
|
+
"""Standard cpkit routers, hooks, and auth dependencies."""
|
|
39
|
+
|
|
40
|
+
auth: AuthBundle
|
|
41
|
+
routers: tuple[APIRouter, ...]
|
|
42
|
+
startup_hooks: tuple[Callable[[], Any], ...]
|
|
43
|
+
background_tasks: tuple[Callable[[], Any], ...]
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def require_authenticated(self):
|
|
47
|
+
return self.auth.require_authenticated
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def require_user(self):
|
|
51
|
+
return self.auth.require_user
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def require_readonly(self):
|
|
55
|
+
return self.auth.require_readonly
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def require_admin(self):
|
|
59
|
+
return self.auth.require_admin
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def get_access_scope(self):
|
|
63
|
+
return self.auth.get_access_scope
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def get_audit_actor(self):
|
|
67
|
+
return self.auth.get_audit_actor
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def create_cpkit_bundle(
|
|
71
|
+
*,
|
|
72
|
+
command_models: CommandModelMap | None = None,
|
|
73
|
+
command_handlers: Mapping[Any, QueueHandler] | None = None,
|
|
74
|
+
parse_job_payload: PayloadParser | None = None,
|
|
75
|
+
reschedule_type_map: Mapping[Any, Any] | None = None,
|
|
76
|
+
resolve_queue_handler: QueueHandlerResolver | None = None,
|
|
77
|
+
parse_queue_message: QueueMessageParser | None = None,
|
|
78
|
+
audit_record_factory: AuditRecordFactory = build_audit_log_record,
|
|
79
|
+
audit_event_hook: AuditHook | None = None,
|
|
80
|
+
) -> CpkitBundle:
|
|
81
|
+
"""Create the standard cpkit capability bundle for an application."""
|
|
82
|
+
configure_audit_logging(audit_record_factory)
|
|
83
|
+
effective_audit_event_hook = audit_event_hook or log_event
|
|
84
|
+
normalized_command_models = _normalize_mapping(command_models)
|
|
85
|
+
effective_parse_job_payload = parse_job_payload or _command_model_parser(
|
|
86
|
+
normalized_command_models
|
|
87
|
+
)
|
|
88
|
+
effective_parse_queue_message = parse_queue_message or _queue_message_parser(
|
|
89
|
+
effective_parse_job_payload
|
|
90
|
+
)
|
|
91
|
+
normalized_command_handlers = _normalize_mapping(command_handlers)
|
|
92
|
+
effective_reschedule_type_map = {
|
|
93
|
+
_type_value(source): _type_value(target)
|
|
94
|
+
for source, target in (reschedule_type_map or {}).items()
|
|
95
|
+
}
|
|
96
|
+
auth = create_auth_bundle(
|
|
97
|
+
get_repo=get_repo,
|
|
98
|
+
audit_record_factory=audit_record_factory,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def validate_auth_config() -> None:
|
|
102
|
+
auth.oidc.validate_config(get_repo())
|
|
103
|
+
|
|
104
|
+
def get_api_keys_service():
|
|
105
|
+
return ApiKeysService(
|
|
106
|
+
get_repo(),
|
|
107
|
+
created_hook=effective_audit_event_hook,
|
|
108
|
+
deleted_hook=effective_audit_event_hook,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def get_events_service():
|
|
112
|
+
return AuditEventsService(get_repo())
|
|
113
|
+
|
|
114
|
+
def get_jobs_service():
|
|
115
|
+
return JobsService(
|
|
116
|
+
get_repo(),
|
|
117
|
+
parse_payload=effective_parse_job_payload,
|
|
118
|
+
reschedule_type_resolver=lambda job_type: effective_reschedule_type_map.get(
|
|
119
|
+
_type_value(job_type),
|
|
120
|
+
job_type,
|
|
121
|
+
),
|
|
122
|
+
rescheduled_hook=effective_audit_event_hook,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def get_playbooks_service():
|
|
126
|
+
return PlaybooksService(
|
|
127
|
+
get_repo(),
|
|
128
|
+
version_created_hook=effective_audit_event_hook,
|
|
129
|
+
version_deleted_hook=effective_audit_event_hook,
|
|
130
|
+
default_set_hook=effective_audit_event_hook,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def get_settings_service():
|
|
134
|
+
return SettingsService(
|
|
135
|
+
get_repo(),
|
|
136
|
+
setting_updated_hook=_setting_updated_hook(effective_audit_event_hook),
|
|
137
|
+
setting_reset_hook=_setting_reset_hook(effective_audit_event_hook),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
admin_router = create_cpkit_admin_router(
|
|
141
|
+
get_api_keys_service=get_api_keys_service,
|
|
142
|
+
get_settings_service=get_settings_service,
|
|
143
|
+
get_playbooks_service=get_playbooks_service,
|
|
144
|
+
get_audit_actor=auth.get_audit_actor,
|
|
145
|
+
handle_service_error=raise_http_from_service_error,
|
|
146
|
+
service_error_type=ServiceError,
|
|
147
|
+
dependencies=(Security(auth.require_admin),),
|
|
148
|
+
)
|
|
149
|
+
events_router = create_events_router(
|
|
150
|
+
get_service=get_events_service,
|
|
151
|
+
get_access_scope=auth.get_access_scope,
|
|
152
|
+
require_readonly=auth.require_readonly,
|
|
153
|
+
handle_service_error=raise_http_from_service_error,
|
|
154
|
+
service_error_type=ServiceError,
|
|
155
|
+
)
|
|
156
|
+
jobs_router = create_jobs_router(
|
|
157
|
+
get_service=get_jobs_service,
|
|
158
|
+
get_access_scope=auth.get_access_scope,
|
|
159
|
+
get_audit_actor=auth.get_audit_actor,
|
|
160
|
+
require_readonly=auth.require_readonly,
|
|
161
|
+
require_user=auth.require_user,
|
|
162
|
+
handle_service_error=raise_http_from_service_error,
|
|
163
|
+
service_error_type=ServiceError,
|
|
164
|
+
)
|
|
165
|
+
queue_worker = create_queue_worker(
|
|
166
|
+
get_pool=get_pool,
|
|
167
|
+
get_repo=get_repo,
|
|
168
|
+
handlers=normalized_command_handlers,
|
|
169
|
+
resolve_handler=resolve_queue_handler,
|
|
170
|
+
parse_message=effective_parse_queue_message,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
bundle = CpkitBundle(
|
|
174
|
+
auth=auth,
|
|
175
|
+
routers=(
|
|
176
|
+
auth.router,
|
|
177
|
+
admin_router,
|
|
178
|
+
events_router,
|
|
179
|
+
jobs_router,
|
|
180
|
+
),
|
|
181
|
+
startup_hooks=(validate_auth_config,),
|
|
182
|
+
background_tasks=(queue_worker,),
|
|
183
|
+
)
|
|
184
|
+
configure_cpkit_dependencies(bundle)
|
|
185
|
+
return bundle
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _setting_updated_hook(
|
|
189
|
+
audit_event_hook: AuditHook | None,
|
|
190
|
+
) -> Callable[[Any, str, str, str], None] | None:
|
|
191
|
+
if audit_event_hook is None:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def log_setting_updated(
|
|
195
|
+
repo: Any,
|
|
196
|
+
setting_id: str,
|
|
197
|
+
value: str,
|
|
198
|
+
updated_by: str,
|
|
199
|
+
) -> None:
|
|
200
|
+
audit_event_hook(
|
|
201
|
+
repo,
|
|
202
|
+
updated_by,
|
|
203
|
+
"SETTING_UPDATED",
|
|
204
|
+
{"ID": setting_id, "value": value},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return log_setting_updated
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _setting_reset_hook(
|
|
211
|
+
audit_event_hook: AuditHook | None,
|
|
212
|
+
) -> Callable[[Any, str, str], None] | None:
|
|
213
|
+
if audit_event_hook is None:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
def log_setting_reset(repo: Any, setting_id: str, updated_by: str) -> None:
|
|
217
|
+
audit_event_hook(
|
|
218
|
+
repo,
|
|
219
|
+
updated_by,
|
|
220
|
+
"SETTING_RESET",
|
|
221
|
+
{"ID": setting_id},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return log_setting_reset
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _type_value(value: Any) -> Any:
|
|
228
|
+
return getattr(value, "value", value)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _normalize_mapping(mapping: Mapping[Any, Any] | None) -> dict[Any, Any] | None:
|
|
232
|
+
if mapping is None:
|
|
233
|
+
return None
|
|
234
|
+
return {_type_value(key): value for key, value in mapping.items()}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _command_model_parser(command_models: Mapping[Any, type[BaseModel]] | None):
|
|
238
|
+
if command_models is None:
|
|
239
|
+
raise ValueError("command_models or parse_job_payload is required.")
|
|
240
|
+
|
|
241
|
+
def parse(command_type: Any, payload: dict[str, Any] | None) -> BaseModel:
|
|
242
|
+
model_type = command_models[_type_value(command_type)]
|
|
243
|
+
return model_type.model_validate(payload or {})
|
|
244
|
+
|
|
245
|
+
return parse
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _queue_message_parser(parse_payload: PayloadParser) -> QueueMessageParser:
|
|
249
|
+
def parse(message: QueueMessage) -> Any:
|
|
250
|
+
return parse_payload(message.msg_type, message.msg_data)
|
|
251
|
+
|
|
252
|
+
return parse
|
cpkit/cli/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# CLI
|
|
2
|
+
|
|
3
|
+
The CLI package gives cpkit-backed applications a small reusable command line
|
|
4
|
+
interface. It is meant for app projects, not as a standalone cpkit server.
|
|
5
|
+
|
|
6
|
+
## Files
|
|
7
|
+
|
|
8
|
+
- `base.py`: `ApplicationCLI`, the main CLI implementation.
|
|
9
|
+
- `schema.py`: SQL schema initialization and preflight helpers.
|
|
10
|
+
- `server.py`: Uvicorn serving helper.
|
|
11
|
+
- `__main__.py`: Allows running the package as a module when exposed by an app.
|
|
12
|
+
|
|
13
|
+
## Runtime Flow
|
|
14
|
+
|
|
15
|
+
`ApplicationCLI.from_project()` reads `pyproject.toml` and environment variables
|
|
16
|
+
to discover the app import path, DDL files, schema checks, and DB URL variable.
|
|
17
|
+
The app can then expose commands like:
|
|
18
|
+
|
|
19
|
+
- `init`: initialize cpkit and app schemas from DDL files.
|
|
20
|
+
- `check`: verify DB connectivity and required tables.
|
|
21
|
+
- `serve`: run the configured FastAPI app with Uvicorn.
|
cpkit/cli/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Reusable command-line helpers for cpkit applications."""
|
|
2
|
+
|
|
3
|
+
from .base import ApplicationCLI, main
|
|
4
|
+
from .schema import apply_sql_file, check_database, check_table
|
|
5
|
+
from .server import serve_uvicorn
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ApplicationCLI",
|
|
9
|
+
"apply_sql_file",
|
|
10
|
+
"check_database",
|
|
11
|
+
"check_table",
|
|
12
|
+
"main",
|
|
13
|
+
"serve_uvicorn",
|
|
14
|
+
]
|
cpkit/cli/__main__.py
ADDED
cpkit/cli/base.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Base application CLI for cpkit apps."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tomllib
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from cpkit.resources import cpkit_ddl_path
|
|
11
|
+
|
|
12
|
+
from .schema import apply_sql_file, check_database, check_table
|
|
13
|
+
from .server import serve_uvicorn
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApplicationCLI:
|
|
17
|
+
"""Standard command-line interface for a cpkit-backed application."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
app_name: str,
|
|
23
|
+
app_import: str,
|
|
24
|
+
app_ddl_paths: Sequence[str | Path] = (),
|
|
25
|
+
app_schema_checks: Sequence[str] = (),
|
|
26
|
+
db_url_env: str = "CPKIT_DB_URL",
|
|
27
|
+
cpkit_ddl_path: str | Path | None = None,
|
|
28
|
+
project_root: str | Path | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.app_name = app_name
|
|
31
|
+
self.app_import = app_import
|
|
32
|
+
self.app_ddl_paths = tuple(Path(path) for path in app_ddl_paths)
|
|
33
|
+
self.app_schema_checks = tuple(app_schema_checks)
|
|
34
|
+
self.db_url_env = db_url_env
|
|
35
|
+
self.cpkit_ddl_path = Path(cpkit_ddl_path) if cpkit_ddl_path else None
|
|
36
|
+
self.project_root = Path(project_root).resolve() if project_root else None
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_project(
|
|
40
|
+
cls,
|
|
41
|
+
*,
|
|
42
|
+
project_root: str | Path | None = None,
|
|
43
|
+
app_name: str | None = None,
|
|
44
|
+
) -> "ApplicationCLI":
|
|
45
|
+
"""Create an app CLI from pyproject.toml metadata."""
|
|
46
|
+
root = Path(project_root or os.getcwd()).resolve()
|
|
47
|
+
pyproject = _load_pyproject(root)
|
|
48
|
+
project = pyproject.get("project", {})
|
|
49
|
+
cpkit_config = pyproject.get("tool", {}).get("cpkit", {})
|
|
50
|
+
effective_app_name = (
|
|
51
|
+
app_name
|
|
52
|
+
or os.getenv("CPKIT_APP_NAME")
|
|
53
|
+
or cpkit_config.get("app_name")
|
|
54
|
+
or project.get("name")
|
|
55
|
+
or Path(sys.argv[0]).name
|
|
56
|
+
)
|
|
57
|
+
app_import = (
|
|
58
|
+
os.getenv("CPKIT_APP_IMPORT")
|
|
59
|
+
or cpkit_config.get("app_import")
|
|
60
|
+
or f"{effective_app_name}.main:app"
|
|
61
|
+
)
|
|
62
|
+
ddl_paths = _configured_paths(
|
|
63
|
+
root,
|
|
64
|
+
os.getenv("CPKIT_APP_DDL"),
|
|
65
|
+
cpkit_config.get("ddl"),
|
|
66
|
+
)
|
|
67
|
+
schema_checks = _configured_values(
|
|
68
|
+
os.getenv("CPKIT_SCHEMA_CHECKS"),
|
|
69
|
+
cpkit_config.get("schema_checks"),
|
|
70
|
+
)
|
|
71
|
+
db_url_env = (
|
|
72
|
+
os.getenv("CPKIT_DB_URL_ENV")
|
|
73
|
+
or cpkit_config.get("db_url_env")
|
|
74
|
+
or "CPKIT_DB_URL"
|
|
75
|
+
)
|
|
76
|
+
cpkit_ddl = cpkit_config.get("cpkit_ddl")
|
|
77
|
+
return cls(
|
|
78
|
+
app_name=effective_app_name,
|
|
79
|
+
app_import=app_import,
|
|
80
|
+
app_ddl_paths=ddl_paths,
|
|
81
|
+
app_schema_checks=schema_checks,
|
|
82
|
+
db_url_env=db_url_env,
|
|
83
|
+
cpkit_ddl_path=(root / cpkit_ddl) if cpkit_ddl else None,
|
|
84
|
+
project_root=root,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def main(self, argv: Sequence[str] | None = None) -> int:
|
|
88
|
+
"""Run the application CLI."""
|
|
89
|
+
args = self._parser().parse_args(argv)
|
|
90
|
+
return int(args.handler(args) or 0)
|
|
91
|
+
|
|
92
|
+
def init(self, _args: argparse.Namespace) -> int:
|
|
93
|
+
"""Initialize cpkit and application schemas from DDL files."""
|
|
94
|
+
db_url = self._db_url()
|
|
95
|
+
cpkit_ddl = self._cpkit_ddl_path()
|
|
96
|
+
if cpkit_ddl is not None:
|
|
97
|
+
print(f"Applying cpkit schema: {cpkit_ddl}")
|
|
98
|
+
apply_sql_file(db_url, cpkit_ddl)
|
|
99
|
+
|
|
100
|
+
for path in self._app_ddl_paths():
|
|
101
|
+
print(f"Applying {self.app_name} schema: {path}")
|
|
102
|
+
apply_sql_file(db_url, path)
|
|
103
|
+
|
|
104
|
+
self._check_schemas(db_url)
|
|
105
|
+
print("Initialization complete.")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
def serve(self, args: argparse.Namespace) -> int:
|
|
109
|
+
"""Serve the FastAPI app."""
|
|
110
|
+
serve_uvicorn(
|
|
111
|
+
self.app_import,
|
|
112
|
+
host=args.host,
|
|
113
|
+
port=args.port,
|
|
114
|
+
reload=args.reload,
|
|
115
|
+
log_level=args.log_level,
|
|
116
|
+
)
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
def _parser(self) -> argparse.ArgumentParser:
|
|
120
|
+
parser = argparse.ArgumentParser(prog=self.app_name)
|
|
121
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
122
|
+
|
|
123
|
+
init = subparsers.add_parser("init", help="Initialize database schemas.")
|
|
124
|
+
init.set_defaults(handler=self.init)
|
|
125
|
+
|
|
126
|
+
server = subparsers.add_parser("serve", help="Run the FastAPI application.")
|
|
127
|
+
server.add_argument("--host", default="0.0.0.0")
|
|
128
|
+
server.add_argument("--port", type=int, default=8000)
|
|
129
|
+
server.add_argument("--reload", action="store_true")
|
|
130
|
+
server.add_argument("--log-level", default="info")
|
|
131
|
+
server.set_defaults(handler=self.serve)
|
|
132
|
+
|
|
133
|
+
return parser
|
|
134
|
+
|
|
135
|
+
def _db_url(self) -> str:
|
|
136
|
+
db_url = os.getenv(self.db_url_env, "").strip()
|
|
137
|
+
if not db_url:
|
|
138
|
+
raise RuntimeError(f"{self.db_url_env} is not set.")
|
|
139
|
+
return db_url
|
|
140
|
+
|
|
141
|
+
def _check_schemas(self, db_url: str) -> None:
|
|
142
|
+
print("Checking database connectivity.")
|
|
143
|
+
check_database(db_url)
|
|
144
|
+
print("Checking cpkit schema.")
|
|
145
|
+
check_table(db_url, "cpkit.settings")
|
|
146
|
+
for table_name in self.app_schema_checks:
|
|
147
|
+
print(f"Checking application table: {table_name}")
|
|
148
|
+
check_table(db_url, table_name)
|
|
149
|
+
|
|
150
|
+
def _cpkit_ddl_path(self) -> Path | None:
|
|
151
|
+
if self.cpkit_ddl_path is not None:
|
|
152
|
+
return self.cpkit_ddl_path
|
|
153
|
+
|
|
154
|
+
candidate = cpkit_ddl_path()
|
|
155
|
+
if candidate.exists():
|
|
156
|
+
return candidate
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def _app_ddl_paths(self) -> tuple[Path, ...]:
|
|
160
|
+
if self.app_ddl_paths:
|
|
161
|
+
return self.app_ddl_paths
|
|
162
|
+
root = self.project_root or Path(os.getcwd()).resolve()
|
|
163
|
+
return tuple(path for path in _default_ddl_paths(root) if path.exists())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
167
|
+
"""Run the current project's cpkit application CLI."""
|
|
168
|
+
return ApplicationCLI.from_project().main(argv)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _load_pyproject(root: Path) -> dict:
|
|
172
|
+
path = root / "pyproject.toml"
|
|
173
|
+
if not path.exists():
|
|
174
|
+
return {}
|
|
175
|
+
return tomllib.loads(path.read_text())
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _configured_paths(
|
|
179
|
+
root: Path,
|
|
180
|
+
env_value: str | None,
|
|
181
|
+
config_value,
|
|
182
|
+
) -> tuple[Path, ...]:
|
|
183
|
+
values = _configured_values(env_value, config_value)
|
|
184
|
+
return tuple(root / value for value in values)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _configured_values(env_value: str | None, config_value) -> tuple[str, ...]:
|
|
188
|
+
if env_value:
|
|
189
|
+
return tuple(item.strip() for item in env_value.split(",") if item.strip())
|
|
190
|
+
if config_value:
|
|
191
|
+
return tuple(str(item) for item in config_value)
|
|
192
|
+
return ()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _default_ddl_paths(root: Path) -> tuple[Path, ...]:
|
|
196
|
+
return (
|
|
197
|
+
root / "resources" / "ddl.sql",
|
|
198
|
+
root / "resources" / "post_schema.sql",
|
|
199
|
+
root / "resources" / "database" / "ddl.sql",
|
|
200
|
+
)
|
cpkit/cli/schema.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Database schema initialization and preflight helpers for cpkit applications."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import psycopg
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def apply_sql_file(db_url: str, path: str | Path) -> None:
|
|
9
|
+
"""Execute a SQL file against the configured database."""
|
|
10
|
+
sql_path = Path(path)
|
|
11
|
+
sql = sql_path.read_text()
|
|
12
|
+
with psycopg.connect(db_url, autocommit=True) as conn:
|
|
13
|
+
with conn.cursor() as cur:
|
|
14
|
+
cur.execute(sql)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_database(db_url: str) -> None:
|
|
18
|
+
"""Verify the database can be reached."""
|
|
19
|
+
with psycopg.connect(db_url, autocommit=True) as conn:
|
|
20
|
+
with conn.cursor() as cur:
|
|
21
|
+
cur.execute("SELECT 1")
|
|
22
|
+
cur.fetchone()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_table(db_url: str, table_name: str) -> None:
|
|
26
|
+
"""Verify a required table can be queried."""
|
|
27
|
+
with psycopg.connect(db_url, autocommit=True) as conn:
|
|
28
|
+
with conn.cursor() as cur:
|
|
29
|
+
cur.execute(f"SELECT 1 FROM {table_name} LIMIT 1")
|
|
30
|
+
cur.fetchone()
|
cpkit/cli/server.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""ASGI server helpers for cpkit application CLIs."""
|
|
2
|
+
|
|
3
|
+
import uvicorn
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def serve_uvicorn(
|
|
7
|
+
app_import: str,
|
|
8
|
+
*,
|
|
9
|
+
host: str,
|
|
10
|
+
port: int,
|
|
11
|
+
reload: bool,
|
|
12
|
+
log_level: str,
|
|
13
|
+
) -> None:
|
|
14
|
+
"""Run an ASGI app through Uvicorn."""
|
|
15
|
+
uvicorn.run(
|
|
16
|
+
app_import,
|
|
17
|
+
host=host,
|
|
18
|
+
port=port,
|
|
19
|
+
reload=reload,
|
|
20
|
+
log_level=log_level,
|
|
21
|
+
log_config=None,
|
|
22
|
+
)
|
cpkit/config/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Config
|
|
2
|
+
|
|
3
|
+
The config package contains small helpers for reading environment-backed
|
|
4
|
+
configuration.
|
|
5
|
+
|
|
6
|
+
At the moment this package is intentionally tiny:
|
|
7
|
+
|
|
8
|
+
- `env.py`: Environment lookup helpers.
|
|
9
|
+
|
|
10
|
+
Keep broad framework wiring in `bundle.py` or capability-specific config modules
|
|
11
|
+
such as `auth/config.py`. Use this package for generic configuration helpers
|
|
12
|
+
that do not belong to a single capability.
|
|
13
|
+
|
cpkit/config/__init__.py
ADDED
cpkit/config/env.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Helpers for parsing environment-style configuration values."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def as_bool(value: str | None, default: bool = False) -> bool:
|
|
7
|
+
"""Parse common truthy environment-style values into a boolean."""
|
|
8
|
+
if value is None:
|
|
9
|
+
return default
|
|
10
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def safe_csv_set(raw_value: str | None) -> set[str]:
|
|
14
|
+
"""Split a comma-delimited string into a trimmed set of values."""
|
|
15
|
+
if not raw_value:
|
|
16
|
+
return set()
|
|
17
|
+
return {part.strip() for part in raw_value.split(",") if part and part.strip()}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def safe_json_string_dict(
|
|
21
|
+
value: str | None, *, default: dict[str, str] | None = None
|
|
22
|
+
) -> dict[str, str]:
|
|
23
|
+
"""Parse a JSON object and coerce its keys and values to strings."""
|
|
24
|
+
if not value:
|
|
25
|
+
return default or {}
|
|
26
|
+
|
|
27
|
+
parsed = json.loads(value)
|
|
28
|
+
if not isinstance(parsed, dict):
|
|
29
|
+
raise ValueError("Expected a JSON object.")
|
|
30
|
+
|
|
31
|
+
return {str(k): str(v) for k, v in parsed.items()}
|
cpkit/db/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# DB
|
|
2
|
+
|
|
3
|
+
The DB package owns the shared database connection pool and low-level query
|
|
4
|
+
helpers used by repository mixins.
|
|
5
|
+
|
|
6
|
+
cpkit currently targets Cockroach/Postgres-style SQL. The helper functions hide
|
|
7
|
+
some repetitive details: pool access, cursor row mapping, JSON/list dumpers,
|
|
8
|
+
statement normalization, and translation from database exceptions into
|
|
9
|
+
repository exceptions.
|
|
10
|
+
|
|
11
|
+
## Files
|
|
12
|
+
|
|
13
|
+
- `postgres.py`: Pool lifecycle, query helpers, custom dumpers, and database
|
|
14
|
+
error translation.
|
|
15
|
+
- `__init__.py`: Public exports.
|
|
16
|
+
|
|
17
|
+
## Runtime Flow
|
|
18
|
+
|
|
19
|
+
1. `create_cpkit_app()` calls `initialize_postgres(db_url)` during app startup.
|
|
20
|
+
2. Repository mixins call helpers such as `fetch_one()`, `fetch_all()`,
|
|
21
|
+
`fetch_scalar()`, and `execute_stmt()`.
|
|
22
|
+
3. Database exceptions are translated into `cpkit.errors.repository` types.
|
|
23
|
+
4. Service layers translate repository errors into user-facing service errors.
|
|
24
|
+
|
cpkit/db/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Database infrastructure helpers."""
|
|
2
|
+
|
|
3
|
+
from .postgres import (
|
|
4
|
+
close_db,
|
|
5
|
+
execute_stmt,
|
|
6
|
+
fetch_all,
|
|
7
|
+
fetch_one,
|
|
8
|
+
fetch_scalar,
|
|
9
|
+
get_pool,
|
|
10
|
+
initialize_postgres,
|
|
11
|
+
translate_database_error,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"close_db",
|
|
16
|
+
"execute_stmt",
|
|
17
|
+
"fetch_all",
|
|
18
|
+
"fetch_one",
|
|
19
|
+
"fetch_scalar",
|
|
20
|
+
"get_pool",
|
|
21
|
+
"initialize_postgres",
|
|
22
|
+
"translate_database_error",
|
|
23
|
+
]
|