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.
Files changed (60) hide show
  1. getresponse/__init__.py +14 -0
  2. getresponse/_core.py +243 -0
  3. getresponse/_ratelimit.py +185 -0
  4. getresponse/_resource_groups.py +161 -0
  5. getresponse/client.py +79 -0
  6. getresponse/models.py +10735 -0
  7. getresponse/py.typed +0 -0
  8. getresponse/resources/__init__.py +103 -0
  9. getresponse/resources/ab_tests.py +65 -0
  10. getresponse/resources/ab_tests_subject.py +116 -0
  11. getresponse/resources/accounts.py +192 -0
  12. getresponse/resources/addresses.py +113 -0
  13. getresponse/resources/autoresponders.py +192 -0
  14. getresponse/resources/campaigns_lists.py +274 -0
  15. getresponse/resources/carts.py +94 -0
  16. getresponse/resources/categories.py +101 -0
  17. getresponse/resources/click_tracks.py +58 -0
  18. getresponse/resources/contacts.py +234 -0
  19. getresponse/resources/custom_events.py +106 -0
  20. getresponse/resources/custom_fields.py +92 -0
  21. getresponse/resources/custom_reports.py +71 -0
  22. getresponse/resources/ecommerce.py +64 -0
  23. getresponse/resources/file_library.py +152 -0
  24. getresponse/resources/form_and_popup.py +44 -0
  25. getresponse/resources/forms.py +88 -0
  26. getresponse/resources/forms_and_popups.py +76 -0
  27. getresponse/resources/from_fields.py +98 -0
  28. getresponse/resources/gdpr_fields.py +55 -0
  29. getresponse/resources/imports.py +84 -0
  30. getresponse/resources/landing_page.py +46 -0
  31. getresponse/resources/landing_pages.py +72 -0
  32. getresponse/resources/legacy_forms.py +62 -0
  33. getresponse/resources/legacy_landing_pages.py +78 -0
  34. getresponse/resources/meta_fields.py +102 -0
  35. getresponse/resources/multimedia.py +35 -0
  36. getresponse/resources/newsletters.py +230 -0
  37. getresponse/resources/orders.py +105 -0
  38. getresponse/resources/predefined_fields.py +93 -0
  39. getresponse/resources/product_variants.py +107 -0
  40. getresponse/resources/products.py +142 -0
  41. getresponse/resources/rss_newsletters.py +165 -0
  42. getresponse/resources/search_contacts.py +172 -0
  43. getresponse/resources/shops.py +91 -0
  44. getresponse/resources/sms.py +37 -0
  45. getresponse/resources/sms_automation_messages.py +69 -0
  46. getresponse/resources/sms_messages.py +101 -0
  47. getresponse/resources/subscription_confirmations.py +42 -0
  48. getresponse/resources/suppressions.py +98 -0
  49. getresponse/resources/tags.py +96 -0
  50. getresponse/resources/taxes.py +94 -0
  51. getresponse/resources/tracking.py +38 -0
  52. getresponse/resources/transactional_emails.py +105 -0
  53. getresponse/resources/transactional_emails_templates.py +99 -0
  54. getresponse/resources/webinars.py +64 -0
  55. getresponse/resources/website.py +46 -0
  56. getresponse/resources/websites.py +74 -0
  57. getresponse/resources/workflows.py +51 -0
  58. getresponse-0.1.0.dist-info/METADATA +101 -0
  59. getresponse-0.1.0.dist-info/RECORD +60 -0
  60. getresponse-0.1.0.dist-info/WHEEL +4 -0
@@ -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