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/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # cpkit Code Map
2
+
3
+ cpkit is an application framework for FastAPI apps that want the same
4
+ operational backbone: authentication, audit events, settings, jobs, playbooks,
5
+ database helpers, and a shared webapp shell.
6
+
7
+ The package is organized by capability. The directories look flat at first
8
+ glance, but most capability packages use the same internal pattern:
9
+
10
+ - `types.py`: Pydantic models, enums, and response shapes.
11
+ - `repository.py` or `repositories.py`: database mixins for framework-owned
12
+ tables.
13
+ - `service.py`: business rules and repository error translation.
14
+ - `router.py`: FastAPI routes and dependency wiring.
15
+ - `__init__.py`: public exports for that capability.
16
+
17
+ The main app flow is:
18
+
19
+ 1. An app calls `create_cpkit_bundle()` from `bundle.py` to assemble cpkit
20
+ capabilities.
21
+ 2. The app calls `create_cpkit_app()` from `app.py` to create the FastAPI app,
22
+ configure the repository, run startup hooks, mount routers, and launch
23
+ background tasks.
24
+ 3. `repository.py` provides `CPKitRepo`, a mix of all framework repository
25
+ capabilities. Apps can subclass it and add their own repository methods.
26
+ 4. Capability services use `get_repo()` to work with the configured app repo.
27
+ 5. The shared webapp in `webapp/` talks to the framework API routes and can be
28
+ extended by app-owned web assets.
29
+
30
+ ## Architecture Review
31
+
32
+ The current flat capability layout is reasonable for cpkit's size. It keeps
33
+ imports readable and makes each framework feature easy to find. A deeper layout
34
+ like `cpkit/core`, `cpkit/capabilities`, and `cpkit/integrations` might become
35
+ useful later, but moving there now would mostly create import churn.
36
+
37
+ The part that made the project feel like a black box was not the package shape;
38
+ it was the lack of an orientation layer. These README files are intended to be
39
+ that layer. They explain the module boundaries without changing runtime code.
40
+
41
+ Good rules of thumb:
42
+
43
+ - Start in `bundle.py` when asking "how does cpkit assemble itself?"
44
+ - Start in `app.py` when asking "how does cpkit become a FastAPI app?"
45
+ - Start in `repository.py` when asking "how do framework services reach the DB?"
46
+ - Start in a capability package when asking about one feature area.
47
+ - Start in `webapp/README.md` for frontend extension behavior.
48
+
49
+ ## Top-Level Modules
50
+
51
+ - `app.py`: FastAPI app bootstrap, lifespan management, router mounting, static
52
+ webapp mounting, DB initialization, and background task startup.
53
+ - `bundle.py`: The standard cpkit capability bundle. It wires auth, jobs,
54
+ events, admin routes, settings, playbooks, API keys, audit hooks, and the
55
+ queue worker.
56
+ - `repository.py`: The framework repository composition point. `CPKitRepo`
57
+ combines all cpkit repository mixins; `configure_repository()` tells cpkit how
58
+ to create app repo instances.
59
+ - `resources/`: Packaged framework resources such as `ddl.sql`. Use
60
+ `cpkit_ddl_path()` to locate them from installed wheels.
61
+ - `admin.py`: Composes built-in admin routers under the admin API prefix.
62
+ - `dependencies.py`: Global dependency accessors used by routers after a bundle
63
+ has been configured.
64
+ - `time.py`: Shared timestamp formatting constants.
65
+
66
+ ## Capability Packages
67
+
68
+ - `audit/`: audit event records, event reads, audit hooks, and the context used
69
+ to attach request/job identifiers.
70
+ - `auth/`: OIDC login/logout, API key auth, role/group mapping, encrypted
71
+ secrets, and auth dependencies.
72
+ - `cli/`: reusable app CLI for schema initialization, schema checks, and serving.
73
+ - `config/`: small environment/config helpers.
74
+ - `db/`: database pool, query helpers, Cockroach/Postgres compatibility, and
75
+ DB error translation.
76
+ - `errors/`: repository/service/http exception layers.
77
+ - `jobs/`: framework job table, queue table, queue worker, job APIs, and job
78
+ rescheduling.
79
+ - `logging/`: request id context, middleware, and logging setup.
80
+ - `playbooks/`: versioned playbook storage and Ansible runner integration.
81
+ - `settings/`: framework settings table and admin settings API.
82
+ - `webapp/`: static HTML/CSS/JS shell and extension contract for app UIs.
cpkit/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """Reusable control-plane framework primitives."""
2
+
3
+ from .admin import create_cpkit_admin_router
4
+ from .app import create_cpkit_app
5
+ from .bundle import CpkitBundle, create_cpkit_bundle
6
+ from .cli import ApplicationCLI
7
+ from .dependencies import (
8
+ configure_cpkit_dependencies,
9
+ get_access_scope,
10
+ get_audit_actor,
11
+ require_admin,
12
+ require_authenticated,
13
+ require_readonly,
14
+ require_user,
15
+ )
16
+ from .repository import CPKitRepo, configure_repository, get_repo
17
+ from .resources import cpkit_ddl_path, cpkit_resources_directory
18
+ from .time import STRFTIME, TS_FORMAT
19
+ from .webapp import template_webapp_directory
20
+
21
+ __all__ = [
22
+ "CPKitRepo",
23
+ "ApplicationCLI",
24
+ "CpkitBundle",
25
+ "STRFTIME",
26
+ "TS_FORMAT",
27
+ "configure_cpkit_dependencies",
28
+ "configure_repository",
29
+ "create_cpkit_bundle",
30
+ "create_cpkit_admin_router",
31
+ "create_cpkit_app",
32
+ "cpkit_ddl_path",
33
+ "cpkit_resources_directory",
34
+ "get_access_scope",
35
+ "get_audit_actor",
36
+ "get_repo",
37
+ "require_admin",
38
+ "require_authenticated",
39
+ "require_readonly",
40
+ "require_user",
41
+ "template_webapp_directory",
42
+ ]
cpkit/admin.py ADDED
@@ -0,0 +1,53 @@
1
+ """Admin router assembly for framework-owned capabilities."""
2
+
3
+ from collections.abc import Callable, Sequence
4
+ from typing import Any
5
+
6
+ from fastapi import APIRouter
7
+
8
+ from .auth import create_api_keys_router
9
+ from .playbooks import create_playbooks_router
10
+ from .settings import create_settings_router
11
+
12
+
13
+ def create_cpkit_admin_router(
14
+ *,
15
+ get_api_keys_service: Callable[..., Any],
16
+ get_settings_service: Callable[..., Any],
17
+ get_playbooks_service: Callable[..., Any],
18
+ get_audit_actor: Callable[..., Any],
19
+ handle_service_error: Callable[[Exception], None],
20
+ service_error_type: type[Exception] = Exception,
21
+ prefix: str = "/admin",
22
+ dependencies: Sequence[Any] | None = None,
23
+ ) -> APIRouter:
24
+ """Create the standard admin routes provided by cpkit."""
25
+ router = APIRouter(
26
+ prefix=prefix,
27
+ dependencies=list(dependencies or ()),
28
+ )
29
+ router.include_router(
30
+ create_settings_router(
31
+ get_service=get_settings_service,
32
+ get_audit_actor=get_audit_actor,
33
+ handle_service_error=handle_service_error,
34
+ service_error_type=service_error_type,
35
+ )
36
+ )
37
+ router.include_router(
38
+ create_playbooks_router(
39
+ get_service=get_playbooks_service,
40
+ get_audit_actor=get_audit_actor,
41
+ handle_service_error=handle_service_error,
42
+ service_error_type=service_error_type,
43
+ )
44
+ )
45
+ router.include_router(
46
+ create_api_keys_router(
47
+ get_service=get_api_keys_service,
48
+ get_audit_actor=get_audit_actor,
49
+ handle_service_error=handle_service_error,
50
+ service_error_type=service_error_type,
51
+ )
52
+ )
53
+ return router
cpkit/app.py ADDED
@@ -0,0 +1,130 @@
1
+ """FastAPI application bootstrap helpers."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ from collections.abc import Awaitable, Callable, Iterable
6
+ from contextlib import asynccontextmanager
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from fastapi import APIRouter, FastAPI, Request
11
+ from fastapi.staticfiles import StaticFiles
12
+
13
+ from cpkit.bundle import CpkitBundle
14
+ from cpkit.db import close_db, initialize_postgres
15
+ from cpkit.logging import configure_logging, request_logging_middleware
16
+ from cpkit.repository import configure_repository
17
+ from cpkit.repository import get_repo as get_configured_repo
18
+
19
+ StartupHook = Callable[[], Any]
20
+ BackgroundTaskFactory = Callable[[], Awaitable[Any]]
21
+ RepoClass = Callable[[Any], Any]
22
+
23
+
24
+ def create_cpkit_app(
25
+ *,
26
+ title: str,
27
+ version: str,
28
+ repo_class: RepoClass | None = None,
29
+ get_repo: Callable[[], Any] | None = None,
30
+ db_url: str | None,
31
+ capabilities: Iterable[CpkitBundle] | None = None,
32
+ bundles: Iterable[CpkitBundle] = (),
33
+ routers: Iterable[APIRouter] = (),
34
+ startup_hooks: Iterable[StartupHook] = (),
35
+ background_tasks: Iterable[BackgroundTaskFactory] = (),
36
+ static_directory: str | Path | None = None,
37
+ app_static_directory: str | Path | None = None,
38
+ api_prefix: str = "/api",
39
+ default_journald_identifier: str = "cp",
40
+ ) -> FastAPI:
41
+ """Create a cpkit-managed FastAPI app with an application API subapp."""
42
+ capability_tuple = tuple(capabilities if capabilities is not None else bundles)
43
+
44
+ @asynccontextmanager
45
+ async def lifespan(_app: FastAPI):
46
+ running_tasks: list[asyncio.Task[Any]] = []
47
+
48
+ initialize_postgres(db_url)
49
+ if repo_class is not None:
50
+ configure_repository(repo_class=repo_class)
51
+ elif get_repo is not None:
52
+ configure_repository(repo_factory=get_repo)
53
+ else:
54
+ raise ValueError("repo_class or get_repo is required.")
55
+
56
+ repo = get_configured_repo()
57
+ configure_logging(
58
+ repo,
59
+ force=True,
60
+ default_journald_identifier=default_journald_identifier,
61
+ )
62
+ effective_startup_hooks = (
63
+ *(
64
+ hook
65
+ for capability in capability_tuple
66
+ for hook in capability.startup_hooks
67
+ ),
68
+ *startup_hooks,
69
+ )
70
+ for hook in effective_startup_hooks:
71
+ result = hook()
72
+ if inspect.isawaitable(result):
73
+ await result
74
+
75
+ effective_background_tasks = (
76
+ *(
77
+ task
78
+ for capability in capability_tuple
79
+ for task in capability.background_tasks
80
+ ),
81
+ *background_tasks,
82
+ )
83
+ running_tasks = [
84
+ asyncio.create_task(task_factory())
85
+ for task_factory in effective_background_tasks
86
+ ]
87
+
88
+ yield
89
+
90
+ for task in running_tasks:
91
+ task.cancel()
92
+ for task in running_tasks:
93
+ try:
94
+ await task
95
+ except asyncio.CancelledError:
96
+ pass
97
+
98
+ close_db()
99
+
100
+ app = FastAPI(lifespan=lifespan)
101
+ api = FastAPI(title=title, version=version)
102
+
103
+ effective_routers = (
104
+ *(router for capability in capability_tuple for router in capability.routers),
105
+ *routers,
106
+ )
107
+ for router in effective_routers:
108
+ api.include_router(router)
109
+
110
+ app.mount(api_prefix, api)
111
+
112
+ if app_static_directory is not None:
113
+ app.mount(
114
+ "/app",
115
+ StaticFiles(directory=Path(app_static_directory), html=True),
116
+ name="app_webapp",
117
+ )
118
+
119
+ if static_directory is not None:
120
+ app.mount(
121
+ "/",
122
+ StaticFiles(directory=Path(static_directory), html=True),
123
+ name="webapp",
124
+ )
125
+
126
+ @app.middleware("http")
127
+ async def dispatch(request: Request, call_next):
128
+ return await request_logging_middleware(request, call_next)
129
+
130
+ return app
cpkit/audit/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # Audit
2
+
3
+ The audit package records and exposes framework/application events such as
4
+ logins, settings changes, API key changes, reschedules, and app-defined actions.
5
+
6
+ The important idea is that audit records are app-owned objects built through a
7
+ record factory, then written through the configured repository. cpkit provides a
8
+ default `AuditLogRecord`, but apps can provide their own factory if their audit
9
+ schema differs.
10
+
11
+ ## Files
12
+
13
+ - `types.py`: Default audit models, including `AuditLogRecord`.
14
+ - `recorder.py`: The common audit recorder and hooks. This is where request ids
15
+ and job ids are resolved before calling the record factory.
16
+ - `repository.py`: Repository mixin for reading and writing `cpkit.event_log`.
17
+ - `events_service.py`: Service used by the Events page/API to list events.
18
+ - `router.py`: Read-only API routes for audit events.
19
+ - `service.py`: Generic audit emission service for app code that wants a small
20
+ service wrapper.
21
+
22
+ ## Runtime Flow
23
+
24
+ 1. A service calls `log_event()` or a hook created by `create_audit_event_hook()`.
25
+ 2. `AuditRecorder` resolves contextual values, currently `request_id` and
26
+ `job_id`, through provider functions.
27
+ 3. The configured record factory receives `actor_id`, `event_type`, `metadata`,
28
+ `request_id`, and `job_id`.
29
+ 4. The resulting record is written through the repository's `log_event()` method.
30
+
31
+ Best-effort audit logging should not break the user workflow. If an audit record
32
+ cannot be built or written, cpkit logs the exception and returns `False`.
33
+
@@ -0,0 +1,44 @@
1
+ """Generic audit record types and service helpers."""
2
+
3
+ from .events_service import AuditEventsService
4
+ from .recorder import (
5
+ AuditRecorder,
6
+ AuditRecordWriter,
7
+ build_audit_log_record,
8
+ configure_audit_logging,
9
+ create_audit_event_hook,
10
+ job_id_ctx,
11
+ log_event,
12
+ write_audit_record,
13
+ write_audit_record_best_effort,
14
+ )
15
+ from .repository import EVENT_LOG_TABLE, AuditEventsRepositoryMixin
16
+ from .router import create_events_router
17
+ from .service import AuditService
18
+ from .types import (
19
+ AuditEventCountResponse,
20
+ AuditLogRecord,
21
+ AuditOutcome,
22
+ AuditRecordCreate,
23
+ )
24
+
25
+ __all__ = [
26
+ "AuditEventCountResponse",
27
+ "AuditEventsRepositoryMixin",
28
+ "AuditEventsService",
29
+ "AuditLogRecord",
30
+ "AuditOutcome",
31
+ "AuditRecorder",
32
+ "AuditRecordCreate",
33
+ "AuditRecordWriter",
34
+ "AuditService",
35
+ "EVENT_LOG_TABLE",
36
+ "build_audit_log_record",
37
+ "configure_audit_logging",
38
+ "create_events_router",
39
+ "create_audit_event_hook",
40
+ "job_id_ctx",
41
+ "log_event",
42
+ "write_audit_record",
43
+ "write_audit_record_best_effort",
44
+ ]
@@ -0,0 +1,36 @@
1
+ """Service helpers for framework audit event reads."""
2
+
3
+ from cpkit.errors import RepositoryError, from_repository_error
4
+
5
+ from .types import AuditLogRecord
6
+
7
+
8
+ class AuditEventsService:
9
+ def __init__(self, repo) -> None:
10
+ self.repo = repo
11
+
12
+ def list_visible_events(
13
+ self,
14
+ limit: int,
15
+ offset: int,
16
+ groups: list[str],
17
+ is_admin: bool,
18
+ ) -> list[AuditLogRecord]:
19
+ try:
20
+ return self.repo.list_events(limit, offset, groups, is_admin)
21
+ except RepositoryError as err:
22
+ raise from_repository_error(
23
+ err,
24
+ unavailable_message="Events are temporarily unavailable.",
25
+ fallback_message="Unable to load events.",
26
+ ) from err
27
+
28
+ def get_event_total(self) -> int:
29
+ try:
30
+ return self.repo.get_event_count()
31
+ except RepositoryError as err:
32
+ raise from_repository_error(
33
+ err,
34
+ unavailable_message="Event totals are temporarily unavailable.",
35
+ fallback_message="Unable to load the event count.",
36
+ ) from err
@@ -0,0 +1,240 @@
1
+ """Audit recording helpers for application-owned audit record models."""
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from contextvars import ContextVar
6
+ from enum import StrEnum
7
+ from typing import Any, Protocol
8
+
9
+ from cpkit.logging import request_id_ctx
10
+
11
+ from .types import AuditLogRecord
12
+
13
+ logger = logging.getLogger(__name__)
14
+ _audit_record_factory: Callable[..., Any] | None = None
15
+ job_id_ctx: ContextVar[int | None] = ContextVar("job_id", default=None)
16
+
17
+
18
+ class AuditRecordWriter(Protocol):
19
+ """Repository-like object that can persist an application audit record."""
20
+
21
+ def log_event(self, record: Any) -> Any: ...
22
+
23
+
24
+ def write_audit_record(
25
+ writer: Any,
26
+ record: Any,
27
+ *,
28
+ method_name: str = "log_event",
29
+ ) -> Any:
30
+ """Write an application-owned audit record through a repository-like writer."""
31
+ return getattr(writer, method_name)(record)
32
+
33
+
34
+ def write_audit_record_best_effort(
35
+ writer: Any,
36
+ record: Any,
37
+ *,
38
+ event_type: str | None = None,
39
+ method_name: str = "log_event",
40
+ event_logger: logging.Logger | None = None,
41
+ ) -> bool:
42
+ """Write an audit record without letting audit failures fail the caller."""
43
+ active_logger = event_logger or logger
44
+
45
+ try:
46
+ write_audit_record(writer, record, method_name=method_name)
47
+ except Exception:
48
+ active_logger.exception("Failed to write audit event %s", event_type or record)
49
+ return False
50
+
51
+ return True
52
+
53
+
54
+ class AuditRecorder:
55
+ """Build and persist application-owned audit records with common mechanics."""
56
+
57
+ def __init__(
58
+ self,
59
+ writer: AuditRecordWriter,
60
+ record_factory: Callable[..., Any],
61
+ *,
62
+ request_id_provider: Callable[[], str | None] | None = None,
63
+ job_id_provider: Callable[[], int | None] | None = None,
64
+ method_name: str = "log_event",
65
+ event_logger: logging.Logger | None = None,
66
+ ) -> None:
67
+ self.writer = writer
68
+ self.record_factory = record_factory
69
+ self.request_id_provider = request_id_provider
70
+ self.job_id_provider = job_id_provider
71
+ self.method_name = method_name
72
+ self.logger = event_logger or logger
73
+
74
+ def emit(
75
+ self,
76
+ event_type: str | StrEnum,
77
+ *,
78
+ actor_id: str,
79
+ metadata: dict[str, Any] | None = None,
80
+ request_id: str | None = None,
81
+ job_id: int | None = None,
82
+ ) -> Any:
83
+ """Create and write an application-owned audit record."""
84
+ record = self._build_record(
85
+ event_type,
86
+ actor_id=actor_id,
87
+ metadata=metadata,
88
+ request_id=request_id,
89
+ job_id=job_id,
90
+ )
91
+ return write_audit_record(
92
+ self.writer,
93
+ record,
94
+ method_name=self.method_name,
95
+ )
96
+
97
+ def emit_best_effort(
98
+ self,
99
+ event_type: str | StrEnum,
100
+ *,
101
+ actor_id: str,
102
+ metadata: dict[str, Any] | None = None,
103
+ request_id: str | None = None,
104
+ job_id: int | None = None,
105
+ ) -> bool:
106
+ """Create and write an audit record without failing the caller."""
107
+ try:
108
+ record = self._build_record(
109
+ event_type,
110
+ actor_id=actor_id,
111
+ metadata=metadata,
112
+ request_id=request_id,
113
+ job_id=job_id,
114
+ )
115
+ return write_audit_record_best_effort(
116
+ self.writer,
117
+ record,
118
+ event_type=str(event_type),
119
+ method_name=self.method_name,
120
+ event_logger=self.logger,
121
+ )
122
+ except Exception:
123
+ self.logger.exception("Failed to build audit event %s", event_type)
124
+ return False
125
+
126
+ def _build_record(
127
+ self,
128
+ event_type: str | StrEnum,
129
+ *,
130
+ actor_id: str,
131
+ metadata: dict[str, Any] | None,
132
+ request_id: str | None,
133
+ job_id: int | None,
134
+ ) -> Any:
135
+ effective_request_id = request_id
136
+ if effective_request_id is None and self.request_id_provider is not None:
137
+ effective_request_id = self.request_id_provider()
138
+
139
+ effective_job_id = job_id
140
+ if effective_job_id is None and self.job_id_provider is not None:
141
+ effective_job_id = self.job_id_provider()
142
+
143
+ return self.record_factory(
144
+ actor_id=actor_id,
145
+ event_type=str(event_type),
146
+ metadata=metadata,
147
+ request_id=effective_request_id,
148
+ job_id=effective_job_id,
149
+ )
150
+
151
+
152
+ def build_audit_log_record(
153
+ *,
154
+ actor_id: str,
155
+ event_type: str,
156
+ metadata: dict[str, Any] | None,
157
+ request_id: str | None,
158
+ job_id: int | None,
159
+ default_metadata: dict[str, Any] | None = None,
160
+ ) -> AuditLogRecord:
161
+ """Build the standard cpkit audit log record."""
162
+ effective_metadata = metadata if metadata is not None else default_metadata
163
+ return AuditLogRecord(
164
+ user_id=actor_id,
165
+ action=event_type,
166
+ job_id=job_id,
167
+ details=effective_metadata,
168
+ request_id=_normalize_request_id(request_id),
169
+ )
170
+
171
+
172
+ def _normalize_request_id(request_id: str | None) -> str | None:
173
+ return (str(request_id).strip() or None) if request_id else None
174
+
175
+
176
+ def configure_audit_logging(record_factory: Callable[..., Any]) -> None:
177
+ """Configure the default audit record factory for framework logging APIs."""
178
+ global _audit_record_factory
179
+ _audit_record_factory = record_factory
180
+
181
+
182
+ def create_audit_event_hook(
183
+ record_factory: Callable[..., Any],
184
+ *,
185
+ request_id_provider: Callable[[], str | None] = request_id_ctx.get,
186
+ job_id_provider: Callable[[], int | None] = job_id_ctx.get,
187
+ best_effort: bool = True,
188
+ event_logger: logging.Logger | None = None,
189
+ ) -> Callable[[Any, str, str | StrEnum, dict[str, Any] | None], None]:
190
+ """Create a reusable audit hook backed by an application record factory."""
191
+
192
+ def emit_audit_event(
193
+ repo: Any,
194
+ actor_id: str,
195
+ action: str | StrEnum,
196
+ details: dict[str, Any] | None = None,
197
+ ) -> None:
198
+ recorder = AuditRecorder(
199
+ repo,
200
+ record_factory,
201
+ request_id_provider=request_id_provider,
202
+ job_id_provider=job_id_provider,
203
+ event_logger=event_logger,
204
+ )
205
+ if best_effort:
206
+ recorder.emit_best_effort(
207
+ action,
208
+ actor_id=actor_id,
209
+ metadata=details,
210
+ )
211
+ return
212
+
213
+ recorder.emit(
214
+ action,
215
+ actor_id=actor_id,
216
+ metadata=details,
217
+ )
218
+
219
+ return emit_audit_event
220
+
221
+
222
+ def log_event(
223
+ repo: Any,
224
+ actor_id: str,
225
+ action: str | StrEnum,
226
+ details: dict[str, Any] | None = None,
227
+ ) -> None:
228
+ """Best-effort audit logging for cpkit-backed service actions."""
229
+ if _audit_record_factory is None:
230
+ raise RuntimeError("cpkit audit logging has not been configured.")
231
+
232
+ create_audit_event_hook(
233
+ _audit_record_factory,
234
+ event_logger=logger,
235
+ )(
236
+ repo,
237
+ actor_id,
238
+ action,
239
+ details,
240
+ )