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.
Files changed (90) hide show
  1. cpkit/README.md +82 -0
  2. cpkit/__init__.py +42 -0
  3. cpkit/admin.py +53 -0
  4. cpkit/app.py +130 -0
  5. cpkit/audit/README.md +33 -0
  6. cpkit/audit/__init__.py +44 -0
  7. cpkit/audit/events_service.py +36 -0
  8. cpkit/audit/recorder.py +240 -0
  9. cpkit/audit/repository.py +64 -0
  10. cpkit/audit/router.py +46 -0
  11. cpkit/audit/service.py +34 -0
  12. cpkit/audit/types.py +38 -0
  13. cpkit/auth/README.md +38 -0
  14. cpkit/auth/__init__.py +123 -0
  15. cpkit/auth/api_key_router.py +52 -0
  16. cpkit/auth/api_key_service.py +143 -0
  17. cpkit/auth/api_keys.py +143 -0
  18. cpkit/auth/bundle.py +113 -0
  19. cpkit/auth/claims.py +37 -0
  20. cpkit/auth/config.py +162 -0
  21. cpkit/auth/dependencies.py +155 -0
  22. cpkit/auth/oidc.py +703 -0
  23. cpkit/auth/redirects.py +12 -0
  24. cpkit/auth/repositories.py +181 -0
  25. cpkit/auth/router.py +214 -0
  26. cpkit/auth/secrets.py +78 -0
  27. cpkit/auth/types.py +49 -0
  28. cpkit/bundle.py +252 -0
  29. cpkit/cli/README.md +21 -0
  30. cpkit/cli/__init__.py +14 -0
  31. cpkit/cli/__main__.py +5 -0
  32. cpkit/cli/base.py +200 -0
  33. cpkit/cli/schema.py +30 -0
  34. cpkit/cli/server.py +22 -0
  35. cpkit/config/README.md +13 -0
  36. cpkit/config/__init__.py +9 -0
  37. cpkit/config/env.py +31 -0
  38. cpkit/db/README.md +24 -0
  39. cpkit/db/__init__.py +23 -0
  40. cpkit/db/postgres.py +252 -0
  41. cpkit/dependencies.py +73 -0
  42. cpkit/errors/README.md +22 -0
  43. cpkit/errors/__init__.py +35 -0
  44. cpkit/errors/http.py +45 -0
  45. cpkit/errors/repository.py +32 -0
  46. cpkit/errors/service.py +74 -0
  47. cpkit/jobs/README.md +29 -0
  48. cpkit/jobs/__init__.py +49 -0
  49. cpkit/jobs/maintenance.py +15 -0
  50. cpkit/jobs/repository.py +306 -0
  51. cpkit/jobs/router.py +105 -0
  52. cpkit/jobs/service.py +138 -0
  53. cpkit/jobs/types.py +67 -0
  54. cpkit/jobs/worker.py +200 -0
  55. cpkit/logging/README.md +19 -0
  56. cpkit/logging/__init__.py +13 -0
  57. cpkit/logging/context.py +29 -0
  58. cpkit/logging/middleware.py +55 -0
  59. cpkit/logging/setup.py +81 -0
  60. cpkit/playbooks/README.md +25 -0
  61. cpkit/playbooks/__init__.py +40 -0
  62. cpkit/playbooks/ansible.py +359 -0
  63. cpkit/playbooks/repository.py +95 -0
  64. cpkit/playbooks/router.py +95 -0
  65. cpkit/playbooks/service.py +232 -0
  66. cpkit/playbooks/types.py +43 -0
  67. cpkit/repository.py +63 -0
  68. cpkit/resources/__init__.py +17 -0
  69. cpkit/resources/ddl.sql +150 -0
  70. cpkit/settings/README.md +22 -0
  71. cpkit/settings/__init__.py +18 -0
  72. cpkit/settings/keys.py +30 -0
  73. cpkit/settings/repository.py +108 -0
  74. cpkit/settings/router.py +62 -0
  75. cpkit/settings/service.py +105 -0
  76. cpkit/settings/types.py +25 -0
  77. cpkit/time.py +4 -0
  78. cpkit/webapp/README.md +71 -0
  79. cpkit/webapp/__init__.py +12 -0
  80. cpkit/webapp/index.html +970 -0
  81. cpkit/webapp/script.js +1690 -0
  82. cpkit/webapp/style.css +1561 -0
  83. cpkit-0.1.0.dist-info/METADATA +105 -0
  84. cpkit-0.1.0.dist-info/RECORD +90 -0
  85. cpkit-0.1.0.dist-info/WHEEL +4 -0
  86. cpkit-0.1.0.dist-info/licenses/LICENSE +201 -0
  87. resources/README.md +16 -0
  88. resources/repository_maintenance_guide.md +90 -0
  89. resources/webapp_extension_guide.md +706 -0
  90. 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
@@ -0,0 +1,5 @@
1
+ """Module entry point for cpkit CLI helpers."""
2
+
3
+ from .base import main
4
+
5
+ raise SystemExit(main())
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
+
@@ -0,0 +1,9 @@
1
+ """Configuration helpers."""
2
+
3
+ from .env import as_bool, safe_csv_set, safe_json_string_dict
4
+
5
+ __all__ = [
6
+ "as_bool",
7
+ "safe_csv_set",
8
+ "safe_json_string_dict",
9
+ ]
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
+ ]