getresponse 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.
- getresponse/__init__.py +14 -0
- getresponse/_core.py +243 -0
- getresponse/_ratelimit.py +185 -0
- getresponse/_resource_groups.py +161 -0
- getresponse/client.py +79 -0
- getresponse/models.py +10735 -0
- getresponse/py.typed +0 -0
- getresponse/resources/__init__.py +103 -0
- getresponse/resources/ab_tests.py +65 -0
- getresponse/resources/ab_tests_subject.py +116 -0
- getresponse/resources/accounts.py +192 -0
- getresponse/resources/addresses.py +113 -0
- getresponse/resources/autoresponders.py +192 -0
- getresponse/resources/campaigns_lists.py +274 -0
- getresponse/resources/carts.py +94 -0
- getresponse/resources/categories.py +101 -0
- getresponse/resources/click_tracks.py +58 -0
- getresponse/resources/contacts.py +234 -0
- getresponse/resources/custom_events.py +106 -0
- getresponse/resources/custom_fields.py +92 -0
- getresponse/resources/custom_reports.py +71 -0
- getresponse/resources/ecommerce.py +64 -0
- getresponse/resources/file_library.py +152 -0
- getresponse/resources/form_and_popup.py +44 -0
- getresponse/resources/forms.py +88 -0
- getresponse/resources/forms_and_popups.py +76 -0
- getresponse/resources/from_fields.py +98 -0
- getresponse/resources/gdpr_fields.py +55 -0
- getresponse/resources/imports.py +84 -0
- getresponse/resources/landing_page.py +46 -0
- getresponse/resources/landing_pages.py +72 -0
- getresponse/resources/legacy_forms.py +62 -0
- getresponse/resources/legacy_landing_pages.py +78 -0
- getresponse/resources/meta_fields.py +102 -0
- getresponse/resources/multimedia.py +35 -0
- getresponse/resources/newsletters.py +230 -0
- getresponse/resources/orders.py +105 -0
- getresponse/resources/predefined_fields.py +93 -0
- getresponse/resources/product_variants.py +107 -0
- getresponse/resources/products.py +142 -0
- getresponse/resources/rss_newsletters.py +165 -0
- getresponse/resources/search_contacts.py +172 -0
- getresponse/resources/shops.py +91 -0
- getresponse/resources/sms.py +37 -0
- getresponse/resources/sms_automation_messages.py +69 -0
- getresponse/resources/sms_messages.py +101 -0
- getresponse/resources/subscription_confirmations.py +42 -0
- getresponse/resources/suppressions.py +98 -0
- getresponse/resources/tags.py +96 -0
- getresponse/resources/taxes.py +94 -0
- getresponse/resources/tracking.py +38 -0
- getresponse/resources/transactional_emails.py +105 -0
- getresponse/resources/transactional_emails_templates.py +99 -0
- getresponse/resources/webinars.py +64 -0
- getresponse/resources/website.py +46 -0
- getresponse/resources/websites.py +74 -0
- getresponse/resources/workflows.py +51 -0
- getresponse-0.1.0.dist-info/METADATA +101 -0
- getresponse-0.1.0.dist-info/RECORD +60 -0
- getresponse-0.1.0.dist-info/WHEEL +4 -0
getresponse/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Async, fully-typed GetResponse v3 API client."""
|
|
2
|
+
|
|
3
|
+
from . import models
|
|
4
|
+
from ._core import GetResponseApiError, RateLimitError
|
|
5
|
+
from ._ratelimit import RateLimitOptions
|
|
6
|
+
from .client import GetResponseClient
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"GetResponseClient",
|
|
10
|
+
"GetResponseApiError",
|
|
11
|
+
"RateLimitError",
|
|
12
|
+
"RateLimitOptions",
|
|
13
|
+
"models",
|
|
14
|
+
]
|
getresponse/_core.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""HTTP transport: request building, auth-agnostic call plumbing, and error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from pydantic import BaseModel, TypeAdapter
|
|
11
|
+
|
|
12
|
+
from ._ratelimit import RateLimitOptions, _RateLimiter, _WaitExceedsMaxWait
|
|
13
|
+
from .models import ErrorResponse
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.getresponse.com/v3"
|
|
16
|
+
MAX_US_BASE_URL = "https://api3.getresponse360.com/v3"
|
|
17
|
+
MAX_PL_BASE_URL = "https://api3.getresponse360.pl/v3"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GetResponseApiError(Exception):
|
|
21
|
+
"""Raised for any non-2xx response; carries the parsed `ErrorResponse` fields."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
status_code: int,
|
|
26
|
+
*,
|
|
27
|
+
code: int | None = None,
|
|
28
|
+
code_description: str | None = None,
|
|
29
|
+
message: str | None = None,
|
|
30
|
+
more_info: str | None = None,
|
|
31
|
+
context: dict[str, Any] | None = None,
|
|
32
|
+
uuid: str | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
self.code = code
|
|
36
|
+
self.code_description = code_description
|
|
37
|
+
self.message = message
|
|
38
|
+
self.more_info = more_info
|
|
39
|
+
self.context = context
|
|
40
|
+
self.uuid = uuid
|
|
41
|
+
super().__init__(
|
|
42
|
+
f"GetResponse API error {status_code}: {message or code_description or 'unknown error'}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_response(cls, response: httpx.Response) -> GetResponseApiError:
|
|
47
|
+
"""Build from an HTTP error response, tolerating a body that isn't an `ErrorResponse`."""
|
|
48
|
+
try:
|
|
49
|
+
err = ErrorResponse.model_validate(response.json())
|
|
50
|
+
except Exception:
|
|
51
|
+
return cls(status_code=response.status_code, message=response.text or None)
|
|
52
|
+
return cls(
|
|
53
|
+
status_code=response.status_code,
|
|
54
|
+
code=err.code,
|
|
55
|
+
code_description=err.code_description,
|
|
56
|
+
message=err.message,
|
|
57
|
+
more_info=err.more_info,
|
|
58
|
+
context=err.context,
|
|
59
|
+
uuid=err.uuid,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RateLimitError(GetResponseApiError):
|
|
64
|
+
"""Raised by the rate limiter: retries after a 429 were exhausted, or a wait -- proactive
|
|
65
|
+
|
|
66
|
+
(concurrency slot / token bucket / adaptive block) or reactive (429 reset) -- would exceed
|
|
67
|
+
`RateLimitOptions.max_wait`.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
status_code: int = 429,
|
|
73
|
+
*,
|
|
74
|
+
code: int | None = None,
|
|
75
|
+
code_description: str | None = None,
|
|
76
|
+
message: str | None = None,
|
|
77
|
+
more_info: str | None = None,
|
|
78
|
+
context: dict[str, Any] | None = None,
|
|
79
|
+
uuid: str | None = None,
|
|
80
|
+
reset_seconds: float | None = None,
|
|
81
|
+
limit: int | None = None,
|
|
82
|
+
remaining: int | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
super().__init__(
|
|
85
|
+
status_code,
|
|
86
|
+
code=code,
|
|
87
|
+
code_description=code_description,
|
|
88
|
+
message=message,
|
|
89
|
+
more_info=more_info,
|
|
90
|
+
context=context,
|
|
91
|
+
uuid=uuid,
|
|
92
|
+
)
|
|
93
|
+
self.reset_seconds = reset_seconds
|
|
94
|
+
self.limit = limit
|
|
95
|
+
self.remaining = remaining
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_error(
|
|
99
|
+
cls,
|
|
100
|
+
err: GetResponseApiError,
|
|
101
|
+
*,
|
|
102
|
+
reset_seconds: float | None,
|
|
103
|
+
limit: int | None,
|
|
104
|
+
remaining: int | None,
|
|
105
|
+
) -> RateLimitError:
|
|
106
|
+
"""Attach rate-limit fields to a `GetResponseApiError` already parsed from a 429."""
|
|
107
|
+
return cls(
|
|
108
|
+
err.status_code,
|
|
109
|
+
code=err.code,
|
|
110
|
+
code_description=err.code_description,
|
|
111
|
+
message=err.message,
|
|
112
|
+
more_info=err.more_info,
|
|
113
|
+
context=err.context,
|
|
114
|
+
uuid=err.uuid,
|
|
115
|
+
reset_seconds=reset_seconds,
|
|
116
|
+
limit=limit,
|
|
117
|
+
remaining=remaining,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def wait_too_long(cls, wait_seconds: float) -> RateLimitError:
|
|
122
|
+
"""Build for a proactive wait that would exceed `max_wait` (no HTTP response involved)."""
|
|
123
|
+
return cls(
|
|
124
|
+
429,
|
|
125
|
+
message=f"rate limit wait of {wait_seconds:.1f}s exceeds max_wait",
|
|
126
|
+
reset_seconds=wait_seconds,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _int_or_none(value: str | None) -> int | None:
|
|
131
|
+
"""Parse an optional header string as an int, tolerating a missing/invalid value."""
|
|
132
|
+
if value is None:
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
return int(value)
|
|
136
|
+
except ValueError:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _encode_query(query: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
141
|
+
"""Drop `None` values; encode enums/datetimes/bools to their wire representation."""
|
|
142
|
+
if not query:
|
|
143
|
+
return None
|
|
144
|
+
encoded: dict[str, Any] = {}
|
|
145
|
+
for key, value in query.items():
|
|
146
|
+
if value is None:
|
|
147
|
+
continue
|
|
148
|
+
if isinstance(value, Enum):
|
|
149
|
+
encoded[key] = value.value
|
|
150
|
+
elif isinstance(value, datetime):
|
|
151
|
+
encoded[key] = value.isoformat()
|
|
152
|
+
elif isinstance(value, bool):
|
|
153
|
+
encoded[key] = "true" if value else "false"
|
|
154
|
+
else:
|
|
155
|
+
encoded[key] = value
|
|
156
|
+
return encoded
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class _Core:
|
|
160
|
+
"""Thin wrapper around an `httpx.AsyncClient`: one call site for every operation."""
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self, client: httpx.AsyncClient, rate_limit: RateLimitOptions | None = None
|
|
164
|
+
) -> None:
|
|
165
|
+
self._client = client
|
|
166
|
+
self._limiter = (
|
|
167
|
+
_RateLimiter(rate_limit) if rate_limit is not None and rate_limit.enabled else None
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
async def request(
|
|
171
|
+
self,
|
|
172
|
+
method: str,
|
|
173
|
+
path: str,
|
|
174
|
+
*,
|
|
175
|
+
path_params: dict[str, str] | None = None,
|
|
176
|
+
query: dict[str, Any] | None = None,
|
|
177
|
+
body: Any = None,
|
|
178
|
+
files: dict[str, Any] | None = None,
|
|
179
|
+
return_type: Any = None,
|
|
180
|
+
) -> Any:
|
|
181
|
+
"""Send one API call and decode the response into `return_type` (or `None`)."""
|
|
182
|
+
url = path.format(**(path_params or {}))
|
|
183
|
+
params = _encode_query(query)
|
|
184
|
+
json_body: Any = None
|
|
185
|
+
if isinstance(body, BaseModel):
|
|
186
|
+
json_body = body.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
187
|
+
elif body is not None:
|
|
188
|
+
json_body = body
|
|
189
|
+
|
|
190
|
+
response = await self._send(method, url, params, json_body, files)
|
|
191
|
+
if response.status_code >= 400:
|
|
192
|
+
raise GetResponseApiError.from_response(response)
|
|
193
|
+
if return_type is None or response.status_code == 204 or not response.content:
|
|
194
|
+
return None
|
|
195
|
+
return TypeAdapter(return_type).validate_python(response.json())
|
|
196
|
+
|
|
197
|
+
async def _send(
|
|
198
|
+
self,
|
|
199
|
+
method: str,
|
|
200
|
+
url: str,
|
|
201
|
+
params: dict[str, Any] | None,
|
|
202
|
+
json_body: Any,
|
|
203
|
+
files: dict[str, Any] | None,
|
|
204
|
+
) -> httpx.Response:
|
|
205
|
+
"""Send the request as-is when no limiter is configured (zero overhead, unchanged
|
|
206
|
+
|
|
207
|
+
behavior); otherwise run it through the limiter's proactive acquire and reactive
|
|
208
|
+
429-retry loop.
|
|
209
|
+
"""
|
|
210
|
+
if self._limiter is None:
|
|
211
|
+
return await self._client.request(
|
|
212
|
+
method, url, params=params, json=json_body, files=files
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
attempt = 0
|
|
216
|
+
while True:
|
|
217
|
+
try:
|
|
218
|
+
await self._limiter.acquire()
|
|
219
|
+
except _WaitExceedsMaxWait as exc:
|
|
220
|
+
raise RateLimitError.wait_too_long(exc.wait_seconds) from exc
|
|
221
|
+
try:
|
|
222
|
+
response = await self._client.request(
|
|
223
|
+
method, url, params=params, json=json_body, files=files
|
|
224
|
+
)
|
|
225
|
+
finally:
|
|
226
|
+
self._limiter.release()
|
|
227
|
+
self._limiter.observe(response.headers)
|
|
228
|
+
if response.status_code != 429:
|
|
229
|
+
return response
|
|
230
|
+
|
|
231
|
+
attempt += 1
|
|
232
|
+
err = GetResponseApiError.from_response(response)
|
|
233
|
+
reset = self._limiter.reset_seconds_from(response.headers, err.context)
|
|
234
|
+
opts = self._limiter.options
|
|
235
|
+
if attempt < opts.max_retries and reset is not None and reset <= opts.max_wait:
|
|
236
|
+
await opts.sleep(reset)
|
|
237
|
+
continue
|
|
238
|
+
raise RateLimitError.from_error(
|
|
239
|
+
err,
|
|
240
|
+
reset_seconds=reset,
|
|
241
|
+
limit=_int_or_none(response.headers.get("X-RateLimit-Limit")),
|
|
242
|
+
remaining=_int_or_none(response.headers.get("X-RateLimit-Remaining")),
|
|
243
|
+
)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Opt-in rate limiter: a concurrency cap plus per-second/per-window token buckets.
|
|
2
|
+
|
|
3
|
+
Disabled unless a `RateLimitOptions` is passed to `GetResponseClient(rate_limit=...)`. `_Core`
|
|
4
|
+
owns one `_RateLimiter` per client and drives it around every send attempt: `acquire()` before
|
|
5
|
+
sending (a concurrency slot, an rps token, a window token, and any adaptive block from a prior
|
|
6
|
+
response), `release()` after, and `observe()`/`reset_seconds_from()` to read the response's rate
|
|
7
|
+
headers. Every wait is capped by `max_wait`; exceeding it raises `_WaitExceedsMaxWait`, which
|
|
8
|
+
`_Core` turns into the public `RateLimitError`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
_RESET_RE = re.compile(r"\d+")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_reset_seconds(value: str) -> int | None:
|
|
26
|
+
"""Extract the leading integer from a header value like `"432 seconds"`."""
|
|
27
|
+
m = _RESET_RE.search(value)
|
|
28
|
+
return int(m.group()) if m else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _default_sleep(seconds: float) -> None:
|
|
32
|
+
"""Default `RateLimitOptions.sleep`: real `asyncio.sleep`, overridable in tests."""
|
|
33
|
+
await asyncio.sleep(seconds)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _default_clock() -> float:
|
|
37
|
+
"""Default `RateLimitOptions.clock`: real monotonic time, overridable in tests."""
|
|
38
|
+
return time.monotonic()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class RateLimitOptions:
|
|
43
|
+
"""Opt-in rate-limit tuning; pass to `GetResponseClient(rate_limit=...)` to enable it.
|
|
44
|
+
|
|
45
|
+
`safety_margin` (range `(0, 1]`) scales `requests_per_second`/`requests_per_window` down
|
|
46
|
+
before they're used as bucket capacities -- concurrency is never scaled. `sleep`/`clock`
|
|
47
|
+
are injectable so tests can run against a fake, instantly-advancing clock.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
enabled: bool = True
|
|
51
|
+
max_concurrency: int = 10
|
|
52
|
+
requests_per_second: float = 80
|
|
53
|
+
requests_per_window: float = 30000
|
|
54
|
+
window_seconds: float = 600
|
|
55
|
+
safety_margin: float = 1.0
|
|
56
|
+
max_retries: int = 5
|
|
57
|
+
max_wait: float = 60.0
|
|
58
|
+
sleep: Callable[[float], Awaitable[None]] = _default_sleep
|
|
59
|
+
clock: Callable[[], float] = _default_clock
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def effective_rps(self) -> float:
|
|
63
|
+
"""`requests_per_second` scaled by `safety_margin` and floored."""
|
|
64
|
+
return float(int(self.requests_per_second * self.safety_margin))
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def effective_rpw(self) -> float:
|
|
68
|
+
"""`requests_per_window` scaled by `safety_margin` and floored."""
|
|
69
|
+
return float(int(self.requests_per_window * self.safety_margin))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _WaitExceedsMaxWait(Exception):
|
|
73
|
+
"""Internal signal: a proactive wait would exceed `max_wait`; `_Core` maps this to `RateLimitError`."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, wait_seconds: float) -> None:
|
|
76
|
+
self.wait_seconds = wait_seconds
|
|
77
|
+
super().__init__(f"wait of {wait_seconds:.1f}s exceeds max_wait")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _TokenBucket:
|
|
81
|
+
"""Continuous-refill token bucket: `capacity` tokens, refilling at `rate` tokens/second."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, rate: float, capacity: float, *, clock: Callable[[], float]) -> None:
|
|
84
|
+
self._rate = rate
|
|
85
|
+
self._capacity = capacity
|
|
86
|
+
self._clock = clock
|
|
87
|
+
self._tokens = capacity
|
|
88
|
+
self._last = clock()
|
|
89
|
+
|
|
90
|
+
def _refill(self) -> None:
|
|
91
|
+
now = self._clock()
|
|
92
|
+
self._tokens = min(self._capacity, self._tokens + (now - self._last) * self._rate)
|
|
93
|
+
self._last = now
|
|
94
|
+
|
|
95
|
+
def wait_time(self) -> float:
|
|
96
|
+
"""Seconds until one token is available (0 if one already is). Does not consume it."""
|
|
97
|
+
self._refill()
|
|
98
|
+
if self._tokens >= 1 or self._rate <= 0:
|
|
99
|
+
return 0.0
|
|
100
|
+
return (1 - self._tokens) / self._rate
|
|
101
|
+
|
|
102
|
+
def consume(self) -> None:
|
|
103
|
+
"""Take one token. Call only right after waiting out `wait_time()`."""
|
|
104
|
+
self._refill()
|
|
105
|
+
self._tokens -= 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _RateLimiter:
|
|
109
|
+
"""Per-client rate limiter: wraps every send attempt with proactive and adaptive throttling."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, options: RateLimitOptions) -> None:
|
|
112
|
+
self.options = options
|
|
113
|
+
self._concurrency = asyncio.Semaphore(options.max_concurrency)
|
|
114
|
+
self._rps_bucket = _TokenBucket(
|
|
115
|
+
options.effective_rps, options.effective_rps, clock=options.clock
|
|
116
|
+
)
|
|
117
|
+
self._rpw_bucket = _TokenBucket(
|
|
118
|
+
options.effective_rpw / options.window_seconds,
|
|
119
|
+
options.effective_rpw,
|
|
120
|
+
clock=options.clock,
|
|
121
|
+
)
|
|
122
|
+
self._blocked_until: float | None = None
|
|
123
|
+
|
|
124
|
+
async def acquire(self) -> None:
|
|
125
|
+
"""Take a concurrency slot, then wait out whichever of rps/window/adaptive-block is
|
|
126
|
+
|
|
127
|
+
longest. Raises `_WaitExceedsMaxWait` instead of waiting past `options.max_wait`; on
|
|
128
|
+
that path the concurrency slot is released again before raising.
|
|
129
|
+
"""
|
|
130
|
+
await self._concurrency.acquire()
|
|
131
|
+
try:
|
|
132
|
+
wait = 0.0
|
|
133
|
+
if self._blocked_until is not None:
|
|
134
|
+
remaining = self._blocked_until - self.options.clock()
|
|
135
|
+
if remaining <= 0:
|
|
136
|
+
self._blocked_until = None
|
|
137
|
+
else:
|
|
138
|
+
wait = remaining
|
|
139
|
+
wait = max(wait, self._rps_bucket.wait_time(), self._rpw_bucket.wait_time())
|
|
140
|
+
if wait > self.options.max_wait:
|
|
141
|
+
raise _WaitExceedsMaxWait(wait)
|
|
142
|
+
if wait > 0:
|
|
143
|
+
await self.options.sleep(wait)
|
|
144
|
+
self._rps_bucket.consume()
|
|
145
|
+
self._rpw_bucket.consume()
|
|
146
|
+
except Exception:
|
|
147
|
+
self._concurrency.release()
|
|
148
|
+
raise
|
|
149
|
+
|
|
150
|
+
def release(self) -> None:
|
|
151
|
+
"""Free the concurrency slot taken by a matching `acquire()`."""
|
|
152
|
+
self._concurrency.release()
|
|
153
|
+
|
|
154
|
+
def observe(self, headers: httpx.Headers) -> None:
|
|
155
|
+
"""Adaptive best-effort throttle: block new requests once a response reports 0 left."""
|
|
156
|
+
remaining = headers.get("X-RateLimit-Remaining")
|
|
157
|
+
if remaining is None:
|
|
158
|
+
return
|
|
159
|
+
try:
|
|
160
|
+
if int(remaining) > 0:
|
|
161
|
+
return
|
|
162
|
+
except ValueError:
|
|
163
|
+
return
|
|
164
|
+
reset = self.reset_seconds_from(headers, None)
|
|
165
|
+
wait = reset if reset is not None else self.options.window_seconds
|
|
166
|
+
self._blocked_until = self.options.clock() + wait
|
|
167
|
+
|
|
168
|
+
def reset_seconds_from(
|
|
169
|
+
self, headers: httpx.Headers, context: dict[str, Any] | None
|
|
170
|
+
) -> float | None:
|
|
171
|
+
"""Seconds until the limit resets: the `X-RateLimit-Reset` header, else
|
|
172
|
+
|
|
173
|
+
`context["timeToReset"]` (from a parsed `ErrorResponse`).
|
|
174
|
+
"""
|
|
175
|
+
header_value = headers.get("X-RateLimit-Reset")
|
|
176
|
+
if header_value is not None:
|
|
177
|
+
parsed = _parse_reset_seconds(header_value)
|
|
178
|
+
if parsed is not None:
|
|
179
|
+
return float(parsed)
|
|
180
|
+
if context and context.get("timeToReset") is not None:
|
|
181
|
+
try:
|
|
182
|
+
return float(context["timeToReset"])
|
|
183
|
+
except (TypeError, ValueError):
|
|
184
|
+
return None
|
|
185
|
+
return None
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Resource-group attributes attached to GetResponseClient. Generated from the spec's tags."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._core import _Core
|
|
6
|
+
from .resources import (
|
|
7
|
+
AbTestsResource,
|
|
8
|
+
AbTestsSubjectResource,
|
|
9
|
+
AccountsResource,
|
|
10
|
+
AddressesResource,
|
|
11
|
+
AutorespondersResource,
|
|
12
|
+
CampaignsListsResource,
|
|
13
|
+
CartsResource,
|
|
14
|
+
CategoriesResource,
|
|
15
|
+
ClickTracksResource,
|
|
16
|
+
ContactsResource,
|
|
17
|
+
CustomEventsResource,
|
|
18
|
+
CustomFieldsResource,
|
|
19
|
+
CustomReportsResource,
|
|
20
|
+
EcommerceResource,
|
|
21
|
+
FileLibraryResource,
|
|
22
|
+
FormAndPopupResource,
|
|
23
|
+
FormsAndPopupsResource,
|
|
24
|
+
FormsResource,
|
|
25
|
+
FromFieldsResource,
|
|
26
|
+
GdprFieldsResource,
|
|
27
|
+
ImportsResource,
|
|
28
|
+
LandingPageResource,
|
|
29
|
+
LandingPagesResource,
|
|
30
|
+
LegacyFormsResource,
|
|
31
|
+
LegacyLandingPagesResource,
|
|
32
|
+
MetaFieldsResource,
|
|
33
|
+
MultimediaResource,
|
|
34
|
+
NewslettersResource,
|
|
35
|
+
OrdersResource,
|
|
36
|
+
PredefinedFieldsResource,
|
|
37
|
+
ProductsResource,
|
|
38
|
+
ProductVariantsResource,
|
|
39
|
+
RssNewslettersResource,
|
|
40
|
+
SearchContactsResource,
|
|
41
|
+
ShopsResource,
|
|
42
|
+
SmsAutomationMessagesResource,
|
|
43
|
+
SmsMessagesResource,
|
|
44
|
+
SmsResource,
|
|
45
|
+
SubscriptionConfirmationsResource,
|
|
46
|
+
SuppressionsResource,
|
|
47
|
+
TagsResource,
|
|
48
|
+
TaxesResource,
|
|
49
|
+
TrackingResource,
|
|
50
|
+
TransactionalEmailsResource,
|
|
51
|
+
TransactionalEmailsTemplatesResource,
|
|
52
|
+
WebinarsResource,
|
|
53
|
+
WebsiteResource,
|
|
54
|
+
WebsitesResource,
|
|
55
|
+
WorkflowsResource,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _ResourceGroups:
|
|
60
|
+
"""Mixin providing one typed resource-group attribute per API tag."""
|
|
61
|
+
|
|
62
|
+
ab_tests: AbTestsResource
|
|
63
|
+
ab_tests_subject: AbTestsSubjectResource
|
|
64
|
+
accounts: AccountsResource
|
|
65
|
+
addresses: AddressesResource
|
|
66
|
+
autoresponders: AutorespondersResource
|
|
67
|
+
campaigns_lists: CampaignsListsResource
|
|
68
|
+
carts: CartsResource
|
|
69
|
+
categories: CategoriesResource
|
|
70
|
+
click_tracks: ClickTracksResource
|
|
71
|
+
contacts: ContactsResource
|
|
72
|
+
custom_events: CustomEventsResource
|
|
73
|
+
custom_fields: CustomFieldsResource
|
|
74
|
+
custom_reports: CustomReportsResource
|
|
75
|
+
ecommerce: EcommerceResource
|
|
76
|
+
file_library: FileLibraryResource
|
|
77
|
+
form_and_popup: FormAndPopupResource
|
|
78
|
+
forms: FormsResource
|
|
79
|
+
forms_and_popups: FormsAndPopupsResource
|
|
80
|
+
from_fields: FromFieldsResource
|
|
81
|
+
gdpr_fields: GdprFieldsResource
|
|
82
|
+
imports: ImportsResource
|
|
83
|
+
landing_page: LandingPageResource
|
|
84
|
+
landing_pages: LandingPagesResource
|
|
85
|
+
legacy_forms: LegacyFormsResource
|
|
86
|
+
legacy_landing_pages: LegacyLandingPagesResource
|
|
87
|
+
meta_fields: MetaFieldsResource
|
|
88
|
+
multimedia: MultimediaResource
|
|
89
|
+
newsletters: NewslettersResource
|
|
90
|
+
orders: OrdersResource
|
|
91
|
+
predefined_fields: PredefinedFieldsResource
|
|
92
|
+
product_variants: ProductVariantsResource
|
|
93
|
+
products: ProductsResource
|
|
94
|
+
rss_newsletters: RssNewslettersResource
|
|
95
|
+
sms_automation_messages: SmsAutomationMessagesResource
|
|
96
|
+
sms_messages: SmsMessagesResource
|
|
97
|
+
search_contacts: SearchContactsResource
|
|
98
|
+
shops: ShopsResource
|
|
99
|
+
sms: SmsResource
|
|
100
|
+
subscription_confirmations: SubscriptionConfirmationsResource
|
|
101
|
+
suppressions: SuppressionsResource
|
|
102
|
+
tags: TagsResource
|
|
103
|
+
taxes: TaxesResource
|
|
104
|
+
tracking: TrackingResource
|
|
105
|
+
transactional_emails: TransactionalEmailsResource
|
|
106
|
+
transactional_emails_templates: TransactionalEmailsTemplatesResource
|
|
107
|
+
webinars: WebinarsResource
|
|
108
|
+
website: WebsiteResource
|
|
109
|
+
websites: WebsitesResource
|
|
110
|
+
workflows: WorkflowsResource
|
|
111
|
+
|
|
112
|
+
def _attach_resource_groups(self, core: _Core) -> None:
|
|
113
|
+
self.ab_tests = AbTestsResource(core)
|
|
114
|
+
self.ab_tests_subject = AbTestsSubjectResource(core)
|
|
115
|
+
self.accounts = AccountsResource(core)
|
|
116
|
+
self.addresses = AddressesResource(core)
|
|
117
|
+
self.autoresponders = AutorespondersResource(core)
|
|
118
|
+
self.campaigns_lists = CampaignsListsResource(core)
|
|
119
|
+
self.carts = CartsResource(core)
|
|
120
|
+
self.categories = CategoriesResource(core)
|
|
121
|
+
self.click_tracks = ClickTracksResource(core)
|
|
122
|
+
self.contacts = ContactsResource(core)
|
|
123
|
+
self.custom_events = CustomEventsResource(core)
|
|
124
|
+
self.custom_fields = CustomFieldsResource(core)
|
|
125
|
+
self.custom_reports = CustomReportsResource(core)
|
|
126
|
+
self.ecommerce = EcommerceResource(core)
|
|
127
|
+
self.file_library = FileLibraryResource(core)
|
|
128
|
+
self.form_and_popup = FormAndPopupResource(core)
|
|
129
|
+
self.forms = FormsResource(core)
|
|
130
|
+
self.forms_and_popups = FormsAndPopupsResource(core)
|
|
131
|
+
self.from_fields = FromFieldsResource(core)
|
|
132
|
+
self.gdpr_fields = GdprFieldsResource(core)
|
|
133
|
+
self.imports = ImportsResource(core)
|
|
134
|
+
self.landing_page = LandingPageResource(core)
|
|
135
|
+
self.landing_pages = LandingPagesResource(core)
|
|
136
|
+
self.legacy_forms = LegacyFormsResource(core)
|
|
137
|
+
self.legacy_landing_pages = LegacyLandingPagesResource(core)
|
|
138
|
+
self.meta_fields = MetaFieldsResource(core)
|
|
139
|
+
self.multimedia = MultimediaResource(core)
|
|
140
|
+
self.newsletters = NewslettersResource(core)
|
|
141
|
+
self.orders = OrdersResource(core)
|
|
142
|
+
self.predefined_fields = PredefinedFieldsResource(core)
|
|
143
|
+
self.product_variants = ProductVariantsResource(core)
|
|
144
|
+
self.products = ProductsResource(core)
|
|
145
|
+
self.rss_newsletters = RssNewslettersResource(core)
|
|
146
|
+
self.sms_automation_messages = SmsAutomationMessagesResource(core)
|
|
147
|
+
self.sms_messages = SmsMessagesResource(core)
|
|
148
|
+
self.search_contacts = SearchContactsResource(core)
|
|
149
|
+
self.shops = ShopsResource(core)
|
|
150
|
+
self.sms = SmsResource(core)
|
|
151
|
+
self.subscription_confirmations = SubscriptionConfirmationsResource(core)
|
|
152
|
+
self.suppressions = SuppressionsResource(core)
|
|
153
|
+
self.tags = TagsResource(core)
|
|
154
|
+
self.taxes = TaxesResource(core)
|
|
155
|
+
self.tracking = TrackingResource(core)
|
|
156
|
+
self.transactional_emails = TransactionalEmailsResource(core)
|
|
157
|
+
self.transactional_emails_templates = TransactionalEmailsTemplatesResource(core)
|
|
158
|
+
self.webinars = WebinarsResource(core)
|
|
159
|
+
self.website = WebsiteResource(core)
|
|
160
|
+
self.websites = WebsitesResource(core)
|
|
161
|
+
self.workflows = WorkflowsResource(core)
|
getresponse/client.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Root async client: auth, resource groups, lifecycle, and pagination."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._core import DEFAULT_BASE_URL, _Core
|
|
11
|
+
from ._ratelimit import RateLimitOptions
|
|
12
|
+
from ._resource_groups import _ResourceGroups
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GetResponseClient(_ResourceGroups):
|
|
16
|
+
"""Async GetResponse v3 API client; one resource-group attribute per tag."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
api_key: str | None = None,
|
|
21
|
+
*,
|
|
22
|
+
access_token: str | None = None,
|
|
23
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
24
|
+
timeout: float = 30.0,
|
|
25
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
26
|
+
rate_limit: RateLimitOptions | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
if (api_key is None) == (access_token is None):
|
|
29
|
+
raise ValueError("Provide exactly one of `api_key` or `access_token`.")
|
|
30
|
+
headers = (
|
|
31
|
+
{"X-Auth-Token": f"api-key {api_key}"}
|
|
32
|
+
if api_key is not None
|
|
33
|
+
else {"Authorization": f"Bearer {access_token}"}
|
|
34
|
+
)
|
|
35
|
+
self._client = httpx.AsyncClient(
|
|
36
|
+
base_url=base_url, headers=headers, timeout=timeout, transport=transport
|
|
37
|
+
)
|
|
38
|
+
self._core = _Core(self._client, rate_limit)
|
|
39
|
+
self._attach_resource_groups(self._core)
|
|
40
|
+
|
|
41
|
+
async def aclose(self) -> None:
|
|
42
|
+
"""Close the underlying HTTP connection pool."""
|
|
43
|
+
await self._client.aclose()
|
|
44
|
+
|
|
45
|
+
async def __aenter__(self) -> GetResponseClient:
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
async def __aexit__(
|
|
49
|
+
self,
|
|
50
|
+
exc_type: type[BaseException] | None,
|
|
51
|
+
exc: BaseException | None,
|
|
52
|
+
tb: object | None,
|
|
53
|
+
) -> None:
|
|
54
|
+
await self.aclose()
|
|
55
|
+
|
|
56
|
+
async def paginate(
|
|
57
|
+
self,
|
|
58
|
+
list_method: Callable[..., Awaitable[list[Any]]],
|
|
59
|
+
*,
|
|
60
|
+
per_page: int = 100,
|
|
61
|
+
**kwargs: Any,
|
|
62
|
+
) -> AsyncIterator[Any]:
|
|
63
|
+
"""Walk every page of a `list_*` resource method, yielding items one by one.
|
|
64
|
+
|
|
65
|
+
GetResponse also reports `TotalCount`/`CurrentPage`/`TotalPages` response
|
|
66
|
+
headers, but resource methods only return typed bodies (no header access),
|
|
67
|
+
so this walks by page length instead: it stops once a page comes back empty
|
|
68
|
+
or shorter than `per_page`.
|
|
69
|
+
"""
|
|
70
|
+
page = 1
|
|
71
|
+
while True:
|
|
72
|
+
items = await list_method(page=page, per_page=per_page, **kwargs)
|
|
73
|
+
if not items:
|
|
74
|
+
return
|
|
75
|
+
for item in items:
|
|
76
|
+
yield item
|
|
77
|
+
if len(items) < per_page:
|
|
78
|
+
return
|
|
79
|
+
page += 1
|