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.
@@ -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))