fingerly 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.
fingerly/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """Server-side Fingerly SDK: event reads and webhook verification.
2
+
3
+ Two clients share one API: :class:`Fingerly` for synchronous code such as
4
+ Django and Flask, and :class:`AsyncFingerly` for asyncio code such as FastAPI
5
+ and Starlette.
6
+
7
+ Example:
8
+ >>> from fingerly import Fingerly
9
+ >>> fingerly = Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"])
10
+ >>> event = fingerly.events.get("01a0a84b-e6a2-7c09-9f51-0b3d7a26c8e4")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from ._client import AsyncEvents, AsyncFingerly, Events, Fingerly
16
+ from ._errors import (
17
+ FingerlyAPIError,
18
+ FingerlyConnectionError,
19
+ FingerlyError,
20
+ FingerlyEventPendingError,
21
+ FingerlyTimeoutError,
22
+ )
23
+ from ._models import Event, EventPage, EventTrigger
24
+ from ._proxy import FingerlyProxy, ProxyRequest
25
+ from ._version import __version__
26
+ from ._webhooks import verify_webhook
27
+
28
+ __all__ = [
29
+ "AsyncEvents",
30
+ "AsyncFingerly",
31
+ "Event",
32
+ "EventPage",
33
+ "EventTrigger",
34
+ "Events",
35
+ "Fingerly",
36
+ "FingerlyAPIError",
37
+ "FingerlyConnectionError",
38
+ "FingerlyError",
39
+ "FingerlyEventPendingError",
40
+ "FingerlyProxy",
41
+ "FingerlyTimeoutError",
42
+ "ProxyRequest",
43
+ "__version__",
44
+ "verify_webhook",
45
+ ]
fingerly/_asgi.py ADDED
@@ -0,0 +1,220 @@
1
+ """The ASGI adapter: read a request, write a response.
2
+
3
+ Every decision belongs to :class:`~fingerly._proxy.FingerlyProxy`. This module
4
+ only translates between ASGI messages and the proxy's own request and response
5
+ types, so the two standards cannot drift apart.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ from collections.abc import Awaitable, MutableMapping
13
+ from typing import TYPE_CHECKING, Any, Callable, Optional
14
+
15
+ from ._proxy import BodyTooLargeError, Headers, ProxyRequest, ProxyResponse
16
+
17
+ if TYPE_CHECKING:
18
+ from ._proxy import FingerlyProxy
19
+
20
+ Scope = MutableMapping[str, Any]
21
+ Receive = Callable[[], Awaitable[MutableMapping[str, Any]]]
22
+ Send = Callable[[MutableMapping[str, Any]], Awaitable[None]]
23
+
24
+
25
+ class ASGIProxy:
26
+ """ASGI application serving the browser SDK's same-origin requests.
27
+
28
+ Reached as ``proxy.asgi``. Route the dashboard-issued path to it under
29
+ FastAPI, Starlette, Django 3+, Quart, Litestar or any other ASGI
30
+ framework::
31
+
32
+ app.add_route("/k7x2qm4a", proxy.asgi)
33
+ """
34
+
35
+ def __init__(self, proxy: FingerlyProxy) -> None:
36
+ self._proxy = proxy
37
+
38
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
39
+ """Serves one ASGI connection."""
40
+ kind = scope.get("type")
41
+ if kind == "lifespan":
42
+ await _acknowledge_lifespan(receive, send)
43
+ return
44
+ if kind != "http":
45
+ # A websocket cannot be a browser SDK call; refuse without opening it.
46
+ if kind == "websocket":
47
+ await send({"type": "websocket.close", "code": 1000})
48
+ return
49
+
50
+ request = _request_from(scope)
51
+ screening = self._proxy.screen(request)
52
+ if screening.refusal is not None:
53
+ await _send(send, screening.refusal)
54
+ return
55
+
56
+ try:
57
+ body = await _read_body(receive, self._proxy.max_body_bytes)
58
+ except BodyTooLargeError:
59
+ await _send(send, self._proxy.too_large())
60
+ return
61
+ if body is None:
62
+ await self._answer_nobody(send)
63
+ return
64
+
65
+ response = await _forward_unless_gone(
66
+ self._proxy.aforward(screening, request, body), receive
67
+ )
68
+ if response is None:
69
+ await self._answer_nobody(send)
70
+ else:
71
+ await _send(send, response)
72
+
73
+ async def _answer_nobody(self, send: Send) -> None:
74
+ """Finishes a request whose visitor has already gone.
75
+
76
+ Nobody reads this, but an app that ends without a response is an error
77
+ to Starlette's ``BaseHTTPMiddleware``, which would log a traceback for
78
+ every tab closed during an identification.
79
+ """
80
+ # ASGI 2.4 lets a server refuse a send on a closed connection.
81
+ with contextlib.suppress(OSError):
82
+ await _send(send, self._proxy.client_gone())
83
+
84
+
85
+ async def _forward_unless_gone(
86
+ forwarding: Awaitable[ProxyResponse], receive: Receive
87
+ ) -> Optional[ProxyResponse]:
88
+ """Forwards, but gives up the moment the visitor disconnects.
89
+
90
+ Without this, a visitor who left would still hold an upstream connection
91
+ until the API answered, so connections opened and dropped in a loop would
92
+ pile up upstream work nobody reads.
93
+
94
+ Returns:
95
+ The response, or None when the visitor went away first.
96
+ """
97
+ forward = asyncio.ensure_future(forwarding)
98
+ gone = asyncio.ensure_future(_disconnected(receive))
99
+ try:
100
+ await asyncio.wait({forward, gone}, return_when=asyncio.FIRST_COMPLETED)
101
+ finally:
102
+ if not forward.done() and not gone.done():
103
+ # Cancelled from outside: take both down with us.
104
+ forward.cancel()
105
+ gone.cancel()
106
+ await asyncio.gather(gone, return_exceptions=True)
107
+ if forward.done():
108
+ return forward.result()
109
+ forward.cancel()
110
+ await asyncio.gather(forward, return_exceptions=True)
111
+ return None
112
+
113
+
114
+ async def _disconnected(receive: Receive) -> None:
115
+ """Returns when the server reports the client gone.
116
+
117
+ Once the body has been read, ``receive`` blocks until the client
118
+ disconnects. A server that answers it with anything else is not reporting
119
+ a disconnect, so this then waits until cancelled.
120
+ """
121
+ message = await receive()
122
+ if message.get("type") != "http.disconnect":
123
+ await asyncio.Event().wait()
124
+
125
+
126
+ def _request_from(scope: Scope) -> ProxyRequest:
127
+ """Builds the neutral request from an ASGI scope."""
128
+ client = scope.get("client")
129
+ return ProxyRequest(
130
+ method=scope.get("method", ""),
131
+ path=_app_path(scope),
132
+ headers=Headers(
133
+ (name.decode("latin-1"), value.decode("latin-1"))
134
+ for name, value in scope.get("headers", ())
135
+ ),
136
+ peer_host=client[0] if client else None,
137
+ mount=str(scope.get("root_path", "")),
138
+ raw_path=_raw_path(scope),
139
+ )
140
+
141
+
142
+ def _raw_path(scope: Scope) -> Optional[str]:
143
+ """The path as the client sent it, when the server reports it."""
144
+ raw = scope.get("raw_path")
145
+ if not isinstance(raw, (bytes, bytearray)):
146
+ return None
147
+ return bytes(raw).decode("latin-1").split("?", 1)[0]
148
+
149
+
150
+ def _app_path(scope: Scope) -> str:
151
+ """Recovers the request path as the mounted application sees it.
152
+
153
+ ASGI servers disagree about ``path`` under a mount. Starlette 0.47 and
154
+ newer leave it whole and report the prefix separately in ``root_path``,
155
+ while older versions and some routers strip the prefix off it. Both are
156
+ permitted, so detect which one this is instead of assuming, and answer
157
+ with the application-relative path either way.
158
+
159
+ Keeping the mount in would break a deployment under a sub-URI, where
160
+ ``root_path`` also carries a prefix that the configured ``path`` knows
161
+ nothing about. This is the same rule Rack's ``PATH_INFO`` and ASP.NET
162
+ Core's ``Request.Path`` already follow.
163
+ """
164
+ path = str(scope.get("path", ""))
165
+ root_path = str(scope.get("root_path", ""))
166
+ if not root_path:
167
+ return path
168
+ if path == root_path:
169
+ return ""
170
+ return path[len(root_path) :] if path.startswith(root_path + "/") else path
171
+
172
+
173
+ async def _read_body(receive: Receive, max_bytes: int) -> Optional[bytes]:
174
+ """Reads the request body without buffering more than the limit.
175
+
176
+ Returns:
177
+ The bytes, or None when the client disconnected first.
178
+
179
+ Raises:
180
+ BodyTooLargeError: As soon as the limit is passed, before the rest arrives.
181
+ """
182
+ chunks: list[bytes] = []
183
+ size = 0
184
+ while True:
185
+ message = await receive()
186
+ if message["type"] == "http.disconnect":
187
+ return None
188
+ chunk = message.get("body", b"")
189
+ size += len(chunk)
190
+ if size > max_bytes:
191
+ raise BodyTooLargeError
192
+ chunks.append(chunk)
193
+ if not message.get("more_body", False):
194
+ return b"".join(chunks)
195
+
196
+
197
+ async def _send(send: Send, response: ProxyResponse) -> None:
198
+ """Writes one response as the two ASGI messages it is made of."""
199
+ await send(
200
+ {
201
+ "type": "http.response.start",
202
+ "status": response.status,
203
+ "headers": [
204
+ (name.encode("latin-1"), value.encode("latin-1"))
205
+ for name, value in response.headers
206
+ ],
207
+ }
208
+ )
209
+ await send({"type": "http.response.body", "body": response.body})
210
+
211
+
212
+ async def _acknowledge_lifespan(receive: Receive, send: Send) -> None:
213
+ """Answers startup and shutdown, so the app is safe to mount as a root."""
214
+ while True:
215
+ message = await receive()
216
+ if message["type"] == "lifespan.startup":
217
+ await send({"type": "lifespan.startup.complete"})
218
+ elif message["type"] == "lifespan.shutdown":
219
+ await send({"type": "lifespan.shutdown.complete"})
220
+ return
fingerly/_client.py ADDED
@@ -0,0 +1,368 @@
1
+ """The synchronous and asyncio clients, and the event reads they expose."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import threading
7
+ import warnings
8
+ from collections.abc import Mapping
9
+ from datetime import datetime
10
+ from types import TracebackType
11
+ from typing import Any, Optional, TypeVar, Union
12
+
13
+ import httpx
14
+
15
+ from ._config import (
16
+ DEFAULT_ENDPOINT,
17
+ Secret,
18
+ no_cookies,
19
+ normalize_api_version,
20
+ normalize_endpoint,
21
+ parse_key,
22
+ positive_seconds,
23
+ )
24
+ from ._errors import FingerlyAPIError, FingerlyConnectionError, FingerlyTimeoutError
25
+ from ._http import MAX_RESPONSE_BYTES, AsyncPool, Reply, ResponseTooLargeError, SyncPool
26
+ from ._models import Event, EventPage, parse_event, parse_page
27
+ from ._transport import (
28
+ KeyAuth,
29
+ api_error,
30
+ decode_json,
31
+ event_path,
32
+ headers_for,
33
+ list_params,
34
+ )
35
+
36
+ DEFAULT_TIMEOUT = 10.0
37
+
38
+ _Self = TypeVar("_Self", bound="_BaseFingerly")
39
+
40
+
41
+ class _BaseFingerly:
42
+ """Configuration shared by :class:`Fingerly` and :class:`AsyncFingerly`."""
43
+
44
+ def __init__(
45
+ self,
46
+ secret_key: str,
47
+ endpoint: Optional[str] = None,
48
+ timeout: float = DEFAULT_TIMEOUT,
49
+ api_version: str = "v1",
50
+ ) -> None:
51
+ """Validates every option before any client is built.
52
+
53
+ Args:
54
+ secret_key: A server-only key, ``fly_sk_{environment}_…``. Its
55
+ prefix names the environment read.
56
+ endpoint: Overrides the Fingerly API origin, for a local API.
57
+ timeout: One deadline, in seconds, over the whole request:
58
+ connecting, sending, the response headers and the body.
59
+ api_version: API route version; the server must support it.
60
+
61
+ Raises:
62
+ TypeError: If any option is invalid. Nothing is sent until then.
63
+ """
64
+ # Validate the key even when an endpoint is given, so a public, proxy
65
+ # or management key can never reach an API that would accept it.
66
+ parse_key(secret_key, "sk")
67
+ origin = DEFAULT_ENDPOINT if endpoint is None else normalize_endpoint(endpoint)
68
+ self._secret_key = Secret(secret_key)
69
+ self._base_url = f"{origin}/api/{normalize_api_version(api_version)}"
70
+ self._timeout = positive_seconds(timeout, "timeout")
71
+
72
+ def __repr__(self) -> str:
73
+ return (
74
+ f"{type(self).__name__}(endpoint={self._base_url!r}, "
75
+ f"secret_key={self._secret_key!r})"
76
+ )
77
+
78
+ def _interpret(self, response: Reply) -> Mapping[str, Any]:
79
+ """Turns a response into a decoded body or the matching error.
80
+
81
+ Raises:
82
+ FingerlyAPIError: On any non-2xx response, redirects included:
83
+ requests never follow one, so a 3xx is not an answer.
84
+ """
85
+ if not 200 <= response.status < 300:
86
+ raise api_error(
87
+ response.status,
88
+ response.body,
89
+ self._secret_key,
90
+ response.headers.get("retry-after"),
91
+ )
92
+ return decode_json(response.body, response.status)
93
+
94
+
95
+ class Events:
96
+ """Synchronous event reads. Reached as ``fingerly.events``."""
97
+
98
+ def __init__(self, client: Fingerly) -> None:
99
+ self._client = client
100
+
101
+ def get(self, request_id: str) -> Event:
102
+ """Fetches one event by request id.
103
+
104
+ Args:
105
+ request_id: The ``request_id`` returned by identification.
106
+
107
+ Raises:
108
+ FingerlyEventPendingError: ``404 event_pending`` when the
109
+ request id was accepted but the event is not recorded yet;
110
+ ``retry_after`` says when to try again. Not retried here.
111
+ FingerlyAPIError: ``404 event_not_found`` when the request id was
112
+ never issued or the event is past retention, and any other
113
+ non-2xx answer.
114
+ FingerlyTimeoutError: If the request exceeds the client timeout.
115
+ """
116
+ return parse_event(self._client._request(event_path(request_id)))
117
+
118
+ def list(
119
+ self,
120
+ from_: Union[datetime, str, None] = None,
121
+ to: Union[datetime, str, None] = None,
122
+ page: int = 1,
123
+ limit: int = 10,
124
+ visitor: Optional[str] = None,
125
+ level: Optional[str] = None,
126
+ ) -> EventPage:
127
+ """Lists events within the retention window, newest first.
128
+
129
+ Args:
130
+ from_: Start of the window. Defaults to 24 hours ago.
131
+ to: End of the window. Defaults to now.
132
+ page: 1-based page number.
133
+ limit: Rows per page, at most 200.
134
+ visitor: Restrict to one exact visitor id.
135
+ level: Restrict to ``low``, ``medium`` or ``high``.
136
+
137
+ Raises:
138
+ TypeError: If a bound, page or limit is unusable.
139
+ FingerlyAPIError: On any non-2xx response.
140
+ """
141
+ params = list_params(from_, to, page, limit, visitor, level)
142
+ return parse_page(self._client._request("/events", params))
143
+
144
+
145
+ class AsyncEvents:
146
+ """Asyncio event reads. Reached as ``fingerly.events``."""
147
+
148
+ def __init__(self, client: AsyncFingerly) -> None:
149
+ self._client = client
150
+
151
+ async def get(self, request_id: str) -> Event:
152
+ """Fetches one event by request id.
153
+
154
+ See :meth:`Events.get`; this coroutine takes the same arguments.
155
+ """
156
+ return parse_event(await self._client._request(event_path(request_id)))
157
+
158
+ async def list(
159
+ self,
160
+ from_: Union[datetime, str, None] = None,
161
+ to: Union[datetime, str, None] = None,
162
+ page: int = 1,
163
+ limit: int = 10,
164
+ visitor: Optional[str] = None,
165
+ level: Optional[str] = None,
166
+ ) -> EventPage:
167
+ """Lists events within the retention window, newest first.
168
+
169
+ See :meth:`Events.list`; this coroutine takes the same arguments.
170
+ """
171
+ params = list_params(from_, to, page, limit, visitor, level)
172
+ return parse_page(await self._client._request("/events", params))
173
+
174
+
175
+ class Fingerly(_BaseFingerly):
176
+ """The Fingerly server SDK for synchronous code.
177
+
178
+ Use it in Django, Flask, scripts and Celery tasks. Build one client and
179
+ reuse it: each holds a pooled connection. Secret keys belong in
180
+ server-side code only.
181
+
182
+ Example:
183
+ >>> with Fingerly(secret_key=os.environ["FINGERLY_SECRET_KEY"]) as fingerly:
184
+ ... page = fingerly.events.list(level="high", limit=50)
185
+ """
186
+
187
+ def __init__(
188
+ self,
189
+ secret_key: str,
190
+ endpoint: Optional[str] = None,
191
+ timeout: float = DEFAULT_TIMEOUT,
192
+ api_version: str = "v1",
193
+ transport: Optional[httpx.BaseTransport] = None,
194
+ limits: Optional[httpx.Limits] = None,
195
+ ) -> None:
196
+ """See :class:`_BaseFingerly` for the shared arguments.
197
+
198
+ Args:
199
+ transport: Replacement httpx transport, for tests or a host with
200
+ its own HTTP stack. It is used as given, as one pool.
201
+ limits: Connections this client may hold across its pools. The
202
+ default lets 256 concurrent reads reuse connections.
203
+ """
204
+ super().__init__(secret_key, endpoint, timeout, api_version)
205
+
206
+ def build(pool_transport: Optional[httpx.BaseTransport]) -> httpx.Client:
207
+ return httpx.Client(
208
+ base_url=self._base_url,
209
+ headers=headers_for(),
210
+ auth=KeyAuth(self._secret_key),
211
+ timeout=self._timeout,
212
+ follow_redirects=False,
213
+ cookies=no_cookies(),
214
+ transport=pool_transport,
215
+ )
216
+
217
+ self._pool = SyncPool(build, self._timeout, limits, transport)
218
+ self.events = Events(self)
219
+
220
+ def close(self) -> None:
221
+ """Closes the pooled connections. Safe to call more than once."""
222
+ self._pool.close()
223
+
224
+ def __enter__(self: _Self) -> _Self:
225
+ return self
226
+
227
+ def __exit__(
228
+ self,
229
+ exc_type: Optional[type[BaseException]],
230
+ exc: Optional[BaseException],
231
+ traceback: Optional[TracebackType],
232
+ ) -> None:
233
+ self.close()
234
+
235
+ def _request(
236
+ self, path: str, params: Optional[Mapping[str, str]] = None
237
+ ) -> Mapping[str, Any]:
238
+ """Performs one GET and decodes its body. Never retries.
239
+
240
+ Raises:
241
+ FingerlyTimeoutError: If the request exceeds the client timeout.
242
+ FingerlyConnectionError: If the API could not be reached.
243
+ FingerlyAPIError: On any non-2xx response.
244
+ """
245
+ _warn_if_on_an_event_loop()
246
+ try:
247
+ reply = self._pool.request("GET", path, params=params)
248
+ except httpx.TimeoutException as error:
249
+ raise FingerlyTimeoutError() from error
250
+ except httpx.HTTPError as error:
251
+ raise FingerlyConnectionError("Could not reach the Fingerly API") from error
252
+ except ResponseTooLargeError as error:
253
+ raise _too_large(error) from None
254
+ return self._interpret(reply)
255
+
256
+
257
+ class AsyncFingerly(_BaseFingerly):
258
+ """The Fingerly server SDK for asyncio code.
259
+
260
+ Use it in FastAPI, Starlette, aiohttp and Quart. Build one client per
261
+ event loop and reuse it.
262
+
263
+ Example:
264
+ >>> async with AsyncFingerly(secret_key=key) as fingerly:
265
+ ... event = await fingerly.events.get(request_id)
266
+ """
267
+
268
+ def __init__(
269
+ self,
270
+ secret_key: str,
271
+ endpoint: Optional[str] = None,
272
+ timeout: float = DEFAULT_TIMEOUT,
273
+ api_version: str = "v1",
274
+ transport: Optional[httpx.AsyncBaseTransport] = None,
275
+ limits: Optional[httpx.Limits] = None,
276
+ ) -> None:
277
+ """See :class:`_BaseFingerly` for the shared arguments.
278
+
279
+ Args:
280
+ transport: Replacement httpx transport, for tests or a host with
281
+ its own HTTP stack. It is used as given, as one pool.
282
+ limits: Connections this client may hold across its pools. The
283
+ default lets 256 concurrent reads reuse connections.
284
+ """
285
+ super().__init__(secret_key, endpoint, timeout, api_version)
286
+
287
+ def build(
288
+ pool_transport: Optional[httpx.AsyncBaseTransport],
289
+ ) -> httpx.AsyncClient:
290
+ return httpx.AsyncClient(
291
+ base_url=self._base_url,
292
+ headers=headers_for(),
293
+ auth=KeyAuth(self._secret_key),
294
+ timeout=self._timeout,
295
+ follow_redirects=False,
296
+ cookies=no_cookies(),
297
+ transport=pool_transport,
298
+ )
299
+
300
+ self._pool = AsyncPool(build, self._timeout, limits, transport)
301
+ self.events = AsyncEvents(self)
302
+
303
+ async def aclose(self) -> None:
304
+ """Closes the pooled connections. Safe to call more than once."""
305
+ await self._pool.aclose()
306
+
307
+ async def __aenter__(self: _Self) -> _Self:
308
+ return self
309
+
310
+ async def __aexit__(
311
+ self,
312
+ exc_type: Optional[type[BaseException]],
313
+ exc: Optional[BaseException],
314
+ traceback: Optional[TracebackType],
315
+ ) -> None:
316
+ await self.aclose()
317
+
318
+ async def _request(
319
+ self, path: str, params: Optional[Mapping[str, str]] = None
320
+ ) -> Mapping[str, Any]:
321
+ """Performs one GET and decodes its body. Never retries.
322
+
323
+ Raises:
324
+ FingerlyTimeoutError: If the request exceeds the client timeout.
325
+ FingerlyConnectionError: If the API could not be reached.
326
+ FingerlyAPIError: On any non-2xx response.
327
+ """
328
+ try:
329
+ reply = await self._pool.request("GET", path, params=params)
330
+ except httpx.TimeoutException as error:
331
+ raise FingerlyTimeoutError() from error
332
+ except httpx.HTTPError as error:
333
+ raise FingerlyConnectionError("Could not reach the Fingerly API") from error
334
+ except ResponseTooLargeError as error:
335
+ raise _too_large(error) from None
336
+ return self._interpret(reply)
337
+
338
+
339
+ _warned_about_event_loop = threading.Event()
340
+
341
+
342
+ def _warn_if_on_an_event_loop() -> None:
343
+ """Warns, once per process, when the sync client blocks an event loop.
344
+
345
+ Called from ``async def`` code, every read stops the loop, and with it
346
+ every other request on that worker, until the API answers.
347
+ """
348
+ if _warned_about_event_loop.is_set():
349
+ return
350
+ try:
351
+ asyncio.get_running_loop()
352
+ except RuntimeError:
353
+ return
354
+ _warned_about_event_loop.set()
355
+ warnings.warn(
356
+ "Fingerly is blocking a running event loop; use AsyncFingerly in async code",
357
+ RuntimeWarning,
358
+ stacklevel=4,
359
+ )
360
+
361
+
362
+ def _too_large(error: ResponseTooLargeError) -> FingerlyAPIError:
363
+ """The error for a response body over the cap; the body is never read."""
364
+ return FingerlyAPIError(
365
+ f"Fingerly API response is larger than {MAX_RESPONSE_BYTES // 1048576} MiB",
366
+ error.status,
367
+ code="response_too_large",
368
+ )