everscribe 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.
everscribe/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """Everscribe Python SDK.
2
+
3
+ Python SDK for the Everscribe audit-log API. Two coordinated surfaces:
4
+
5
+ - :mod:`everscribe.recorder` - append-only event ingest.
6
+ - :mod:`everscribe.minter` - short-lived embed tokens for frontend audit views.
7
+
8
+ Bind credentials once with :func:`new` (or :func:`new_from_env`) and create
9
+ per-surface subclients from the returned :class:`Client`::
10
+
11
+ import everscribe
12
+
13
+ es = everscribe.new(project_id, api_key)
14
+ rec = es.new_recorder()
15
+ rec.record(everscribe.Event("user.login"))
16
+
17
+ The core is zero-dependency (standard library only). The optional ASGI adapter
18
+ lives in :mod:`everscribe.asgi` (install the ``fastapi`` extra).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from . import event, minter, recorder
24
+ from .client import (
25
+ EVERSCRIBE_API_KEY,
26
+ EVERSCRIBE_PROJECT_ID,
27
+ Client,
28
+ new,
29
+ new_from_env,
30
+ )
31
+ from .event import Event
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ __all__ = [
36
+ "__version__",
37
+ # Root client
38
+ "Client",
39
+ "new",
40
+ "new_from_env",
41
+ "EVERSCRIBE_PROJECT_ID",
42
+ "EVERSCRIBE_API_KEY",
43
+ # Event model
44
+ "Event",
45
+ # Subpackages
46
+ "event",
47
+ "recorder",
48
+ "minter",
49
+ ]
everscribe/asgi.py ADDED
@@ -0,0 +1,155 @@
1
+ """ASGI middleware for FastAPI / Starlette (and any ASGI app).
2
+
3
+ This adapter supplies only ASGI-specific transport bindings; the record
4
+ lifecycle itself (the request-scoped current event, dedupe, idempotency-key
5
+ stamping, and the final-outcome rule) lives in :mod:`everscribe.event` so
6
+ other framework adapters (Flask, Django, gRPC) can reuse it identically.
7
+
8
+ It:
9
+
10
+ 1. Builds an Event template (actor from ``resolve_actor``, origin from the
11
+ request) and installs it, via :func:`everscribe.event.request_scope`, in
12
+ the request-scoped context so handlers can pull fresh events with
13
+ :func:`everscribe.event.new_from_context`.
14
+ 2. Installs a status capture so :func:`everscribe.event.prepare_event` can
15
+ auto-populate the event's ``result`` from the final HTTP status when the
16
+ handler hasn't set one explicitly.
17
+ 3. Exposes a single per-request mutable Event via :func:`everscribe.event.current_event`
18
+ (re-exported here as :func:`current_event`, and available at
19
+ ``request.state.everscribe_event``) for handlers to enrich (action,
20
+ target, metadata, optionally result).
21
+ 4. If a ``recorder`` was configured, records that event once when the response
22
+ finishes via :func:`everscribe.event.end_event` - but only when the handler
23
+ set ``action`` (empty action is a no-op), and only if nothing already
24
+ claimed the dedupe flag. Auto-record failures are logged, never raised: an
25
+ audit failure must not break the user-facing response.
26
+
27
+ Mount it AFTER any session/auth middleware, since ``resolve_actor`` typically
28
+ reads identity off the request.
29
+
30
+ Requires Starlette (``pip install "everscribe[fastapi]"``).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ from collections.abc import Callable
37
+
38
+ try:
39
+ from starlette.requests import Request
40
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
41
+ except ImportError as e: # pragma: no cover - exercised via install extras
42
+ raise ImportError(
43
+ "everscribe.asgi requires Starlette. Install it with: "
44
+ 'pip install "everscribe[fastapi]"'
45
+ ) from e
46
+
47
+ from .event import (
48
+ Event,
49
+ current_event,
50
+ end_event,
51
+ origin_from_request,
52
+ request_scope,
53
+ result_from_http_status,
54
+ )
55
+ from .event.types import Actor, Logger, Result
56
+ from .recorder import Recorder
57
+
58
+ ActorResolver = Callable[[Request], Actor]
59
+ """Derives the :class:`Actor` for a request. Typically reads session/auth
60
+ state off the Starlette ``Request``. Must not consume the request body."""
61
+
62
+ _ANONYMOUS = Actor(type="anonymous")
63
+
64
+
65
+ def event_from_request(request: Request) -> Event:
66
+ """Return the per-request event from ``request.state``, falling back to
67
+ :func:`current_event`."""
68
+ e = getattr(request.state, "everscribe_event", None)
69
+ return e if isinstance(e, Event) else current_event()
70
+
71
+
72
+ class _StatusCapture:
73
+ """Captures the response status from the ASGI ``http.response.start``
74
+ message and translates it into a :class:`Result` on demand, satisfying
75
+ :class:`everscribe.event.OutcomeCapture`.
76
+
77
+ Status 0 (nothing written yet) maps to ``outcome`` being ``None`` -
78
+ :func:`result_from_http_status` is only consulted once a status has
79
+ actually been captured.
80
+ """
81
+
82
+ def __init__(self) -> None:
83
+ self._status = 0
84
+
85
+ def set_status(self, status: int) -> None:
86
+ self._status = status
87
+
88
+ @property
89
+ def outcome(self) -> Result | None:
90
+ if self._status == 0:
91
+ return None
92
+ return result_from_http_status(self._status)
93
+
94
+
95
+ class EverscribeMiddleware:
96
+ """ASGI middleware that installs a per-request audit event and (optionally)
97
+ auto-records it on response finish.
98
+
99
+ Add it to a Starlette/FastAPI app::
100
+
101
+ app.add_middleware(
102
+ EverscribeMiddleware,
103
+ recorder=rec,
104
+ resolve_actor=lambda req: Actor(type="user", id=req.state.user_id),
105
+ )
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ app: ASGIApp,
111
+ *,
112
+ recorder: Recorder | None = None,
113
+ resolve_actor: ActorResolver | None = None,
114
+ logger: Logger | None = None,
115
+ ) -> None:
116
+ self.app = app
117
+ self.recorder = recorder
118
+ self.resolve_actor: ActorResolver = resolve_actor or (lambda req: _ANONYMOUS)
119
+ self.logger: Logger = logger or logging.getLogger("everscribe.asgi")
120
+
121
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
122
+ if scope["type"] != "http":
123
+ await self.app(scope, receive, send)
124
+ return
125
+
126
+ request = Request(scope, receive=receive)
127
+ template = Event()
128
+ template.actor = self.resolve_actor(request)
129
+ remote = request.client.host if request.client else ""
130
+ template.origin = origin_from_request(dict(request.headers), remote)
131
+
132
+ capture = _StatusCapture()
133
+
134
+ async def send_wrapper(message: Message) -> None:
135
+ if message["type"] == "http.response.start":
136
+ capture.set_status(int(message["status"]))
137
+ await send(message)
138
+
139
+ with request_scope(template, capture) as user_event:
140
+ scope.setdefault("state", {})["everscribe_event"] = user_event
141
+ try:
142
+ await self.app(scope, receive, send_wrapper)
143
+ finally:
144
+ # end_event applies the final outcome (including the "no
145
+ # response written" sentinel) and records user_event once,
146
+ # skipping it if a handler already recorded it manually.
147
+ end_event(user_event, self.recorder, self.logger)
148
+
149
+
150
+ __all__ = [
151
+ "ActorResolver",
152
+ "EverscribeMiddleware",
153
+ "current_event",
154
+ "event_from_request",
155
+ ]
everscribe/client.py ADDED
@@ -0,0 +1,102 @@
1
+ """The top-level Everscribe client: binds a project's credentials once and
2
+ hands out per-surface subclients (recorder for ingest, minter for read-side
3
+ embed tokens) that share the same auth.
4
+
5
+ Typical usage::
6
+
7
+ import everscribe
8
+
9
+ es = everscribe.new(project_id, api_key) # or new_from_env()
10
+ rec = es.new_recorder()
11
+ try:
12
+ ...
13
+ finally:
14
+ rec.close()
15
+
16
+ m = es.new_minter()
17
+ token = m.mint_token(everscribe.minter.TokenOptions(...))
18
+
19
+ Customers who only need one surface can call its constructor directly -
20
+ ``everscribe.recorder.new(project_id, api_key)`` and
21
+ ``everscribe.minter.Client(project_id, api_key)`` both work and skip the
22
+ client step.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ from typing import Any
29
+
30
+ from .minter import Client as MinterClient
31
+ from .recorder import BufferedRecorder
32
+ from .recorder import new as _recorder_new
33
+
34
+ #: Environment-variable names read by :func:`new_from_env`.
35
+ EVERSCRIBE_PROJECT_ID = "EVERSCRIBE_PROJECT_ID"
36
+ EVERSCRIBE_API_KEY = "EVERSCRIBE_API_KEY"
37
+
38
+
39
+ class Client:
40
+ """Credential-bearing handle to an Everscribe project. Holds no network
41
+ state itself; the subclient constructors build per-surface clients that own
42
+ their own connections. Reuse a single Client for the lifetime of the
43
+ process - safe for concurrent use."""
44
+
45
+ def __init__(self, project_id: str, api_key: str) -> None:
46
+ """Validate and trim credentials. Raises :class:`ValueError` if either
47
+ is empty or whitespace-only, so configuration bugs surface at
48
+ construction rather than at the first network call."""
49
+ project_id = project_id.strip()
50
+ if not project_id:
51
+ raise ValueError("everscribe: project_id is empty")
52
+ api_key = api_key.strip()
53
+ if not api_key:
54
+ raise ValueError("everscribe: api_key is empty")
55
+ self.project_id = project_id
56
+ self._api_key = api_key
57
+
58
+ @classmethod
59
+ def from_env(cls) -> Client:
60
+ """Construct a Client from ``EVERSCRIBE_PROJECT_ID`` and
61
+ ``EVERSCRIBE_API_KEY``. Raises :class:`ValueError` naming the missing
62
+ variable if either is unset or empty after trimming."""
63
+ project_id = os.environ.get(EVERSCRIBE_PROJECT_ID, "").strip()
64
+ if not project_id:
65
+ raise ValueError(f"everscribe: {EVERSCRIBE_PROJECT_ID} is not set or empty")
66
+ api_key = os.environ.get(EVERSCRIBE_API_KEY, "").strip()
67
+ if not api_key:
68
+ raise ValueError(f"everscribe: {EVERSCRIBE_API_KEY} is not set or empty")
69
+ return cls(project_id, api_key)
70
+
71
+ def new_recorder(self, **opts: Any) -> BufferedRecorder:
72
+ """Return a buffered recorder for the bound project. Keyword options
73
+ forward to :func:`everscribe.recorder.new` (buffer_size, flush_interval,
74
+ base_url, ...)."""
75
+ return _recorder_new(self.project_id, self._api_key, **opts)
76
+
77
+ def new_minter(self, **opts: Any) -> MinterClient:
78
+ """Return a minter client for the bound project. Keyword options
79
+ forward to :class:`everscribe.minter.Client` (base_url,
80
+ request_timeout, ...)."""
81
+ return MinterClient(self.project_id, self._api_key, **opts)
82
+
83
+
84
+ def new(project_id: str, api_key: str) -> Client:
85
+ """Construct a :class:`Client`. Raises :class:`ValueError` on
86
+ empty/whitespace credentials."""
87
+ return Client(project_id, api_key)
88
+
89
+
90
+ def new_from_env() -> Client:
91
+ """Construct a :class:`Client` from environment variables. See
92
+ :meth:`Client.from_env`."""
93
+ return Client.from_env()
94
+
95
+
96
+ __all__ = [
97
+ "EVERSCRIBE_API_KEY",
98
+ "EVERSCRIBE_PROJECT_ID",
99
+ "Client",
100
+ "new",
101
+ "new_from_env",
102
+ ]
everscribe/django.py ADDED
@@ -0,0 +1,181 @@
1
+ """Django adapter, implemented as a standard Django middleware.
2
+
3
+ This adapter supplies only Django-specific transport bindings; the record
4
+ lifecycle itself (the request-scoped current event, dedupe, idempotency-key
5
+ stamping, and the final-outcome rule) lives in :mod:`everscribe.event` so
6
+ this adapter stays as thin as :mod:`everscribe.asgi` / :mod:`everscribe.flask`.
7
+
8
+ Django's middleware protocol - a class instantiated once per server process
9
+ as ``Middleware(get_response)``, whose ``__call__(request)`` wraps the rest
10
+ of the chain - is not bare WSGI: Django itself already sits between the WSGI
11
+ handler and the view, has already parsed ``request.user``/``request.session``
12
+ by the time a middleware later in ``MIDDLEWARE`` runs, and already converts
13
+ view exceptions into an ``HttpResponse`` before an outer middleware's
14
+ ``get_response(request)`` call returns (Django wraps every middleware and the
15
+ view individually via ``convert_exception_to_response``). That is why this
16
+ gets its own adapter instead of reusing a generic WSGI wrapper: identity
17
+ lives on ``request.user`` rather than anything a WSGI-level wrapper could
18
+ read, and the exception-to-response conversion already happens beneath this
19
+ middleware, so (unlike the Flask adapter) a single ``__call__`` typically
20
+ sees a real response even when a view raised.
21
+
22
+ Mount it in ``MIDDLEWARE`` after ``AuthenticationMiddleware`` (and any other
23
+ session/auth middleware), since ``resolve_actor`` typically reads identity
24
+ off ``request.user``.
25
+
26
+ Requires Django (``pip install "everscribe[django]"``).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ from collections.abc import Callable
33
+
34
+ try:
35
+ from django.conf import settings
36
+ from django.http import HttpRequest, HttpResponse
37
+ except ImportError as e: # pragma: no cover - exercised via install extras
38
+ raise ImportError(
39
+ "everscribe.django requires Django. Install it with: "
40
+ 'pip install "everscribe[django]"'
41
+ ) from e
42
+
43
+ from .event import (
44
+ Event,
45
+ current_event,
46
+ end_event,
47
+ origin_from_request,
48
+ request_scope,
49
+ result_from_http_status,
50
+ )
51
+ from .event.types import Actor, Logger, Result
52
+ from .recorder import Recorder
53
+
54
+ ActorResolver = Callable[[HttpRequest], Actor]
55
+ """Derives the :class:`Actor` for a request. Typically reads
56
+ ``request.user`` (populated by ``AuthenticationMiddleware``, which must run
57
+ before this middleware) or ``request.session``. Must not consume the request
58
+ body."""
59
+
60
+ _ANONYMOUS = Actor(type="anonymous")
61
+
62
+
63
+ class _OutcomeCapture:
64
+ """Captures the response status from the value ``get_response`` returns,
65
+ or the exception it raises, and translates either into a :class:`Result`
66
+ on demand, satisfying :class:`everscribe.event.OutcomeCapture`.
67
+
68
+ Django's own exception-to-response conversion (``convert_exception_to_
69
+ response``, applied to every middleware and the view) means
70
+ ``get_response`` ordinarily returns a real ``HttpResponse`` even when a
71
+ view raised further down the chain; the exception path here exists for
72
+ the same reason the Flask adapter's does - defense against whatever
73
+ still reaches this middleware unconverted (for example a later
74
+ middleware, or ASGI streaming responses, that Django's own conversion
75
+ doesn't cover) - so an audit event still records rather than crashing
76
+ silently.
77
+ """
78
+
79
+ def __init__(self) -> None:
80
+ self._status = 0
81
+ self._error: BaseException | None = None
82
+
83
+ def set_status(self, status: int) -> None:
84
+ self._status = status
85
+
86
+ def set_error(self, err: BaseException) -> None:
87
+ self._error = err
88
+
89
+ @property
90
+ def outcome(self) -> Result | None:
91
+ if self._error is not None:
92
+ return Result(status="error", code=500, message=self._error)
93
+ if self._status == 0:
94
+ return None
95
+ return result_from_http_status(self._status)
96
+
97
+
98
+ class EverscribeMiddleware:
99
+ """Django middleware that installs a per-request audit event and
100
+ (optionally) auto-records it once ``get_response`` returns (or raises).
101
+
102
+ Django itself instantiates middleware as ``Middleware(get_response)``,
103
+ with no seam to pass extra constructor arguments, so ``recorder``,
104
+ ``resolve_actor``, and ``logger`` can also be supplied via Django
105
+ settings instead of (or in addition to) constructor keywords - whichever
106
+ is set wins if both are given:
107
+
108
+ - ``EVERSCRIBE_RECORDER``: a :class:`~everscribe.recorder.Recorder`
109
+ instance. Omit to install the per-request event without auto-recording.
110
+ - ``EVERSCRIBE_RESOLVE_ACTOR``: an :data:`ActorResolver`. Defaults to a
111
+ resolver that returns an anonymous actor.
112
+ - ``EVERSCRIBE_LOGGER``: a :class:`~everscribe.event.Logger` for
113
+ auto-record failures. Defaults to a stdlib logger.
114
+
115
+ Add to ``MIDDLEWARE``::
116
+
117
+ MIDDLEWARE = [
118
+ ...,
119
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
120
+ "myproject.middleware.EverscribeMiddleware", # after auth
121
+ ]
122
+ """
123
+
124
+ def __init__(
125
+ self,
126
+ get_response: Callable[[HttpRequest], HttpResponse],
127
+ *,
128
+ recorder: Recorder | None = None,
129
+ resolve_actor: ActorResolver | None = None,
130
+ logger: Logger | None = None,
131
+ ) -> None:
132
+ self.get_response = get_response
133
+ self.recorder: Recorder | None = (
134
+ recorder if recorder is not None else getattr(settings, "EVERSCRIBE_RECORDER", None)
135
+ )
136
+ self.resolve_actor: ActorResolver = (
137
+ resolve_actor
138
+ or getattr(settings, "EVERSCRIBE_RESOLVE_ACTOR", None)
139
+ or (lambda req: _ANONYMOUS)
140
+ )
141
+ self.logger: Logger = (
142
+ logger
143
+ or getattr(settings, "EVERSCRIBE_LOGGER", None)
144
+ or logging.getLogger("everscribe.django")
145
+ )
146
+
147
+ def __call__(self, request: HttpRequest) -> HttpResponse:
148
+ template = Event()
149
+ template.actor = self.resolve_actor(request)
150
+ template.origin = origin_from_request(dict(request.headers), request.META.get("REMOTE_ADDR", ""))
151
+
152
+ capture = _OutcomeCapture()
153
+ with request_scope(template, capture) as current:
154
+ request.everscribe_event = current
155
+ try:
156
+ response = self.get_response(request)
157
+ except Exception as exc:
158
+ capture.set_error(exc)
159
+ # end_event applies the final outcome (including the raised-
160
+ # exception case above) and records `current` once, skipping
161
+ # it if a handler already recorded it manually.
162
+ end_event(current, self.recorder, self.logger)
163
+ raise
164
+ capture.set_status(response.status_code)
165
+ end_event(current, self.recorder, self.logger)
166
+ return response
167
+
168
+
169
+ def event_from_request(request: HttpRequest) -> Event:
170
+ """Return the per-request event stashed on ``request`` by
171
+ :class:`EverscribeMiddleware`, falling back to :func:`current_event`."""
172
+ e = getattr(request, "everscribe_event", None)
173
+ return e if isinstance(e, Event) else current_event()
174
+
175
+
176
+ __all__ = [
177
+ "ActorResolver",
178
+ "EverscribeMiddleware",
179
+ "current_event",
180
+ "event_from_request",
181
+ ]
@@ -0,0 +1,71 @@
1
+ """Event domain: the on-the-wire event model, redaction, and (added in a
2
+ later step) request-context helpers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from .context import (
8
+ current_event,
9
+ end_event,
10
+ event_scope,
11
+ new_from_context,
12
+ prepare_event,
13
+ request_scope,
14
+ result_from_http_status,
15
+ run_with_event,
16
+ )
17
+ from .event import Event
18
+ from .origin import client_ip, origin_from_request
19
+ from .redact import (
20
+ REDACTED,
21
+ DiffConfig,
22
+ DiffOption,
23
+ apply_redaction,
24
+ with_redacted_fields,
25
+ )
26
+ from .types import (
27
+ UNSET,
28
+ Actor,
29
+ Change,
30
+ Logger,
31
+ Origin,
32
+ OutcomeCapture,
33
+ Result,
34
+ Target,
35
+ )
36
+ from .wire import event_to_wire, result_to_wire
37
+
38
+ __all__ = [
39
+ # Model
40
+ "Event",
41
+ "Actor",
42
+ "Target",
43
+ "Origin",
44
+ "Result",
45
+ "Change",
46
+ "UNSET",
47
+ # Protocols
48
+ "OutcomeCapture",
49
+ "Logger",
50
+ # Request context / lifecycle
51
+ "new_from_context",
52
+ "current_event",
53
+ "run_with_event",
54
+ "event_scope",
55
+ "request_scope",
56
+ "prepare_event",
57
+ "end_event",
58
+ "result_from_http_status",
59
+ # Origin
60
+ "origin_from_request",
61
+ "client_ip",
62
+ # Redaction
63
+ "with_redacted_fields",
64
+ "apply_redaction",
65
+ "DiffOption",
66
+ "DiffConfig",
67
+ "REDACTED",
68
+ # Wire
69
+ "event_to_wire",
70
+ "result_to_wire",
71
+ ]