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 +45 -0
- fingerly/_asgi.py +220 -0
- fingerly/_client.py +368 -0
- fingerly/_config.py +178 -0
- fingerly/_errors.py +82 -0
- fingerly/_http.py +452 -0
- fingerly/_models.py +215 -0
- fingerly/_proxy.py +659 -0
- fingerly/_transport.py +222 -0
- fingerly/_version.py +7 -0
- fingerly/_webhooks.py +123 -0
- fingerly/_wsgi.py +144 -0
- fingerly/py.typed +0 -0
- fingerly-0.1.0.dist-info/METADATA +566 -0
- fingerly-0.1.0.dist-info/RECORD +17 -0
- fingerly-0.1.0.dist-info/WHEEL +4 -0
- fingerly-0.1.0.dist-info/licenses/LICENSE +202 -0
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
|
+
)
|