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,76 @@
|
|
|
1
|
+
"""Error hierarchy, mirroring broadcast-ruby's lib/broadcast/errors.rb.
|
|
2
|
+
|
|
3
|
+
Note the shape: ``ValidationError`` and ``TimeoutError`` descend from
|
|
4
|
+
``BroadcastError``, NOT from ``APIError``. That is deliberate and matches the
|
|
5
|
+
Ruby gem — catching ``APIError`` gets you transport and status failures and
|
|
6
|
+
leaves validation to be handled explicitly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BroadcastError(Exception):
|
|
13
|
+
"""Base class for everything this package raises."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigurationError(BroadcastError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APIError(BroadcastError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthenticationError(APIError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthorizationError(APIError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotFoundError(APIError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConflictError(APIError):
|
|
37
|
+
"""409 — an in-flight request is already using this Idempotency-Key.
|
|
38
|
+
|
|
39
|
+
The original request is still processing; retrying after a short pause will
|
|
40
|
+
either replay its stored response or run fresh if it failed.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RateLimitError(APIError):
|
|
45
|
+
"""429. ``retry_after`` is the seconds the server asked us to wait."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, message: Optional[str] = None, retry_after: Optional[int] = None):
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
self.retry_after = retry_after
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ValidationError(BroadcastError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TimeoutError(BroadcastError): # noqa: A001 - mirrors the Ruby gem's name
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DeliveryError(BroadcastError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class WarningError(BroadcastError):
|
|
65
|
+
"""Raised instead of returning when ``warnings_mode`` is ``"raise"`` and a
|
|
66
|
+
2xx response carried warnings.
|
|
67
|
+
|
|
68
|
+
The request DID succeed — the write happened. Callers catching this must
|
|
69
|
+
not assume anything was rolled back.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, warnings: List[Any], response: Any = None):
|
|
73
|
+
self.warnings = warnings
|
|
74
|
+
self.response = response
|
|
75
|
+
joined = "; ".join(str(w) for w in warnings)
|
|
76
|
+
super().__init__("API returned {} warning(s): {}".format(len(warnings), joined))
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, Dict, Union
|
|
3
|
+
|
|
4
|
+
from .base import BaseResource
|
|
5
|
+
|
|
6
|
+
Id = Union[str, int]
|
|
7
|
+
|
|
8
|
+
#: The API renders a configured key bullet-masked and never returns the real
|
|
9
|
+
#: value. Writing a masked value back would replace a working credential with
|
|
10
|
+
#: bullets, so :meth:`Autopilots.update` strips it — the same guard as
|
|
11
|
+
#: :mod:`email_servers`.
|
|
12
|
+
REDACTED_KEY_PATTERN = re.compile(r"\A•+\Z")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Autopilots(BaseResource):
|
|
16
|
+
"""Autopilot — AI-generated newsletters.
|
|
17
|
+
|
|
18
|
+
Requires the ``autopilot_read`` / ``autopilot_write`` token permissions.
|
|
19
|
+
|
|
20
|
+
Sources and tone samples have no API endpoints; they are configured in the
|
|
21
|
+
web UI. Since :meth:`activate` requires an active source, an autopilot
|
|
22
|
+
created entirely over the API cannot be activated until a source is added
|
|
23
|
+
there.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def list(self, **params: Any) -> Any:
|
|
27
|
+
return self._get("/api/v1/autopilots", params)
|
|
28
|
+
|
|
29
|
+
def get(self, id: Id) -> Any: # noqa: A002
|
|
30
|
+
return self._get("/api/v1/autopilots/{}".format(id))
|
|
31
|
+
|
|
32
|
+
def create(self, **attrs: Any) -> Any:
|
|
33
|
+
"""Attributes are wrapped under ``autopilot:``.
|
|
34
|
+
|
|
35
|
+
``name`` is required and unique per channel. ``openrouter_api_key`` is
|
|
36
|
+
write-only. Scheduling takes ``schedule_frequency`` (daily, weekly,
|
|
37
|
+
biweekly, monthly), ``schedule_day_of_week``, ``schedule_day_of_month``,
|
|
38
|
+
``schedule_time``, ``schedule_timezone``. ``segment_ids`` restricts the
|
|
39
|
+
newsletter's audience.
|
|
40
|
+
"""
|
|
41
|
+
return self._post("/api/v1/autopilots", {"autopilot": attrs})
|
|
42
|
+
|
|
43
|
+
def update(self, id: Id, **attrs: Any) -> Any: # noqa: A002
|
|
44
|
+
"""Pass the real key to rotate it, or omit the field. A masked key is dropped."""
|
|
45
|
+
return self._patch("/api/v1/autopilots/{}".format(id), {"autopilot": self._scrub_key(attrs)})
|
|
46
|
+
|
|
47
|
+
def delete(self, id: Id) -> Any: # noqa: A002
|
|
48
|
+
return self._delete("/api/v1/autopilots/{}".format(id))
|
|
49
|
+
|
|
50
|
+
# --- Lifecycle ---
|
|
51
|
+
|
|
52
|
+
def activate(self, id: Id) -> Any: # noqa: A002
|
|
53
|
+
"""Requires at least one active source, an API key, and a model.
|
|
54
|
+
|
|
55
|
+
Raises :class:`ValidationError` naming the missing prerequisites
|
|
56
|
+
otherwise, comma-joined.
|
|
57
|
+
"""
|
|
58
|
+
return self._post("/api/v1/autopilots/{}/activate".format(id))
|
|
59
|
+
|
|
60
|
+
def pause(self, id: Id) -> Any: # noqa: A002
|
|
61
|
+
return self._post("/api/v1/autopilots/{}/pause".format(id))
|
|
62
|
+
|
|
63
|
+
def deactivate(self, id: Id) -> Any: # noqa: A002
|
|
64
|
+
return self._post("/api/v1/autopilots/{}/deactivate".format(id))
|
|
65
|
+
|
|
66
|
+
def trigger_run(self, id: Id) -> Any: # noqa: A002
|
|
67
|
+
"""Returns 202 — generation is asynchronous, so poll :meth:`runs`."""
|
|
68
|
+
return self._post("/api/v1/autopilots/{}/trigger_run".format(id))
|
|
69
|
+
|
|
70
|
+
def runs(self, id: Id, **params: Any) -> Any: # noqa: A002
|
|
71
|
+
"""Generation runs, most recent first. Supports ``limit`` and ``offset``."""
|
|
72
|
+
return self._get("/api/v1/autopilots/{}/runs".format(id), params)
|
|
73
|
+
|
|
74
|
+
def _scrub_key(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
|
|
75
|
+
key = attrs.get("openrouter_api_key")
|
|
76
|
+
if not isinstance(key, str) or not REDACTED_KEY_PATTERN.match(key):
|
|
77
|
+
return attrs
|
|
78
|
+
|
|
79
|
+
self._warn(
|
|
80
|
+
"[broadcast-python] Dropped redacted openrouter_api_key from update payload — "
|
|
81
|
+
"pass the real key or omit the field"
|
|
82
|
+
)
|
|
83
|
+
return {k: v for k, v in attrs.items() if k != "openrouter_api_key"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Shared plumbing for resource classes.
|
|
2
|
+
|
|
3
|
+
The HTTP helpers are underscore-prefixed so a resource can expose ``get`` and
|
|
4
|
+
``delete`` as its public API without shadowing them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseResource:
|
|
11
|
+
def __init__(self, client: Any):
|
|
12
|
+
self._client = client
|
|
13
|
+
|
|
14
|
+
def _get(self, path: str, params: Optional[Dict[str, Any]] = None, raw: bool = False) -> Any:
|
|
15
|
+
return self._client.request("GET", path, params or {}, raw=raw)
|
|
16
|
+
|
|
17
|
+
def _post(
|
|
18
|
+
self,
|
|
19
|
+
path: str,
|
|
20
|
+
body: Optional[Dict[str, Any]] = None,
|
|
21
|
+
headers: Optional[Dict[str, Any]] = None,
|
|
22
|
+
) -> Any:
|
|
23
|
+
return self._client.request("POST", path, body or {}, headers=headers)
|
|
24
|
+
|
|
25
|
+
def _patch(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
|
|
26
|
+
return self._client.request("PATCH", path, body or {})
|
|
27
|
+
|
|
28
|
+
def _delete(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
|
|
29
|
+
return self._client.request("DELETE", path, body)
|
|
30
|
+
|
|
31
|
+
def _warn(self, message: str) -> None:
|
|
32
|
+
"""Emit through the configured logger, else stderr."""
|
|
33
|
+
logger = self._client.config.logger
|
|
34
|
+
if logger is not None:
|
|
35
|
+
logger.warning(message)
|
|
36
|
+
else:
|
|
37
|
+
import sys
|
|
38
|
+
|
|
39
|
+
print(message, file=sys.stderr)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compact(mapping: Dict[str, Any]) -> Dict[str, Any]:
|
|
43
|
+
"""Drop None values so an omitted argument never reaches the wire."""
|
|
44
|
+
return {k: v for k, v in mapping.items() if v is not None}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from typing import Any, Union
|
|
2
|
+
|
|
3
|
+
from .base import BaseResource
|
|
4
|
+
|
|
5
|
+
Id = Union[str, int]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Broadcasts(BaseResource):
|
|
9
|
+
def list(self, **params: Any) -> Any:
|
|
10
|
+
return self._get("/api/v1/broadcasts", params)
|
|
11
|
+
|
|
12
|
+
def get(self, id: Id) -> Any: # noqa: A002 - mirrors the API's parameter name
|
|
13
|
+
return self._get("/api/v1/broadcasts/{}".format(id))
|
|
14
|
+
|
|
15
|
+
def create(self, **attrs: Any) -> Any:
|
|
16
|
+
return self._post("/api/v1/broadcasts", attrs)
|
|
17
|
+
|
|
18
|
+
def update(self, id: Id, **attrs: Any) -> Any: # noqa: A002
|
|
19
|
+
return self._patch("/api/v1/broadcasts/{}".format(id), attrs)
|
|
20
|
+
|
|
21
|
+
def delete(self, id: Id) -> Any: # noqa: A002
|
|
22
|
+
return self._delete("/api/v1/broadcasts/{}".format(id))
|
|
23
|
+
|
|
24
|
+
def send(self, id: Id) -> Any: # noqa: A002
|
|
25
|
+
"""Sends immediately. There is no undo — the API has no unsend."""
|
|
26
|
+
return self._post("/api/v1/broadcasts/{}/send_broadcast".format(id))
|
|
27
|
+
|
|
28
|
+
def schedule(self, id: Id, scheduled_send_at: str, scheduled_timezone: str) -> Any: # noqa: A002
|
|
29
|
+
body = {"scheduled_send_at": scheduled_send_at, "scheduled_timezone": scheduled_timezone}
|
|
30
|
+
# Verb and path stay on one line: the coverage scanner is line-based,
|
|
31
|
+
# and wrapping the path onto its own line reads as unimplemented.
|
|
32
|
+
return self._post("/api/v1/broadcasts/{}/schedule_broadcast".format(id), body)
|
|
33
|
+
|
|
34
|
+
def cancel_schedule(self, id: Id) -> Any: # noqa: A002
|
|
35
|
+
return self._post("/api/v1/broadcasts/{}/cancel_schedule".format(id))
|
|
36
|
+
|
|
37
|
+
def statistics(self, id: Id) -> Any: # noqa: A002
|
|
38
|
+
return self._get("/api/v1/broadcasts/{}/statistics".format(id))
|
|
39
|
+
|
|
40
|
+
def statistics_timeline(self, id: Id, **params: Any) -> Any: # noqa: A002
|
|
41
|
+
return self._get("/api/v1/broadcasts/{}/statistics/timeline".format(id), params)
|
|
42
|
+
|
|
43
|
+
def statistics_links(self, id: Id, **params: Any) -> Any: # noqa: A002
|
|
44
|
+
return self._get("/api/v1/broadcasts/{}/statistics/links".format(id), params)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from .base import BaseResource
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Discovery(BaseResource):
|
|
7
|
+
"""Introspection endpoints.
|
|
8
|
+
|
|
9
|
+
Built for agents and CLIs that need to discover what a token can do before
|
|
10
|
+
acting, and equally useful as a deploy-time smoke check.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def whoami(self) -> Any:
|
|
14
|
+
"""Token label, type (channel_scoped or admin_cross_channel), per-resource
|
|
15
|
+
permissions, and the resolved channel."""
|
|
16
|
+
return self._get("/api/v1/whoami")
|
|
17
|
+
|
|
18
|
+
def status(self) -> Any:
|
|
19
|
+
"""Channel sender config, subscriber counts, and per-feature transmission
|
|
20
|
+
readiness. Worth calling before a send — ``readiness["broadcasts"]`` false
|
|
21
|
+
means the channel has no usable email server or sender identity."""
|
|
22
|
+
return self._get("/api/v1/status")
|
|
23
|
+
|
|
24
|
+
def prime(self) -> Any:
|
|
25
|
+
"""Full capability manifest: platform version, token permissions, channel
|
|
26
|
+
status, the endpoint list the token can reach, rate limit, and usage tips."""
|
|
27
|
+
return self._get("/api/v1/prime")
|
|
28
|
+
|
|
29
|
+
def skill(self) -> str:
|
|
30
|
+
"""Plain-text agent skill manifest (Markdown with YAML front matter),
|
|
31
|
+
including the safety rules agents are expected to follow.
|
|
32
|
+
|
|
33
|
+
Returns a ``str``, not a dict — this endpoint serves ``text/plain``.
|
|
34
|
+
"""
|
|
35
|
+
return self._get("/api/v1/skill", raw=True)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, Dict, Optional, Union
|
|
3
|
+
|
|
4
|
+
from .base import BaseResource, compact
|
|
5
|
+
|
|
6
|
+
Id = Union[str, int]
|
|
7
|
+
|
|
8
|
+
#: Fields the API returns bullet-masked. Round-tripping one of these from a
|
|
9
|
+
#: fetch into an update would replace a working credential with bullets, so
|
|
10
|
+
#: :meth:`EmailServers.update` strips them. This is a data-loss guard.
|
|
11
|
+
REDACTED_FIELDS = (
|
|
12
|
+
"smtp_password",
|
|
13
|
+
"aws_access_key_id",
|
|
14
|
+
"aws_secret_access_key",
|
|
15
|
+
"outbound_aws_access_key_id",
|
|
16
|
+
"outbound_aws_secret_access_key",
|
|
17
|
+
"postmark_api_token",
|
|
18
|
+
"inboxroad_api_token",
|
|
19
|
+
"smtp_com_api_key",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
#: Matches the API's redaction shape: 8 bullets, or 4-char prefix + bullets + 4-char suffix.
|
|
23
|
+
REDACTED_PATTERN = re.compile(r"\A(?:•{8}|.{0,4}•+.{0,4})\Z")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EmailServers(BaseResource):
|
|
27
|
+
def list(self, limit: Optional[int] = None, offset: Optional[int] = None) -> Any:
|
|
28
|
+
return self._get("/api/v1/email_servers", compact({"limit": limit, "offset": offset}))
|
|
29
|
+
|
|
30
|
+
def get(self, id: Id) -> Any: # noqa: A002
|
|
31
|
+
return self._get("/api/v1/email_servers/{}".format(id))
|
|
32
|
+
|
|
33
|
+
def create(self, **attrs: Any) -> Any:
|
|
34
|
+
return self._post("/api/v1/email_servers", {"email_server": attrs})
|
|
35
|
+
|
|
36
|
+
def update(self, id: Id, **attrs: Any) -> Any: # noqa: A002
|
|
37
|
+
"""Update an email server.
|
|
38
|
+
|
|
39
|
+
CAUTION: API responses redact credential fields with bullet characters.
|
|
40
|
+
Never echo a fetched response back into update — this method scrubs
|
|
41
|
+
values matching the redaction pattern, but you should pass only the
|
|
42
|
+
fields you actually want to change.
|
|
43
|
+
"""
|
|
44
|
+
return self._patch("/api/v1/email_servers/{}".format(id), {"email_server": self._scrub(attrs)})
|
|
45
|
+
|
|
46
|
+
def delete(self, id: Id) -> Any: # noqa: A002
|
|
47
|
+
return self._delete("/api/v1/email_servers/{}".format(id))
|
|
48
|
+
|
|
49
|
+
def test_connection(self, id: Id) -> Any: # noqa: A002
|
|
50
|
+
return self._post("/api/v1/email_servers/{}/test_connection".format(id))
|
|
51
|
+
|
|
52
|
+
def copy_to_channel(self, id: Id, target_channel_id: Id) -> Any: # noqa: A002
|
|
53
|
+
"""Requires an admin/system token. In SaaS mode the target channel is
|
|
54
|
+
scoped to the token creator's account."""
|
|
55
|
+
body = {"target_channel_id": target_channel_id}
|
|
56
|
+
return self._post("/api/v1/email_servers/{}/copy_to_channel".format(id), body)
|
|
57
|
+
|
|
58
|
+
def _scrub(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
|
|
59
|
+
scrubbed = {}
|
|
60
|
+
for key, value in attrs.items():
|
|
61
|
+
if key in REDACTED_FIELDS and isinstance(value, str) and REDACTED_PATTERN.match(value):
|
|
62
|
+
self._warn(
|
|
63
|
+
"[broadcast-python] Dropped redacted {} from update payload — "
|
|
64
|
+
"pass the real credential or omit the field".format(key)
|
|
65
|
+
)
|
|
66
|
+
continue
|
|
67
|
+
scrubbed[key] = value
|
|
68
|
+
return scrubbed
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Read-only export endpoints under ``/api/migration/v1``.
|
|
2
|
+
|
|
3
|
+
Two things differ from the v1 API:
|
|
4
|
+
|
|
5
|
+
1. **Admin tokens only.** Channel-scoped tokens are rejected outright.
|
|
6
|
+
2. **broadcast_channel_id is required on every call.** Set it once via
|
|
7
|
+
``Broadcast(broadcast_channel_id=...)`` or ``client.with_channel(id)`` and it
|
|
8
|
+
is attached automatically; otherwise pass it per call.
|
|
9
|
+
|
|
10
|
+
On a demo instance (``DEMO_MODE``) this entire API returns 403 for every
|
|
11
|
+
request, valid token or not, so a public demo cannot be used as a token oracle.
|
|
12
|
+
That surfaces here as :class:`AuthorizationError`.
|
|
13
|
+
|
|
14
|
+
Every list endpoint pages with ``limit`` (1..250, default 250) and ``offset``,
|
|
15
|
+
returning ``{"data": [...], "pagination": {...}}``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import Any, Iterator, Union
|
|
19
|
+
|
|
20
|
+
from .base import BaseResource
|
|
21
|
+
|
|
22
|
+
Id = Union[str, int]
|
|
23
|
+
|
|
24
|
+
#: Endpoints that are a plain paginated list of the channel's records.
|
|
25
|
+
COLLECTIONS = (
|
|
26
|
+
"channels",
|
|
27
|
+
"subscribers",
|
|
28
|
+
"templates",
|
|
29
|
+
"segments",
|
|
30
|
+
"sequences",
|
|
31
|
+
"email_servers",
|
|
32
|
+
"opt_in_forms",
|
|
33
|
+
"broadcasts",
|
|
34
|
+
"outbound_receipts",
|
|
35
|
+
"webhook_endpoints",
|
|
36
|
+
"tokens",
|
|
37
|
+
"suppressions",
|
|
38
|
+
"tags",
|
|
39
|
+
"users",
|
|
40
|
+
"link_redirects",
|
|
41
|
+
"link_clicks",
|
|
42
|
+
"subscriber_histories",
|
|
43
|
+
"file_assets",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Migration(BaseResource):
|
|
48
|
+
def manifest(self, **params: Any) -> Any:
|
|
49
|
+
"""Export summary: format version, channel identity, per-resource counts,
|
|
50
|
+
and recent-history totals. Call this first to size an export.
|
|
51
|
+
|
|
52
|
+
``days_history`` windows the time-bounded counts; the server clamps to 1..365.
|
|
53
|
+
"""
|
|
54
|
+
return self._get("/api/migration/v1/manifest", params)
|
|
55
|
+
|
|
56
|
+
def download_file_asset(self, id: Id, **params: Any) -> bytes: # noqa: A002
|
|
57
|
+
"""Binary contents of a stored file asset — bytes, not JSON."""
|
|
58
|
+
return self._get("/api/migration/v1/file_assets/{}/download".format(id), params, raw=True)
|
|
59
|
+
|
|
60
|
+
def each_record(self, collection: str, limit: int = 250, **params: Any) -> Iterator[Any]:
|
|
61
|
+
"""Page through a collection, yielding each record.
|
|
62
|
+
|
|
63
|
+
for sub in client.migration.each_record("subscribers"):
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
Stops when the server reports ``has_more: False``, and advances by the
|
|
67
|
+
limit the server actually applied rather than the one requested — the
|
|
68
|
+
server clamps to 250, so trusting the request would skip records.
|
|
69
|
+
"""
|
|
70
|
+
offset = 0
|
|
71
|
+
while True:
|
|
72
|
+
page = getattr(self, collection)(limit=limit, offset=offset, **params)
|
|
73
|
+
records = page.get("data") or []
|
|
74
|
+
yield from records
|
|
75
|
+
|
|
76
|
+
pagination = page.get("pagination") or {}
|
|
77
|
+
if not pagination.get("has_more"):
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# `pagination.get("limit") or len(records)` would be wrong: Python's
|
|
81
|
+
# `or` falls back on any falsy value, so a server-reported limit of
|
|
82
|
+
# 0 would become len(records) and the loop would never terminate.
|
|
83
|
+
# Ruby's `||` only falls back on nil, which is why the reference
|
|
84
|
+
# implementation can spell it that way and this one cannot.
|
|
85
|
+
advanced = pagination.get("limit")
|
|
86
|
+
if advanced is None:
|
|
87
|
+
advanced = len(records)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
advanced = int(advanced)
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
return
|
|
93
|
+
if advanced <= 0:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
offset += advanced
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _make_collection_method(name: str):
|
|
100
|
+
def method(self, **params: Any) -> Any:
|
|
101
|
+
return self._get("/api/migration/v1/{}".format(name), params)
|
|
102
|
+
|
|
103
|
+
method.__name__ = name
|
|
104
|
+
method.__qualname__ = "Migration.{}".format(name)
|
|
105
|
+
method.__doc__ = "Paginated export of the channel's {}.".format(name.replace("_", " "))
|
|
106
|
+
return method
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Generated rather than hand-written: 18 near-identical methods invite the kind
|
|
110
|
+
# of copy-paste drift this whole SDK family exists to prevent. Declared in
|
|
111
|
+
# .api-coverage.yml so the coverage report still counts them.
|
|
112
|
+
for _collection in COLLECTIONS:
|
|
113
|
+
setattr(Migration, _collection, _make_collection_method(_collection))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from datetime import date, datetime
|
|
2
|
+
from typing import Any, Optional, Union
|
|
3
|
+
|
|
4
|
+
from .base import BaseResource, compact
|
|
5
|
+
|
|
6
|
+
Id = Union[str, int]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OptInForms(BaseResource):
|
|
10
|
+
def list(self, **params: Any) -> Any:
|
|
11
|
+
"""Up to 250 per page with ``pagination`` metadata. Variants are excluded.
|
|
12
|
+
|
|
13
|
+
Optional filters: ``filter`` (label substring), ``widget_type``, ``enabled``.
|
|
14
|
+
"""
|
|
15
|
+
return self._get("/api/v1/opt_in_forms", params)
|
|
16
|
+
|
|
17
|
+
def get(self, id: Id) -> Any: # noqa: A002
|
|
18
|
+
return self._get("/api/v1/opt_in_forms/{}".format(id))
|
|
19
|
+
|
|
20
|
+
def create(self, **attrs: Any) -> Any:
|
|
21
|
+
"""Attributes are wrapped under ``opt_in_form:``.
|
|
22
|
+
|
|
23
|
+
Nested settings dicts (``theme_settings``, ``automation_settings``,
|
|
24
|
+
``security_settings``, ``trigger_settings``, ``widget_settings``) and
|
|
25
|
+
the block arrays are passed through verbatim.
|
|
26
|
+
"""
|
|
27
|
+
return self._post("/api/v1/opt_in_forms", {"opt_in_form": attrs})
|
|
28
|
+
|
|
29
|
+
def update(self, id: Id, **attrs: Any) -> Any: # noqa: A002
|
|
30
|
+
return self._patch("/api/v1/opt_in_forms/{}".format(id), {"opt_in_form": attrs})
|
|
31
|
+
|
|
32
|
+
def delete(self, id: Id) -> Any: # noqa: A002
|
|
33
|
+
return self._delete("/api/v1/opt_in_forms/{}".format(id))
|
|
34
|
+
|
|
35
|
+
def analytics(
|
|
36
|
+
self,
|
|
37
|
+
id: Id, # noqa: A002
|
|
38
|
+
start_date: Optional[Union[str, date, datetime]] = None,
|
|
39
|
+
end_date: Optional[Union[str, date, datetime]] = None,
|
|
40
|
+
) -> Any:
|
|
41
|
+
"""Performance analytics. Dates accept ``date``, ``datetime``, or ISO-8601
|
|
42
|
+
strings; the server defaults to the last 30 days."""
|
|
43
|
+
params = compact(
|
|
44
|
+
{
|
|
45
|
+
"start_date": _coerce_date(start_date) if start_date is not None else None,
|
|
46
|
+
"end_date": _coerce_date(end_date) if end_date is not None else None,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
return self._get("/api/v1/opt_in_forms/{}/analytics".format(id), params)
|
|
50
|
+
|
|
51
|
+
def create_variant(self, id: Id, name: Optional[str] = None, weight: Optional[int] = None) -> Any: # noqa: A002
|
|
52
|
+
body = compact({"name": name, "weight": weight})
|
|
53
|
+
return self._post("/api/v1/opt_in_forms/{}/variants".format(id), body)
|
|
54
|
+
|
|
55
|
+
def duplicate(self, id: Id, label: Optional[str] = None) -> Any: # noqa: A002
|
|
56
|
+
return self._post("/api/v1/opt_in_forms/{}/duplicate".format(id), compact({"label": label}))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _coerce_date(value: Union[str, date, datetime]) -> str:
|
|
60
|
+
if isinstance(value, (date, datetime)):
|
|
61
|
+
return value.isoformat()
|
|
62
|
+
return str(value)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Any, Optional, Union
|
|
2
|
+
|
|
3
|
+
from .base import BaseResource
|
|
4
|
+
|
|
5
|
+
Id = Union[str, int]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Segments(BaseResource):
|
|
9
|
+
def list(self, **params: Any) -> Any:
|
|
10
|
+
return self._get("/api/v1/segments.json", params)
|
|
11
|
+
|
|
12
|
+
def get(self, id: Id, page: Optional[int] = None) -> Any: # noqa: A002
|
|
13
|
+
"""Reading a segment recounts its members server-side, so this is not free."""
|
|
14
|
+
return self._get("/api/v1/segments/{}.json".format(id), {"page": page} if page else {})
|
|
15
|
+
|
|
16
|
+
def create(self, **attrs: Any) -> Any:
|
|
17
|
+
return self._post("/api/v1/segments", {"segment": attrs})
|
|
18
|
+
|
|
19
|
+
def update(self, id: Id, **attrs: Any) -> Any: # noqa: A002
|
|
20
|
+
return self._patch("/api/v1/segments/{}".format(id), {"segment": attrs})
|
|
21
|
+
|
|
22
|
+
def delete(self, id: Id) -> Any: # noqa: A002
|
|
23
|
+
return self._delete("/api/v1/segments/{}".format(id))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from typing import Any, Union
|
|
2
|
+
|
|
3
|
+
from .base import BaseResource
|
|
4
|
+
|
|
5
|
+
Id = Union[str, int]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Sequences(BaseResource):
|
|
9
|
+
def list(self, **params: Any) -> Any:
|
|
10
|
+
return self._get("/api/v1/sequences", params)
|
|
11
|
+
|
|
12
|
+
def get(self, id: Id, include_steps: bool = False) -> Any: # noqa: A002
|
|
13
|
+
return self._get("/api/v1/sequences/{}".format(id), {"include_steps": True} if include_steps else {})
|
|
14
|
+
|
|
15
|
+
def create(self, **attrs: Any) -> Any:
|
|
16
|
+
return self._post("/api/v1/sequences", attrs)
|
|
17
|
+
|
|
18
|
+
def update(self, id: Id, **attrs: Any) -> Any: # noqa: A002
|
|
19
|
+
return self._patch("/api/v1/sequences/{}".format(id), attrs)
|
|
20
|
+
|
|
21
|
+
def delete(self, id: Id) -> Any: # noqa: A002
|
|
22
|
+
return self._delete("/api/v1/sequences/{}".format(id))
|
|
23
|
+
|
|
24
|
+
# --- Subscriber enrollment ---
|
|
25
|
+
|
|
26
|
+
def add_subscriber(self, sequence_id: Id, **attrs: Any) -> Any:
|
|
27
|
+
return self._post("/api/v1/sequences/{}/add_subscriber".format(sequence_id), attrs)
|
|
28
|
+
|
|
29
|
+
def remove_subscriber(self, sequence_id: Id, email: str) -> Any:
|
|
30
|
+
return self._delete("/api/v1/sequences/{}/remove_subscriber".format(sequence_id), {"email": email})
|
|
31
|
+
|
|
32
|
+
def list_subscribers(self, sequence_id: Id, page: int = 1) -> Any:
|
|
33
|
+
return self._get("/api/v1/sequences/{}/list_subscribers".format(sequence_id), {"page": page})
|
|
34
|
+
|
|
35
|
+
# --- Steps ---
|
|
36
|
+
#
|
|
37
|
+
# Steps hang off the sequences resource rather than a top-level one,
|
|
38
|
+
# matching the nested routes.
|
|
39
|
+
|
|
40
|
+
def list_steps(self, sequence_id: Id) -> Any:
|
|
41
|
+
return self._get("/api/v1/sequences/{}/steps".format(sequence_id))
|
|
42
|
+
|
|
43
|
+
def get_step(self, sequence_id: Id, step_id: Id) -> Any:
|
|
44
|
+
return self._get("/api/v1/sequences/{}/steps/{}".format(sequence_id, step_id))
|
|
45
|
+
|
|
46
|
+
def create_step(self, sequence_id: Id, **attrs: Any) -> Any:
|
|
47
|
+
return self._post("/api/v1/sequences/{}/steps".format(sequence_id), attrs)
|
|
48
|
+
|
|
49
|
+
def update_step(self, sequence_id: Id, step_id: Id, **attrs: Any) -> Any:
|
|
50
|
+
return self._patch("/api/v1/sequences/{}/steps/{}".format(sequence_id, step_id), attrs)
|
|
51
|
+
|
|
52
|
+
def move_step(self, sequence_id: Id, step_id: Id, under_id: Id) -> Any:
|
|
53
|
+
"""Reorders a step to sit directly after ``under_id``."""
|
|
54
|
+
body = {"under_id": under_id}
|
|
55
|
+
return self._post("/api/v1/sequences/{}/steps/{}/move".format(sequence_id, step_id), body)
|
|
56
|
+
|
|
57
|
+
def delete_step(self, sequence_id: Id, step_id: Id) -> Any:
|
|
58
|
+
return self._delete("/api/v1/sequences/{}/steps/{}".format(sequence_id, step_id))
|