broadcast-python 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.
- broadcast_python/__init__.py +66 -0
- broadcast_python/client.py +130 -0
- broadcast_python/configuration.py +117 -0
- broadcast_python/connection.py +384 -0
- broadcast_python/errors.py +76 -0
- broadcast_python/py.typed +0 -0
- broadcast_python/resources/__init__.py +0 -0
- broadcast_python/resources/autopilots.py +83 -0
- broadcast_python/resources/base.py +44 -0
- broadcast_python/resources/broadcasts.py +44 -0
- broadcast_python/resources/discovery.py +35 -0
- broadcast_python/resources/email_servers.py +68 -0
- broadcast_python/resources/migration.py +113 -0
- broadcast_python/resources/opt_in_forms.py +62 -0
- broadcast_python/resources/segments.py +23 -0
- broadcast_python/resources/sequences.py +58 -0
- broadcast_python/resources/subscribers.py +72 -0
- broadcast_python/resources/templates.py +34 -0
- broadcast_python/resources/transactionals.py +85 -0
- broadcast_python/resources/webhook_endpoints.py +29 -0
- broadcast_python/response.py +160 -0
- broadcast_python/version.py +3 -0
- broadcast_python/webhook.py +121 -0
- broadcast_python-0.1.0.dist-info/METADATA +488 -0
- broadcast_python-0.1.0.dist-info/RECORD +27 -0
- broadcast_python-0.1.0.dist-info/WHEEL +4 -0
- broadcast_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Python client for the Broadcast email platform.
|
|
2
|
+
|
|
3
|
+
from broadcast_python import Broadcast
|
|
4
|
+
|
|
5
|
+
client = Broadcast(api_token="...", host="https://mail.example.com")
|
|
6
|
+
client.subscribers.create(email="ada@example.com")
|
|
7
|
+
|
|
8
|
+
Works with any Broadcast instance — self-hosted or SaaS. No runtime
|
|
9
|
+
dependencies: the transport is built on the standard library's urllib.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from . import webhook
|
|
13
|
+
from .client import Broadcast
|
|
14
|
+
from .configuration import ENV_HOST, ENV_TOKEN, WARNINGS_MODES, Configuration
|
|
15
|
+
from .errors import (
|
|
16
|
+
APIError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
AuthorizationError,
|
|
19
|
+
BroadcastError,
|
|
20
|
+
ConfigurationError,
|
|
21
|
+
ConflictError,
|
|
22
|
+
DeliveryError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
TimeoutError,
|
|
26
|
+
ValidationError,
|
|
27
|
+
WarningError,
|
|
28
|
+
)
|
|
29
|
+
from .resources.email_servers import REDACTED_FIELDS
|
|
30
|
+
from .resources.migration import COLLECTIONS
|
|
31
|
+
from .resources.transactionals import MAX_IDEMPOTENCY_KEY_LENGTH
|
|
32
|
+
from .response import RateLimit, Response, Warning_
|
|
33
|
+
from .version import VERSION
|
|
34
|
+
from .webhook import EVENT_TYPES
|
|
35
|
+
|
|
36
|
+
__version__ = VERSION
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"COLLECTIONS",
|
|
40
|
+
"ENV_HOST",
|
|
41
|
+
"ENV_TOKEN",
|
|
42
|
+
"EVENT_TYPES",
|
|
43
|
+
"MAX_IDEMPOTENCY_KEY_LENGTH",
|
|
44
|
+
"REDACTED_FIELDS",
|
|
45
|
+
"VERSION",
|
|
46
|
+
"WARNINGS_MODES",
|
|
47
|
+
"APIError",
|
|
48
|
+
"AuthenticationError",
|
|
49
|
+
"AuthorizationError",
|
|
50
|
+
"Broadcast",
|
|
51
|
+
"BroadcastError",
|
|
52
|
+
"Configuration",
|
|
53
|
+
"ConfigurationError",
|
|
54
|
+
"ConflictError",
|
|
55
|
+
"DeliveryError",
|
|
56
|
+
"NotFoundError",
|
|
57
|
+
"RateLimit",
|
|
58
|
+
"RateLimitError",
|
|
59
|
+
"Response",
|
|
60
|
+
"TimeoutError",
|
|
61
|
+
"ValidationError",
|
|
62
|
+
"WarningError",
|
|
63
|
+
"Warning_",
|
|
64
|
+
"__version__",
|
|
65
|
+
"webhook",
|
|
66
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from typing import Any, Dict, Iterator, Optional, Union
|
|
3
|
+
|
|
4
|
+
from .configuration import Configuration
|
|
5
|
+
from .connection import Connection
|
|
6
|
+
from .resources.autopilots import Autopilots
|
|
7
|
+
from .resources.broadcasts import Broadcasts
|
|
8
|
+
from .resources.discovery import Discovery
|
|
9
|
+
from .resources.email_servers import EmailServers
|
|
10
|
+
from .resources.migration import Migration
|
|
11
|
+
from .resources.opt_in_forms import OptInForms
|
|
12
|
+
from .resources.segments import Segments
|
|
13
|
+
from .resources.sequences import Sequences
|
|
14
|
+
from .resources.subscribers import Subscribers
|
|
15
|
+
from .resources.templates import Templates
|
|
16
|
+
from .resources.transactionals import Transactionals
|
|
17
|
+
from .resources.webhook_endpoints import WebhookEndpoints
|
|
18
|
+
|
|
19
|
+
Id = Union[str, int]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Broadcast:
|
|
23
|
+
"""Client for the Broadcast API.
|
|
24
|
+
|
|
25
|
+
client = Broadcast(api_token="...", host="https://mail.example.com")
|
|
26
|
+
client.subscribers.create(email="ada@example.com")
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, **settings: Any):
|
|
30
|
+
self.config = Configuration(**settings)
|
|
31
|
+
self.config.validate()
|
|
32
|
+
self._connection = Connection(self.config)
|
|
33
|
+
self._channel_override: Optional[Id] = None
|
|
34
|
+
|
|
35
|
+
self.subscribers = Subscribers(self)
|
|
36
|
+
self.sequences = Sequences(self)
|
|
37
|
+
self.broadcasts = Broadcasts(self)
|
|
38
|
+
self.segments = Segments(self)
|
|
39
|
+
self.templates = Templates(self)
|
|
40
|
+
self.webhook_endpoints = WebhookEndpoints(self)
|
|
41
|
+
self.transactionals = Transactionals(self)
|
|
42
|
+
self.opt_in_forms = OptInForms(self)
|
|
43
|
+
self.email_servers = EmailServers(self)
|
|
44
|
+
self.autopilots = Autopilots(self)
|
|
45
|
+
self.discovery = Discovery(self)
|
|
46
|
+
#: Read-only export endpoints. Requires an admin (system) API token.
|
|
47
|
+
self.migration = Migration(self)
|
|
48
|
+
|
|
49
|
+
# --- Channel scoping (admin/system tokens) ---
|
|
50
|
+
|
|
51
|
+
@contextmanager
|
|
52
|
+
def with_channel(self, broadcast_channel_id: Id) -> Iterator["Broadcast"]:
|
|
53
|
+
"""Scope every request inside the block to a channel.
|
|
54
|
+
|
|
55
|
+
with client.with_channel(123):
|
|
56
|
+
client.email_servers.list()
|
|
57
|
+
|
|
58
|
+
The previous scope is restored on exit, including when the block raises.
|
|
59
|
+
The override lives on the client instance, so concurrent use of the same
|
|
60
|
+
instance across threads will interleave — use one client per thread, or
|
|
61
|
+
pass ``broadcast_channel_id`` explicitly.
|
|
62
|
+
"""
|
|
63
|
+
previous = self._channel_override
|
|
64
|
+
self._channel_override = broadcast_channel_id
|
|
65
|
+
try:
|
|
66
|
+
yield self
|
|
67
|
+
finally:
|
|
68
|
+
self._channel_override = previous
|
|
69
|
+
|
|
70
|
+
# --- Transactional email (convenience shims) ---
|
|
71
|
+
|
|
72
|
+
def send_email(
|
|
73
|
+
self,
|
|
74
|
+
to: str,
|
|
75
|
+
subject: Optional[str] = None,
|
|
76
|
+
body: Optional[str] = None,
|
|
77
|
+
reply_to: Optional[str] = None,
|
|
78
|
+
) -> Any:
|
|
79
|
+
"""Thin wrapper around ``transactionals.create``. Use that directly for
|
|
80
|
+
``template_id``, ``double_opt_in``, ``preheader``, ``idempotency_key``."""
|
|
81
|
+
return self.transactionals.create(to=to, subject=subject, body=body, reply_to=reply_to)
|
|
82
|
+
|
|
83
|
+
def get_email(self, id: Id) -> Any: # noqa: A002
|
|
84
|
+
return self.transactionals.get(id)
|
|
85
|
+
|
|
86
|
+
# --- Discovery (convenience shims) ---
|
|
87
|
+
|
|
88
|
+
def whoami(self) -> Any:
|
|
89
|
+
return self.discovery.whoami()
|
|
90
|
+
|
|
91
|
+
def status(self) -> Any:
|
|
92
|
+
return self.discovery.status()
|
|
93
|
+
|
|
94
|
+
def prime(self) -> Any:
|
|
95
|
+
return self.discovery.prime()
|
|
96
|
+
|
|
97
|
+
def skill(self) -> str:
|
|
98
|
+
return self.discovery.skill()
|
|
99
|
+
|
|
100
|
+
# --- Internal ---
|
|
101
|
+
|
|
102
|
+
def request(
|
|
103
|
+
self,
|
|
104
|
+
method: str,
|
|
105
|
+
path: str,
|
|
106
|
+
body_or_params: Any = None,
|
|
107
|
+
headers: Optional[Dict[str, Any]] = None,
|
|
108
|
+
raw: bool = False,
|
|
109
|
+
) -> Any:
|
|
110
|
+
payload = self._inject_channel_scope(body_or_params)
|
|
111
|
+
return self._connection.request(method, path, payload, headers=headers, raw=raw)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def _active_channel_id(self) -> Optional[Id]:
|
|
115
|
+
if self._channel_override is not None:
|
|
116
|
+
return self._channel_override
|
|
117
|
+
return self.config.broadcast_channel_id
|
|
118
|
+
|
|
119
|
+
def _inject_channel_scope(self, body_or_params: Any) -> Any:
|
|
120
|
+
"""Auto-include broadcast_channel_id when configured and not already set."""
|
|
121
|
+
channel_id = self._active_channel_id
|
|
122
|
+
if channel_id is None:
|
|
123
|
+
return body_or_params
|
|
124
|
+
|
|
125
|
+
payload = dict(body_or_params) if isinstance(body_or_params, dict) else {}
|
|
126
|
+
if payload.get("broadcast_channel_id") is not None:
|
|
127
|
+
return payload
|
|
128
|
+
|
|
129
|
+
payload["broadcast_channel_id"] = channel_id
|
|
130
|
+
return payload
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Client configuration and validation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Callable, Optional, Union
|
|
5
|
+
|
|
6
|
+
from .errors import ConfigurationError
|
|
7
|
+
|
|
8
|
+
#: How to handle the ``warnings`` array the API returns on successful writes.
|
|
9
|
+
#:
|
|
10
|
+
#: ``log`` — warn through ``logger`` if one is set (default)
|
|
11
|
+
#: ``raise`` — raise :class:`WarningError`; note the write already happened
|
|
12
|
+
#: ``ignore`` — leave them on the response for the caller to inspect
|
|
13
|
+
WARNINGS_MODES = ("log", "raise", "ignore")
|
|
14
|
+
|
|
15
|
+
#: Env vars use the same names as the Broadcast CLI's ``~/.config/broadcast/config``,
|
|
16
|
+
#: so a machine set up for the CLI can drive this client with no extra config.
|
|
17
|
+
ENV_HOST = "BROADCAST_HOST"
|
|
18
|
+
ENV_TOKEN = "BROADCAST_API_TOKEN"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Configuration:
|
|
22
|
+
"""Settings for a :class:`broadcast.client.Broadcast` instance.
|
|
23
|
+
|
|
24
|
+
Durations are in **seconds**, matching the Ruby gem.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
__slots__ = (
|
|
28
|
+
"api_token",
|
|
29
|
+
"broadcast_channel_id",
|
|
30
|
+
"debug",
|
|
31
|
+
"host",
|
|
32
|
+
"logger",
|
|
33
|
+
"max_retry_delay",
|
|
34
|
+
"open_timeout",
|
|
35
|
+
"opener",
|
|
36
|
+
"retry_attempts",
|
|
37
|
+
"retry_delay",
|
|
38
|
+
"sleep",
|
|
39
|
+
"timeout",
|
|
40
|
+
"warnings_mode",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_token: Optional[str] = None,
|
|
46
|
+
host: Optional[str] = None,
|
|
47
|
+
timeout: int = 30,
|
|
48
|
+
open_timeout: int = 10,
|
|
49
|
+
retry_attempts: int = 3,
|
|
50
|
+
retry_delay: float = 1,
|
|
51
|
+
max_retry_delay: float = 30,
|
|
52
|
+
warnings_mode: str = "log",
|
|
53
|
+
logger: Any = None,
|
|
54
|
+
debug: bool = False,
|
|
55
|
+
broadcast_channel_id: Optional[Union[str, int]] = None,
|
|
56
|
+
opener: Any = None,
|
|
57
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
58
|
+
):
|
|
59
|
+
self.api_token = api_token if api_token is not None else os.environ.get(ENV_TOKEN)
|
|
60
|
+
# No default host. Broadcast is self-hosted-first — every instance lives
|
|
61
|
+
# at its own domain, so any built-in guess is wrong for nearly everyone.
|
|
62
|
+
self.host = host if host is not None else os.environ.get(ENV_HOST)
|
|
63
|
+
|
|
64
|
+
self.timeout = timeout
|
|
65
|
+
self.open_timeout = open_timeout
|
|
66
|
+
self.retry_attempts = retry_attempts
|
|
67
|
+
self.retry_delay = retry_delay
|
|
68
|
+
# Ceiling for a server-supplied Retry-After. Without it a long
|
|
69
|
+
# rate-limit window would block the caller for as long as the server asked.
|
|
70
|
+
self.max_retry_delay = max_retry_delay
|
|
71
|
+
|
|
72
|
+
self.warnings_mode = warnings_mode
|
|
73
|
+
self.logger = logger
|
|
74
|
+
self.debug = debug
|
|
75
|
+
self.broadcast_channel_id = broadcast_channel_id
|
|
76
|
+
|
|
77
|
+
# Injectable for tests, so the suite neither opens sockets nor sleeps.
|
|
78
|
+
self.opener = opener
|
|
79
|
+
self.sleep = sleep
|
|
80
|
+
|
|
81
|
+
def validate(self) -> None:
|
|
82
|
+
if _blank(self.api_token):
|
|
83
|
+
raise ConfigurationError("api_token is required")
|
|
84
|
+
if _blank(self.host):
|
|
85
|
+
raise ConfigurationError(_host_missing_message())
|
|
86
|
+
|
|
87
|
+
self.host = str(self.host).strip().rstrip("/")
|
|
88
|
+
self._validate_host_scheme()
|
|
89
|
+
self._validate_warnings_mode()
|
|
90
|
+
|
|
91
|
+
def _validate_host_scheme(self) -> None:
|
|
92
|
+
if str(self.host).startswith(("http://", "https://")):
|
|
93
|
+
return
|
|
94
|
+
raise ConfigurationError(
|
|
95
|
+
"host must include a scheme (http:// or https://), got {!r}".format(self.host)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _validate_warnings_mode(self) -> None:
|
|
99
|
+
if self.warnings_mode in WARNINGS_MODES:
|
|
100
|
+
return
|
|
101
|
+
raise ConfigurationError(
|
|
102
|
+
"warnings_mode must be one of {}, got {!r}".format(
|
|
103
|
+
", ".join(WARNINGS_MODES), self.warnings_mode
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _blank(value: Any) -> bool:
|
|
109
|
+
return value is None or str(value).strip() == ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _host_missing_message() -> str:
|
|
113
|
+
return (
|
|
114
|
+
"host is required — point it at your Broadcast instance, e.g. "
|
|
115
|
+
"Broadcast(api_token='...', host='https://mail.example.com'). "
|
|
116
|
+
"You can also set the {} environment variable.".format(ENV_HOST)
|
|
117
|
+
)
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""HTTP transport.
|
|
2
|
+
|
|
3
|
+
Owns request building, response/error mapping, retries, redirects, and warning
|
|
4
|
+
dispatch, so ``Broadcast`` stays a thin facade over configuration and resources.
|
|
5
|
+
|
|
6
|
+
Built on ``urllib`` from the standard library so the package has no runtime
|
|
7
|
+
dependencies and cannot conflict with a pinned ``requests`` or ``httpx``
|
|
8
|
+
elsewhere in the environment.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import socket
|
|
14
|
+
import time
|
|
15
|
+
import urllib.request
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
from urllib.error import HTTPError, URLError
|
|
18
|
+
from urllib.parse import urlencode, urljoin, urlparse
|
|
19
|
+
|
|
20
|
+
from .configuration import Configuration
|
|
21
|
+
from .errors import (
|
|
22
|
+
APIError,
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
AuthorizationError,
|
|
25
|
+
ConflictError,
|
|
26
|
+
NotFoundError,
|
|
27
|
+
RateLimitError,
|
|
28
|
+
TimeoutError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
WarningError,
|
|
31
|
+
)
|
|
32
|
+
from .response import build_response
|
|
33
|
+
from .version import VERSION
|
|
34
|
+
|
|
35
|
+
MAX_REDIRECTS = 3
|
|
36
|
+
REDIRECT_CODES = (301, 302, 307, 308)
|
|
37
|
+
|
|
38
|
+
ERROR_MAPPING = {
|
|
39
|
+
401: (AuthenticationError, "Authentication failed"),
|
|
40
|
+
403: (AuthorizationError, "Not authorized"),
|
|
41
|
+
404: (NotFoundError, "Resource not found"),
|
|
42
|
+
409: (ConflictError, "A request with this Idempotency-Key is still being processed"),
|
|
43
|
+
422: (ValidationError, "Validation failed"),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _NoRedirects(urllib.request.HTTPRedirectHandler):
|
|
48
|
+
"""Stops urllib following redirects on our behalf.
|
|
49
|
+
|
|
50
|
+
Every request carries ``Authorization: Bearer <token>``. urllib's default
|
|
51
|
+
handler would follow a redirect to any host and take the token with it.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Connection:
|
|
59
|
+
def __init__(self, config: Configuration):
|
|
60
|
+
self.config = config
|
|
61
|
+
|
|
62
|
+
def request(
|
|
63
|
+
self,
|
|
64
|
+
method: str,
|
|
65
|
+
path: str,
|
|
66
|
+
payload: Any = None,
|
|
67
|
+
headers: Optional[Dict[str, Any]] = None,
|
|
68
|
+
raw: bool = False,
|
|
69
|
+
) -> Any:
|
|
70
|
+
url = self._build_url(path, method, payload)
|
|
71
|
+
return self._retry_with_backoff(
|
|
72
|
+
lambda: self._execute(method, url, payload, headers or {}, raw, 0)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# --- Request building ---------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def _build_url(self, path: str, method: str, payload: Any) -> str:
|
|
78
|
+
url = "{}{}".format(self.config.host, path)
|
|
79
|
+
if method == "GET" and _present(payload):
|
|
80
|
+
url = "{}?{}".format(url, urlencode(_flatten_params(payload)))
|
|
81
|
+
return url
|
|
82
|
+
|
|
83
|
+
def _execute(
|
|
84
|
+
self, method: str, url: str, payload: Any, extra_headers: Dict[str, Any], raw: bool, redirects: int
|
|
85
|
+
) -> Any:
|
|
86
|
+
body = None
|
|
87
|
+
if method != "GET" and _present(payload):
|
|
88
|
+
body = json.dumps(payload).encode("utf-8")
|
|
89
|
+
|
|
90
|
+
request = urllib.request.Request(url, data=body, method=method)
|
|
91
|
+
request.add_header("Authorization", "Bearer {}".format(self.config.api_token))
|
|
92
|
+
request.add_header("Content-Type", "application/json")
|
|
93
|
+
request.add_header("User-Agent", "broadcast-python/{}".format(VERSION))
|
|
94
|
+
for key, value in (extra_headers or {}).items():
|
|
95
|
+
if value is None:
|
|
96
|
+
continue
|
|
97
|
+
request.add_header(str(key), str(value))
|
|
98
|
+
|
|
99
|
+
self._debug_request(method, url, body)
|
|
100
|
+
|
|
101
|
+
opener = self.config.opener or _default_opener()
|
|
102
|
+
try:
|
|
103
|
+
response = opener.open(request, timeout=self.config.timeout)
|
|
104
|
+
except HTTPError as error:
|
|
105
|
+
status = error.code
|
|
106
|
+
headers = dict(error.headers.items()) if error.headers else {}
|
|
107
|
+
if status in REDIRECT_CODES:
|
|
108
|
+
return self._follow_redirect(
|
|
109
|
+
headers, status, method, url, extra_headers, raw, redirects
|
|
110
|
+
)
|
|
111
|
+
self._raise_for_status(status, _safe_read(error), headers)
|
|
112
|
+
raise # unreachable; _raise_for_status always raises
|
|
113
|
+
except URLError as error:
|
|
114
|
+
raise _transport_error(error) from error
|
|
115
|
+
except socket.timeout as error:
|
|
116
|
+
raise TimeoutError("Request timeout: {}".format(error)) from error
|
|
117
|
+
|
|
118
|
+
# urllib exposes the code as `.status` on 3.9+ and `.code` on older
|
|
119
|
+
# objects. Both are Any to the type checker, so the fallback is spelled
|
|
120
|
+
# out rather than chained through `or`, which loses the int.
|
|
121
|
+
raw_status = getattr(response, "status", None)
|
|
122
|
+
if raw_status is None:
|
|
123
|
+
raw_status = getattr(response, "code", None)
|
|
124
|
+
status = int(raw_status) if raw_status is not None else 200
|
|
125
|
+
headers = _headers_of(response)
|
|
126
|
+
payload_bytes = response.read()
|
|
127
|
+
self._debug_response(status)
|
|
128
|
+
|
|
129
|
+
if status in REDIRECT_CODES:
|
|
130
|
+
return self._follow_redirect(headers, status, method, url, extra_headers, raw, redirects)
|
|
131
|
+
|
|
132
|
+
return self._build_success(status, payload_bytes, headers, raw)
|
|
133
|
+
|
|
134
|
+
# --- Redirects ----------------------------------------------------------
|
|
135
|
+
#
|
|
136
|
+
# A redirect nearly always means a misconfigured `host` (http vs https, a
|
|
137
|
+
# bare apex that redirects to www, a stale domain). Two things are never
|
|
138
|
+
# followed: writes, because replaying a send against an unexpected origin is
|
|
139
|
+
# worse than failing; and anything that changes host, because the request
|
|
140
|
+
# carries the API token.
|
|
141
|
+
|
|
142
|
+
def _follow_redirect(self, headers, status, method, url, extra_headers, raw, redirects):
|
|
143
|
+
location = _header(headers, "location")
|
|
144
|
+
|
|
145
|
+
if method != "GET":
|
|
146
|
+
raise APIError(
|
|
147
|
+
"Host redirected {} {} to {}. Set `host` to the final URL — "
|
|
148
|
+
"writes are not followed automatically.".format(
|
|
149
|
+
method, url, location or "(no Location header)"
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
if location is None:
|
|
153
|
+
raise APIError("Redirect from {} had no Location header".format(url))
|
|
154
|
+
if redirects >= MAX_REDIRECTS:
|
|
155
|
+
raise APIError("Too many redirects ({}) starting at {}".format(MAX_REDIRECTS, url))
|
|
156
|
+
|
|
157
|
+
target = urljoin(url, location)
|
|
158
|
+
if (urlparse(target).hostname or "").lower() != (urlparse(url).hostname or "").lower():
|
|
159
|
+
raise APIError(
|
|
160
|
+
"Host redirected {} to a different host ({}). Not following it — "
|
|
161
|
+
"the request carries your API token. Set `host` to the correct "
|
|
162
|
+
"instance URL.".format(url, target)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# The query string is already baked into the current URL.
|
|
166
|
+
return self._execute("GET", target, None, extra_headers, raw, redirects + 1)
|
|
167
|
+
|
|
168
|
+
# --- Responses ----------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def _build_success(self, status: int, payload: bytes, headers: Dict[str, str], raw: bool) -> Any:
|
|
171
|
+
if raw:
|
|
172
|
+
return _raw_body(payload, headers)
|
|
173
|
+
|
|
174
|
+
result = build_response(_parse_success_body(payload), status, headers)
|
|
175
|
+
self._handle_warnings(result)
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
def _raise_for_status(self, status: int, payload: bytes, headers: Dict[str, str]) -> None:
|
|
179
|
+
message = _parse_error(payload)
|
|
180
|
+
|
|
181
|
+
if status == 429:
|
|
182
|
+
retry_after = _header(headers, "retry-after")
|
|
183
|
+
raise RateLimitError(
|
|
184
|
+
message or "Rate limit exceeded",
|
|
185
|
+
retry_after=int(retry_after) if retry_after and str(retry_after).isdigit() else None,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
mapping = ERROR_MAPPING.get(status)
|
|
189
|
+
if mapping:
|
|
190
|
+
error_class, default = mapping
|
|
191
|
+
raise error_class(message or default)
|
|
192
|
+
|
|
193
|
+
if status >= 500:
|
|
194
|
+
raise APIError(message or "Server error ({})".format(status))
|
|
195
|
+
|
|
196
|
+
raise APIError(message or "Unexpected response: {}".format(status))
|
|
197
|
+
|
|
198
|
+
def _handle_warnings(self, result: Any) -> None:
|
|
199
|
+
warnings = getattr(result, "warnings", None)
|
|
200
|
+
if not warnings:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
if self.config.warnings_mode == "raise":
|
|
204
|
+
raise WarningError(warnings, result)
|
|
205
|
+
if self.config.warnings_mode == "log" and self.config.logger is not None:
|
|
206
|
+
for warning in warnings:
|
|
207
|
+
self.config.logger.warning("[broadcast] {}".format(warning))
|
|
208
|
+
|
|
209
|
+
# --- Retries ------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def _retry_with_backoff(self, operation):
|
|
212
|
+
attempts = 0
|
|
213
|
+
while True:
|
|
214
|
+
attempts += 1
|
|
215
|
+
try:
|
|
216
|
+
return operation()
|
|
217
|
+
except Exception as error:
|
|
218
|
+
if attempts >= self.config.retry_attempts or not _retryable(error):
|
|
219
|
+
raise
|
|
220
|
+
self._sleep(self._delay_for(error, attempts))
|
|
221
|
+
|
|
222
|
+
def _delay_for(self, error: Exception, attempts: int) -> float:
|
|
223
|
+
"""Honour Retry-After, but never sleep longer than max_retry_delay."""
|
|
224
|
+
if isinstance(error, RateLimitError) and error.retry_after is not None:
|
|
225
|
+
return min(error.retry_after, self.config.max_retry_delay)
|
|
226
|
+
return min(self.config.retry_delay * attempts, self.config.max_retry_delay)
|
|
227
|
+
|
|
228
|
+
def _sleep(self, seconds: float) -> None:
|
|
229
|
+
(self.config.sleep or time.sleep)(seconds)
|
|
230
|
+
|
|
231
|
+
# --- Debug logging ------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def _debug_request(self, method: str, url: str, body: Optional[bytes]) -> None:
|
|
234
|
+
if not self.config.debug or self.config.logger is None:
|
|
235
|
+
return
|
|
236
|
+
# Never log the Authorization header or the body: bodies carry
|
|
237
|
+
# subscriber email addresses and credential fields.
|
|
238
|
+
self.config.logger.debug(
|
|
239
|
+
"[broadcast] -> {} {}{}".format(method, url, " (body redacted)" if body else "")
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def _debug_response(self, status: int) -> None:
|
|
243
|
+
if not self.config.debug or self.config.logger is None:
|
|
244
|
+
return
|
|
245
|
+
self.config.logger.debug("[broadcast] <- {}".format(status))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# --- Helpers ---------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _default_opener():
|
|
252
|
+
return urllib.request.build_opener(_NoRedirects)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _present(payload: Any) -> bool:
|
|
256
|
+
return isinstance(payload, dict) and len(payload) > 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _flatten_params(params: Dict[str, Any]) -> List[Tuple[str, str]]:
|
|
260
|
+
"""Arrays repeat as ``key[]``, dicts flatten to ``key[sub]``.
|
|
261
|
+
|
|
262
|
+
Booleans are lowercased: Python's ``str(True)`` is ``"True"``, which Rails
|
|
263
|
+
does not read as true.
|
|
264
|
+
"""
|
|
265
|
+
result: List[Tuple[str, str]] = []
|
|
266
|
+
|
|
267
|
+
for key, value in params.items():
|
|
268
|
+
if value is None:
|
|
269
|
+
continue
|
|
270
|
+
if isinstance(value, (list, tuple)):
|
|
271
|
+
result.extend(("{}[]".format(key), _stringify(v)) for v in value)
|
|
272
|
+
elif isinstance(value, dict):
|
|
273
|
+
result.extend(("{}[{}]".format(key, k), _stringify(v)) for k, v in value.items())
|
|
274
|
+
else:
|
|
275
|
+
result.append((str(key), _stringify(value)))
|
|
276
|
+
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _stringify(value: Any) -> str:
|
|
281
|
+
if isinstance(value, bool):
|
|
282
|
+
return "true" if value else "false"
|
|
283
|
+
return str(value)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _raw_body(payload: bytes, headers: Dict[str, str]) -> Any:
|
|
287
|
+
"""Raw endpoints serve text (/api/v1/skill) and binary file assets alike.
|
|
288
|
+
|
|
289
|
+
Decoding a PNG as text would corrupt it, so only decode when the server
|
|
290
|
+
actually declared a charset.
|
|
291
|
+
"""
|
|
292
|
+
content_type = _header(headers, "content-type") or ""
|
|
293
|
+
if "charset=" in content_type.lower():
|
|
294
|
+
return payload.decode("utf-8", errors="replace")
|
|
295
|
+
return payload
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _parse_success_body(payload: bytes) -> Any:
|
|
299
|
+
text = payload.decode("utf-8", errors="replace").strip() if payload else ""
|
|
300
|
+
if text == "":
|
|
301
|
+
return {}
|
|
302
|
+
try:
|
|
303
|
+
return json.loads(text)
|
|
304
|
+
except ValueError:
|
|
305
|
+
# A 2xx that isn't JSON (an HTML error page from a proxy, say). Surface
|
|
306
|
+
# it as an empty body rather than exploding — raw=True is the deliberate
|
|
307
|
+
# way to read non-JSON endpoints.
|
|
308
|
+
return {}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _parse_error(payload: bytes) -> Optional[str]:
|
|
312
|
+
try:
|
|
313
|
+
body = json.loads(payload.decode("utf-8", errors="replace"))
|
|
314
|
+
except (ValueError, AttributeError):
|
|
315
|
+
return None
|
|
316
|
+
if not isinstance(body, dict):
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
if isinstance(body.get("error"), str):
|
|
320
|
+
return body["error"]
|
|
321
|
+
return _format_errors(body.get("errors"))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _format_errors(errors: Any) -> Optional[str]:
|
|
325
|
+
"""ActiveModel errors arrive as ``{"field": ["msg", ...]}``."""
|
|
326
|
+
if errors is None:
|
|
327
|
+
return None
|
|
328
|
+
if isinstance(errors, list):
|
|
329
|
+
return ", ".join(str(e) for e in errors)
|
|
330
|
+
if not isinstance(errors, dict):
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
parts = []
|
|
334
|
+
for field, messages in errors.items():
|
|
335
|
+
if not isinstance(messages, (list, tuple)):
|
|
336
|
+
messages = [messages]
|
|
337
|
+
parts.append("{} {}".format(field, ", ".join(str(m) for m in messages)))
|
|
338
|
+
return "; ".join(parts)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _headers_of(response: Any) -> Dict[str, str]:
|
|
342
|
+
headers = getattr(response, "headers", {}) or {}
|
|
343
|
+
try:
|
|
344
|
+
return dict(headers.items())
|
|
345
|
+
except AttributeError:
|
|
346
|
+
return dict(headers)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _header(headers: Dict[str, str], name: str) -> Optional[str]:
|
|
350
|
+
for key, value in (headers or {}).items():
|
|
351
|
+
if str(key).lower() == name:
|
|
352
|
+
return value
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _safe_read(error: HTTPError) -> bytes:
|
|
357
|
+
"""Read and close an error body.
|
|
358
|
+
|
|
359
|
+
HTTPError is a file-like object; leaving it open leaks the underlying
|
|
360
|
+
socket and raises ResourceWarning under -W error.
|
|
361
|
+
"""
|
|
362
|
+
try:
|
|
363
|
+
return error.read()
|
|
364
|
+
except Exception:
|
|
365
|
+
return b""
|
|
366
|
+
finally:
|
|
367
|
+
with contextlib.suppress(Exception):
|
|
368
|
+
error.close()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _transport_error(error: URLError) -> Exception:
|
|
372
|
+
"""DNS/TCP/TLS failures and timeouts alike arrive as URLError.
|
|
373
|
+
|
|
374
|
+
They are transient often enough to be worth a retry, and TimeoutError is the
|
|
375
|
+
class the retry loop honours.
|
|
376
|
+
"""
|
|
377
|
+
return TimeoutError("Request timeout: {}".format(error.reason if hasattr(error, "reason") else error))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _retryable(error: Exception) -> bool:
|
|
381
|
+
if isinstance(error, (TimeoutError, RateLimitError)):
|
|
382
|
+
return True
|
|
383
|
+
# Only 5xx. A 422 is deterministic — retrying it is pure latency.
|
|
384
|
+
return isinstance(error, APIError) and "Server error" in str(error)
|