getricky 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.
getricky/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """The official Python SDK for the Ricky REST API: https://getricky.ai/docs/api"""
2
+
3
+ from ._client import DEFAULT_BASE_URL, AsyncPaginator, AsyncRicky, Paginator, Ricky
4
+ from ._errors import RickyConnectionError, RickyError, WebhookVerificationError
5
+ from ._page import Page, Resource
6
+ from ._version import __version__
7
+ from ._webhooks import verify_webhook
8
+
9
+ __all__ = [
10
+ "DEFAULT_BASE_URL",
11
+ "AsyncPaginator",
12
+ "AsyncRicky",
13
+ "Page",
14
+ "Paginator",
15
+ "Resource",
16
+ "Ricky",
17
+ "RickyConnectionError",
18
+ "RickyError",
19
+ "WebhookVerificationError",
20
+ "__version__",
21
+ "verify_webhook",
22
+ ]
getricky/_client.py ADDED
@@ -0,0 +1,294 @@
1
+ import asyncio
2
+ import email.utils
3
+ import os
4
+ import random
5
+ import time
6
+ import uuid
7
+ from typing import (
8
+ Any,
9
+ AsyncIterator,
10
+ Awaitable,
11
+ Callable,
12
+ Dict,
13
+ Generic,
14
+ Iterator,
15
+ Mapping,
16
+ Optional,
17
+ Tuple,
18
+ TypeVar,
19
+ )
20
+ from urllib.parse import quote
21
+
22
+ import httpx
23
+ from typing_extensions import ParamSpec
24
+
25
+ from ._errors import RickyConnectionError, RickyError
26
+ from ._operations import AsyncOperations, SyncOperations
27
+ from ._page import Page
28
+ from ._version import __version__
29
+
30
+ DEFAULT_BASE_URL = "https://getricky.ai/api/v1"
31
+ DEFAULT_TIMEOUT = 120.0
32
+ DEFAULT_MAX_RETRIES = 2
33
+ RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
34
+ MAX_RETRY_AFTER_SECONDS = 60.0
35
+
36
+ T = TypeVar("T")
37
+ P = ParamSpec("P")
38
+
39
+
40
+ class _Base:
41
+ """What the sync and async clients share: building requests and deciding retries."""
42
+
43
+ def __init__(self, api_key: Optional[str], base_url: str, max_retries: int) -> None:
44
+ key = api_key if api_key is not None else os.environ.get("RICKY_API_KEY")
45
+ if not key:
46
+ raise ValueError("Pass an API key or set RICKY_API_KEY.")
47
+ self._headers = {
48
+ "Authorization": f"Bearer {key}",
49
+ "User-Agent": f"getricky-python/{__version__}",
50
+ }
51
+ self._base_url = base_url.rstrip("/")
52
+ self._max_retries = max_retries
53
+
54
+ def _build(
55
+ self,
56
+ method: str,
57
+ path: str,
58
+ path_parameters: Optional[Mapping[str, str]] = None,
59
+ query: Optional[Mapping[str, Any]] = None,
60
+ body: Any = None,
61
+ idempotent: bool = False,
62
+ idempotency_key: Optional[str] = None,
63
+ ) -> httpx.Request:
64
+ url = self._base_url + path.format(
65
+ **{name: quote(str(value), safe="") for name, value in (path_parameters or {}).items()}
66
+ )
67
+ headers = dict(self._headers)
68
+ if idempotent:
69
+ headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
70
+ params = {name: _query_value(value) for name, value in (query or {}).items() if value is not None}
71
+ return httpx.Request(method, url, headers=headers, params=params, json=body)
72
+
73
+ def _attempts(self, request: httpx.Request) -> int:
74
+ retryable = request.method in ("GET", "HEAD") or "Idempotency-Key" in request.headers
75
+ return 1 + self._max_retries if retryable else 1
76
+
77
+ @staticmethod
78
+ def _parse(response: httpx.Response) -> Any:
79
+ if response.is_success:
80
+ return response.json() if response.content else {}
81
+ raise RickyError.from_response(response)
82
+
83
+
84
+ class Ricky(_Base, SyncOperations):
85
+ """A client for one organization's API key.
86
+
87
+ Every operation is a method named after its ``operationId`` in snake case.
88
+ Path parameters come first, then the request body (a dict), then query
89
+ parameters as keywords. Methods return the response body, ``{"data": ...}``
90
+ or ``{"data": [...], "next_cursor": ...}``, and raise ``RickyError`` or
91
+ ``RickyConnectionError``.
92
+
93
+ Writes that accept an ``Idempotency-Key`` get a random one unless you pass
94
+ ``idempotency_key=``. Reads and keyed writes are retried on connection
95
+ errors, 429 and 5xx, honoring ``Retry-After``.
96
+
97
+ Args:
98
+ api_key: An organization API key (``rky_live_…``); defaults to the
99
+ ``RICKY_API_KEY`` environment variable. Use it only from a server.
100
+ base_url: Defaults to ``https://getricky.ai/api/v1``.
101
+ max_retries: Retries after the first attempt. Defaults to 2.
102
+ timeout: Seconds per attempt. Defaults to 120: a file send downloads
103
+ the file before it answers.
104
+ http_client: An ``httpx.Client`` to send requests with.
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ api_key: Optional[str] = None,
110
+ *,
111
+ base_url: str = DEFAULT_BASE_URL,
112
+ max_retries: int = DEFAULT_MAX_RETRIES,
113
+ timeout: float = DEFAULT_TIMEOUT,
114
+ http_client: Optional[httpx.Client] = None,
115
+ ) -> None:
116
+ super().__init__(api_key, base_url, max_retries)
117
+ self._owns_http = http_client is None
118
+ self._http = http_client if http_client is not None else httpx.Client(timeout=timeout)
119
+
120
+ def _request(self, method: str, path: str, **options: Any) -> Any:
121
+ request = self._build(method, path, **options)
122
+ attempts = self._attempts(request)
123
+ for attempt in range(attempts):
124
+ last = attempt == attempts - 1
125
+ try:
126
+ response = self._http.send(request)
127
+ except httpx.TransportError as error:
128
+ if last:
129
+ raise RickyConnectionError(str(error) or type(error).__name__) from error
130
+ time.sleep(_backoff(attempt))
131
+ continue
132
+ if last or response.status_code not in RETRYABLE_STATUSES:
133
+ return self._parse(response)
134
+ response.close()
135
+ time.sleep(_retry_delay(response, attempt))
136
+ raise AssertionError("unreachable")
137
+
138
+ def paginate(self, list_method: Callable[P, "Page[T]"], *args: P.args, **kwargs: P.kwargs) -> "Paginator[T]":
139
+ """Walks a list method page by page, passing each ``next_cursor`` back as ``cursor``.
140
+
141
+ Iterating yields items; ``.pages()`` yields whole pages::
142
+
143
+ for contact in ricky.paginate(ricky.list_contacts, tag_id=tag_id):
144
+ print(contact["id"])
145
+ """
146
+ return Paginator(list_method, args, kwargs)
147
+
148
+ def close(self) -> None:
149
+ """Closes the connections this client opened."""
150
+ if self._owns_http:
151
+ self._http.close()
152
+
153
+ def __enter__(self) -> "Ricky":
154
+ return self
155
+
156
+ def __exit__(self, *exc_info: object) -> None:
157
+ self.close()
158
+
159
+
160
+ class AsyncRicky(_Base, AsyncOperations):
161
+ """The asyncio version of ``Ricky``: the same methods, awaitable.
162
+
163
+ Args:
164
+ api_key: An organization API key (``rky_live_…``); defaults to the
165
+ ``RICKY_API_KEY`` environment variable. Use it only from a server.
166
+ base_url: Defaults to ``https://getricky.ai/api/v1``.
167
+ max_retries: Retries after the first attempt. Defaults to 2.
168
+ timeout: Seconds per attempt. Defaults to 120.
169
+ http_client: An ``httpx.AsyncClient`` to send requests with.
170
+ """
171
+
172
+ def __init__(
173
+ self,
174
+ api_key: Optional[str] = None,
175
+ *,
176
+ base_url: str = DEFAULT_BASE_URL,
177
+ max_retries: int = DEFAULT_MAX_RETRIES,
178
+ timeout: float = DEFAULT_TIMEOUT,
179
+ http_client: Optional[httpx.AsyncClient] = None,
180
+ ) -> None:
181
+ super().__init__(api_key, base_url, max_retries)
182
+ self._owns_http = http_client is None
183
+ self._http = http_client if http_client is not None else httpx.AsyncClient(timeout=timeout)
184
+
185
+ async def _request(self, method: str, path: str, **options: Any) -> Any:
186
+ request = self._build(method, path, **options)
187
+ attempts = self._attempts(request)
188
+ for attempt in range(attempts):
189
+ last = attempt == attempts - 1
190
+ try:
191
+ response = await self._http.send(request)
192
+ except httpx.TransportError as error:
193
+ if last:
194
+ raise RickyConnectionError(str(error) or type(error).__name__) from error
195
+ await asyncio.sleep(_backoff(attempt))
196
+ continue
197
+ if last or response.status_code not in RETRYABLE_STATUSES:
198
+ return self._parse(response)
199
+ await response.aclose()
200
+ await asyncio.sleep(_retry_delay(response, attempt))
201
+ raise AssertionError("unreachable")
202
+
203
+ def paginate(
204
+ self, list_method: Callable[P, Awaitable["Page[T]"]], *args: P.args, **kwargs: P.kwargs
205
+ ) -> "AsyncPaginator[T]":
206
+ """Walks a list method page by page, passing each ``next_cursor`` back as ``cursor``.
207
+
208
+ ``async for`` yields items; ``.pages()`` yields whole pages::
209
+
210
+ async for contact in ricky.paginate(ricky.list_contacts, tag_id=tag_id):
211
+ print(contact["id"])
212
+ """
213
+ return AsyncPaginator(list_method, args, kwargs)
214
+
215
+ async def aclose(self) -> None:
216
+ """Closes the connections this client opened."""
217
+ if self._owns_http:
218
+ await self._http.aclose()
219
+
220
+ async def __aenter__(self) -> "AsyncRicky":
221
+ return self
222
+
223
+ async def __aexit__(self, *exc_info: object) -> None:
224
+ await self.aclose()
225
+
226
+
227
+ class Paginator(Generic[T]):
228
+ """Items of every page, in order. ``pages()`` yields whole pages instead."""
229
+
230
+ def __init__(self, list_method: Callable[..., "Page[T]"], args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> None:
231
+ self._list = list_method
232
+ self._args = args
233
+ self._kwargs = kwargs
234
+
235
+ def pages(self) -> Iterator["Page[T]"]:
236
+ cursor = self._kwargs.get("cursor")
237
+ while True:
238
+ page = self._list(*self._args, **{**self._kwargs, "cursor": cursor})
239
+ yield page
240
+ cursor = page["next_cursor"]
241
+ if cursor is None:
242
+ return
243
+
244
+ def __iter__(self) -> Iterator[T]:
245
+ for page in self.pages():
246
+ yield from page["data"]
247
+
248
+
249
+ class AsyncPaginator(Generic[T]):
250
+ """Items of every page, in order. ``pages()`` yields whole pages instead."""
251
+
252
+ def __init__(
253
+ self, list_method: Callable[..., Awaitable["Page[T]"]], args: Tuple[Any, ...], kwargs: Dict[str, Any]
254
+ ) -> None:
255
+ self._list = list_method
256
+ self._args = args
257
+ self._kwargs = kwargs
258
+
259
+ async def pages(self) -> AsyncIterator["Page[T]"]:
260
+ cursor = self._kwargs.get("cursor")
261
+ while True:
262
+ page = await self._list(*self._args, **{**self._kwargs, "cursor": cursor})
263
+ yield page
264
+ cursor = page["next_cursor"]
265
+ if cursor is None:
266
+ return
267
+
268
+ async def __aiter__(self) -> AsyncIterator[T]:
269
+ async for page in self.pages():
270
+ for item in page["data"]:
271
+ yield item
272
+
273
+
274
+ def _query_value(value: Any) -> Any:
275
+ return ("true" if value else "false") if isinstance(value, bool) else value
276
+
277
+
278
+ def _retry_delay(response: httpx.Response, attempt: int) -> float:
279
+ """``Retry-After`` (seconds or an HTTP date), capped; exponential backoff without one."""
280
+ header = response.headers.get("retry-after", "").strip()
281
+ if header.isdigit():
282
+ return min(float(header), MAX_RETRY_AFTER_SECONDS)
283
+ if header:
284
+ try:
285
+ moment = email.utils.parsedate_to_datetime(header)
286
+ return min(max(moment.timestamp() - time.time(), 0.0), MAX_RETRY_AFTER_SECONDS)
287
+ except (AttributeError, TypeError, ValueError): # 3.9 returns None for a bad date
288
+ pass
289
+ return _backoff(attempt)
290
+
291
+
292
+ def _backoff(attempt: int) -> float:
293
+ """0.5 s, 1 s, 2 s … up to 8 s, with up to 25 % jitter below."""
294
+ return min(0.5 * 2.0**attempt, 8.0) * (1 - random.random() * 0.25)
getricky/_errors.py ADDED
@@ -0,0 +1,65 @@
1
+ from typing import Any, Optional
2
+
3
+ import httpx
4
+
5
+
6
+ class RickyError(Exception):
7
+ """The API answered with an error.
8
+
9
+ ``code`` is stable and safe to branch on; the message is for people.
10
+ ``code`` and ``request_id`` are None only when the response did not come
11
+ from the API itself (a proxy page, for example).
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ *,
18
+ status: int,
19
+ code: Optional[str],
20
+ param: Optional[str],
21
+ request_id: Optional[str],
22
+ headers: httpx.Headers,
23
+ ) -> None:
24
+ super().__init__(message)
25
+ self.message = message
26
+ self.status = status
27
+ self.code = code
28
+ self.param = param
29
+ self.request_id = request_id
30
+ self.headers = headers
31
+
32
+ def __repr__(self) -> str:
33
+ return f"RickyError(status={self.status}, code={self.code!r}, request_id={self.request_id!r})"
34
+
35
+ @classmethod
36
+ def from_response(cls, response: httpx.Response) -> "RickyError":
37
+ try:
38
+ body: Any = response.json()
39
+ except ValueError:
40
+ body = None
41
+ error = body.get("error") if isinstance(body, dict) else None
42
+ error = error if isinstance(error, dict) else {}
43
+
44
+ def text(value: Any) -> Optional[str]:
45
+ return value if isinstance(value, str) else None
46
+
47
+ fallback = f"HTTP {response.status_code}"
48
+ if body is None and response.text:
49
+ fallback += f": {response.text[:200]}"
50
+ return cls(
51
+ text(error.get("message")) or fallback,
52
+ status=response.status_code,
53
+ code=text(error.get("code")),
54
+ param=text(error.get("param")),
55
+ request_id=text(error.get("request_id")) or response.headers.get("x-request-id"),
56
+ headers=response.headers,
57
+ )
58
+
59
+
60
+ class RickyConnectionError(Exception):
61
+ """The request never got an answer: DNS, TLS, a dropped connection or a timeout."""
62
+
63
+
64
+ class WebhookVerificationError(Exception):
65
+ """A webhook delivery failed verification and must be rejected."""