broadcast-python 0.5.0__tar.gz → 0.7.0__tar.gz

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 (38) hide show
  1. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/CHANGELOG.md +30 -0
  2. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/PKG-INFO +87 -5
  3. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/README.md +86 -4
  4. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/pyproject.toml +1 -1
  5. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/client.py +10 -2
  6. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/connection.py +18 -4
  7. broadcast_python-0.7.0/src/broadcast_python/multipart.py +59 -0
  8. broadcast_python-0.7.0/src/broadcast_python/resources/channel_design.py +39 -0
  9. broadcast_python-0.7.0/src/broadcast_python/resources/channel_settings.py +31 -0
  10. broadcast_python-0.7.0/src/broadcast_python/resources/file_assets.py +112 -0
  11. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/opt_in_forms.py +12 -1
  12. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/version.py +1 -1
  13. broadcast_python-0.5.0/src/broadcast_python/resources/channel_design.py +0 -24
  14. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/.gitignore +0 -0
  15. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/LICENSE +0 -0
  16. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/__init__.py +0 -0
  17. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/configuration.py +0 -0
  18. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/errors.py +0 -0
  19. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/py.typed +0 -0
  20. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/__init__.py +0 -0
  21. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/autopilots.py +0 -0
  22. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/base.py +0 -0
  23. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/broadcasts.py +0 -0
  24. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/discovery.py +0 -0
  25. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/email_servers.py +0 -0
  26. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/global_suppressions.py +0 -0
  27. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/migration.py +0 -0
  28. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/segments.py +0 -0
  29. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/sequences.py +0 -0
  30. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/subscribers.py +0 -0
  31. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/suppressions.py +0 -0
  32. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/templates.py +0 -0
  33. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/topics.py +0 -0
  34. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/transactionals.py +0 -0
  35. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/users.py +0 -0
  36. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/resources/webhook_endpoints.py +0 -0
  37. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/response.py +0 -0
  38. {broadcast_python-0.5.0 → broadcast_python-0.7.0}/src/broadcast_python/webhook.py +0 -0
@@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.7.0] - 2026-10-17
8
+
9
+ ### Added
10
+
11
+ - `TRIGGER_FREQUENCIES` includes `once_per_days` (Broadcast 2.49): the popup shows
12
+ again after `trigger_settings.frequency_cookie_days` days, a whole number, 1 or
13
+ more; blank means 30. The client still sends any word as it is, so nothing
14
+ changes for a server before 2.49, which refuses the word with 422.
15
+
16
+ ## [0.6.0] - 2026-10-16
17
+
18
+ ### Added
19
+
20
+ - `client.file_assets` for the File Assets API (Broadcast 2.48.0): `list(filter=,
21
+ limit=, offset=)`, `get(id)`, `upload(path_or_file_or_bytes, filename=,
22
+ content_type=, channel_restricted=, idempotency_key=)` (multipart),
23
+ `upload_base64(filename=, data=, ...)` (JSON) and `delete(id)`. The returned
24
+ `url` is what `channel_design.update({"brand": {"logo_url": ...}})` takes.
25
+ Deleting a brand kit logo raises `ValidationError` (422). Needs the new
26
+ `file_assets` permissions.
27
+ - multipart/form-data requests in the transport, built on the standard
28
+ library (still no runtime dependencies). The debug log never shows the body.
29
+ - `client.channel_settings.get()` and `client.channel_settings.update(settings)`
30
+ for `GET`/`PATCH /api/v1/channel/settings` (Broadcast 2.46.0): general,
31
+ sender, unsubscribe page (with the privacy policy and legal notice links),
32
+ confirmation page and opt-in forms (`opt_in_forms.match_brand_kit`) settings. Needs the new `channel_settings` permissions.
33
+ - `client.channel_design.update(kit)` for `PATCH /api/v1/channel/design`
34
+ (Broadcast 2.46.0): change the brand kit. Needs `channel_settings_write`.
35
+ The logo is the `logo_url` of an image in the channel's file library.
36
+
7
37
  ## [0.5.0] - 2026-10-10
8
38
 
9
39
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: broadcast-python
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Python client for the Broadcast email platform. Subscribers, sequences, broadcasts, segments, templates, autopilot, webhooks, and transactional email.
5
5
  Project-URL: Homepage, https://sendbroadcast.net
6
6
  Project-URL: Repository, https://github.com/send-broadcast/broadcast-python
@@ -26,7 +26,7 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  Official Python client for [Broadcast](https://sendbroadcast.net), the self-hosted email marketing platform.
28
28
 
29
- Works with any Broadcast instance — self-hosted or SaaS. Covers **117/117 API operations**, verified against the API's generated OpenAPI document.
29
+ Works with any Broadcast instance — self-hosted or SaaS. Covers **143/143 API operations**, verified against the API's generated OpenAPI document.
30
30
 
31
31
  📖 **[Python SDK documentation](https://sendbroadcast.net/docs/python-sdk)** · [API reference](https://sendbroadcast.net/docs/api-authentication) · [All docs](https://sendbroadcast.net/docs)
32
32
 
@@ -242,6 +242,8 @@ client.templates.create(label="Welcome", subject="Hi", body="...")
242
242
  client.opt_in_forms.analytics(id, start_date=date(2026, 1, 1))
243
243
  # How often the same visitor sees a popup: one of TRIGGER_FREQUENCIES, or ValidationError (422).
244
244
  client.opt_in_forms.update(id, trigger_settings={"frequency": "once_per_day"})
245
+ # Every N days (Broadcast 2.49 and later): frequency_cookie_days is a whole number, 1 or more; blank means 30.
246
+ client.opt_in_forms.update(id, trigger_settings={"frequency": "once_per_days", "frequency_cookie_days": 7})
245
247
  client.opt_in_forms.create_variant(id, name="B", weight=50)
246
248
  client.opt_in_forms.duplicate(id, label="Copy")
247
249
  ```
@@ -289,9 +291,87 @@ kit["typography"]["font_stack"] # email-safe CSS font stack
289
291
  kit["brand"]["logo_url"] # public URL, or None
290
292
  ```
291
293
 
292
- Read-only, for the token's own channel, fully resolved (defaults filled in).
293
- Needs `templates_read`. Block emails built with the drag-and-drop editor inherit
294
- these values.
294
+ For the token's own channel, fully resolved (defaults filled in). Reading
295
+ needs `templates_read` (or, from Broadcast 2.46.0, `channel_settings_read`).
296
+ Block emails built with the drag-and-drop editor inherit these values.
297
+
298
+ From Broadcast 2.46.0 you can change it. This needs `channel_settings_write`:
299
+
300
+ ```python
301
+ kit = client.channel_design.update({
302
+ "colors": {"accent": "#ff5500"},
303
+ "typography": {"font": "georgia"},
304
+ "brand": {"logo_url": "https://your-broadcast.example/files/eyJ...-logo.png"},
305
+ })
306
+ kit.warnings # e.g. a key the server did not recognize
307
+ ```
308
+
309
+ Send only what changes, in the shape `get()` returns. An empty string or
310
+ `None` puts a value back to its default, and `social_links` replaces the list.
311
+ `font_stack` and `width` are read-only. The logo must be the `logo_url` of an
312
+ image in the channel's file library; the server never fetches a URL. A value
313
+ the Brand Kit page would refuse raises `ValidationError` (422), and nothing is
314
+ saved.
315
+
316
+ ### Channel settings
317
+
318
+ ```python
319
+ settings = client.channel_settings.get()
320
+ settings["unsubscribe_page"]["legal_notice_url"] # "https://example.com/impressum"
321
+
322
+ updated = client.channel_settings.update({
323
+ "unsubscribe_page": {
324
+ "privacy_policy_url": "https://example.com/privacy",
325
+ "legal_notice_url": "https://example.com/impressum",
326
+ },
327
+ })
328
+ updated.warnings
329
+ ```
330
+
331
+ Broadcast 2.46.0 or later. The settings are grouped like the Settings pages:
332
+ `general`, `sender`, `unsubscribe_page` (with the legal links),
333
+ `confirmation_page` and `opt_in_forms` (`match_brand_kit`: whether new opt-in
334
+ forms start from the brand kit). Updates are partial: nested text and colours merge, an
335
+ empty string or `None` removes a value, and `languages` replaces the list.
336
+ Reading needs `channel_settings_read`, changing needs `channel_settings_write`;
337
+ both are off on every token until you tick them.
338
+
339
+ The `match_brand_kit` switches (`unsubscribe_page`, `confirmation_page`
340
+ and `opt_in_forms`) decide where the brand kit is used. They are set
341
+ here, not with the brand kit update.
342
+
343
+ ### File assets
344
+
345
+ The channel's file library (Broadcast 2.48.0 or later): PNG, JPEG and GIF
346
+ images under 10 MB, each with a public URL that never expires. Use the URL in
347
+ emails, or as the brand kit logo.
348
+
349
+ ```python
350
+ # Upload from a path (multipart). The content type follows the extension.
351
+ file = client.file_assets.upload("logo.png")
352
+ file["url"] # "https://your-broadcast.example/files/eyJ...-logo.png"
353
+
354
+ # From a file object or bytes, with a filename; link it to this channel only; safe to retry
355
+ client.file_assets.upload(png_bytes, filename="banner.gif", channel_restricted=True,
356
+ idempotency_key="banner-{}".format(campaign_id))
357
+
358
+ # Already have base64? Send it as JSON.
359
+ client.file_assets.upload_base64(filename="logo.png", content_type="image/png",
360
+ data=base64.b64encode(png_bytes).decode())
361
+
362
+ # Use it as the brand kit logo
363
+ client.channel_design.update({"brand": {"logo_url": file["url"]}})
364
+
365
+ client.file_assets.list(filter="logo", limit=20) # {"data": [...], "total": n}
366
+ client.file_assets.get(file["id"])
367
+ client.file_assets.delete(file["id"])
368
+ ```
369
+
370
+ A file that is not a PNG, JPEG or GIF, a file of 10 MB or more, or invalid
371
+ base64 raises `ValidationError` (422). So does deleting a file that is a
372
+ channel's brand kit logo: set another logo first. The server never fetches a
373
+ URL; upload the bytes. Reading needs `file_assets_read`; uploading and
374
+ deleting need `file_assets_write`.
295
375
 
296
376
  ### Email servers
297
377
 
@@ -470,6 +550,8 @@ integration requires.
470
550
  | Email Servers | `email_servers_read` -- list, get | `email_servers_write` -- create, update, delete, test_connection, copy_to_channel (admin) |
471
551
  | Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
472
552
  | Autopilot | `autopilot_read` -- list, get, runs | `autopilot_write` -- create, update, delete, activate, pause, deactivate, trigger_run |
553
+ | Channel Settings | `channel_settings_read` -- channel_settings.get, channel_design.get | `channel_settings_write` -- channel_settings.update, channel_design.update |
554
+ | File Assets | `file_assets_read` -- list, get | `file_assets_write` -- upload, upload_base64, delete |
473
555
  | Users (admin token only) | `users_read` -- list, get, channel_permissions, system_permissions | `users_write` -- create, update, deactivate, activate, delete, permission writes |
474
556
 
475
557
  ---
@@ -2,7 +2,7 @@
2
2
 
3
3
  Official Python client for [Broadcast](https://sendbroadcast.net), the self-hosted email marketing platform.
4
4
 
5
- Works with any Broadcast instance — self-hosted or SaaS. Covers **117/117 API operations**, verified against the API's generated OpenAPI document.
5
+ Works with any Broadcast instance — self-hosted or SaaS. Covers **143/143 API operations**, verified against the API's generated OpenAPI document.
6
6
 
7
7
  📖 **[Python SDK documentation](https://sendbroadcast.net/docs/python-sdk)** · [API reference](https://sendbroadcast.net/docs/api-authentication) · [All docs](https://sendbroadcast.net/docs)
8
8
 
@@ -218,6 +218,8 @@ client.templates.create(label="Welcome", subject="Hi", body="...")
218
218
  client.opt_in_forms.analytics(id, start_date=date(2026, 1, 1))
219
219
  # How often the same visitor sees a popup: one of TRIGGER_FREQUENCIES, or ValidationError (422).
220
220
  client.opt_in_forms.update(id, trigger_settings={"frequency": "once_per_day"})
221
+ # Every N days (Broadcast 2.49 and later): frequency_cookie_days is a whole number, 1 or more; blank means 30.
222
+ client.opt_in_forms.update(id, trigger_settings={"frequency": "once_per_days", "frequency_cookie_days": 7})
221
223
  client.opt_in_forms.create_variant(id, name="B", weight=50)
222
224
  client.opt_in_forms.duplicate(id, label="Copy")
223
225
  ```
@@ -265,9 +267,87 @@ kit["typography"]["font_stack"] # email-safe CSS font stack
265
267
  kit["brand"]["logo_url"] # public URL, or None
266
268
  ```
267
269
 
268
- Read-only, for the token's own channel, fully resolved (defaults filled in).
269
- Needs `templates_read`. Block emails built with the drag-and-drop editor inherit
270
- these values.
270
+ For the token's own channel, fully resolved (defaults filled in). Reading
271
+ needs `templates_read` (or, from Broadcast 2.46.0, `channel_settings_read`).
272
+ Block emails built with the drag-and-drop editor inherit these values.
273
+
274
+ From Broadcast 2.46.0 you can change it. This needs `channel_settings_write`:
275
+
276
+ ```python
277
+ kit = client.channel_design.update({
278
+ "colors": {"accent": "#ff5500"},
279
+ "typography": {"font": "georgia"},
280
+ "brand": {"logo_url": "https://your-broadcast.example/files/eyJ...-logo.png"},
281
+ })
282
+ kit.warnings # e.g. a key the server did not recognize
283
+ ```
284
+
285
+ Send only what changes, in the shape `get()` returns. An empty string or
286
+ `None` puts a value back to its default, and `social_links` replaces the list.
287
+ `font_stack` and `width` are read-only. The logo must be the `logo_url` of an
288
+ image in the channel's file library; the server never fetches a URL. A value
289
+ the Brand Kit page would refuse raises `ValidationError` (422), and nothing is
290
+ saved.
291
+
292
+ ### Channel settings
293
+
294
+ ```python
295
+ settings = client.channel_settings.get()
296
+ settings["unsubscribe_page"]["legal_notice_url"] # "https://example.com/impressum"
297
+
298
+ updated = client.channel_settings.update({
299
+ "unsubscribe_page": {
300
+ "privacy_policy_url": "https://example.com/privacy",
301
+ "legal_notice_url": "https://example.com/impressum",
302
+ },
303
+ })
304
+ updated.warnings
305
+ ```
306
+
307
+ Broadcast 2.46.0 or later. The settings are grouped like the Settings pages:
308
+ `general`, `sender`, `unsubscribe_page` (with the legal links),
309
+ `confirmation_page` and `opt_in_forms` (`match_brand_kit`: whether new opt-in
310
+ forms start from the brand kit). Updates are partial: nested text and colours merge, an
311
+ empty string or `None` removes a value, and `languages` replaces the list.
312
+ Reading needs `channel_settings_read`, changing needs `channel_settings_write`;
313
+ both are off on every token until you tick them.
314
+
315
+ The `match_brand_kit` switches (`unsubscribe_page`, `confirmation_page`
316
+ and `opt_in_forms`) decide where the brand kit is used. They are set
317
+ here, not with the brand kit update.
318
+
319
+ ### File assets
320
+
321
+ The channel's file library (Broadcast 2.48.0 or later): PNG, JPEG and GIF
322
+ images under 10 MB, each with a public URL that never expires. Use the URL in
323
+ emails, or as the brand kit logo.
324
+
325
+ ```python
326
+ # Upload from a path (multipart). The content type follows the extension.
327
+ file = client.file_assets.upload("logo.png")
328
+ file["url"] # "https://your-broadcast.example/files/eyJ...-logo.png"
329
+
330
+ # From a file object or bytes, with a filename; link it to this channel only; safe to retry
331
+ client.file_assets.upload(png_bytes, filename="banner.gif", channel_restricted=True,
332
+ idempotency_key="banner-{}".format(campaign_id))
333
+
334
+ # Already have base64? Send it as JSON.
335
+ client.file_assets.upload_base64(filename="logo.png", content_type="image/png",
336
+ data=base64.b64encode(png_bytes).decode())
337
+
338
+ # Use it as the brand kit logo
339
+ client.channel_design.update({"brand": {"logo_url": file["url"]}})
340
+
341
+ client.file_assets.list(filter="logo", limit=20) # {"data": [...], "total": n}
342
+ client.file_assets.get(file["id"])
343
+ client.file_assets.delete(file["id"])
344
+ ```
345
+
346
+ A file that is not a PNG, JPEG or GIF, a file of 10 MB or more, or invalid
347
+ base64 raises `ValidationError` (422). So does deleting a file that is a
348
+ channel's brand kit logo: set another logo first. The server never fetches a
349
+ URL; upload the bytes. Reading needs `file_assets_read`; uploading and
350
+ deleting need `file_assets_write`.
271
351
 
272
352
  ### Email servers
273
353
 
@@ -446,6 +526,8 @@ integration requires.
446
526
  | Email Servers | `email_servers_read` -- list, get | `email_servers_write` -- create, update, delete, test_connection, copy_to_channel (admin) |
447
527
  | Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
448
528
  | Autopilot | `autopilot_read` -- list, get, runs | `autopilot_write` -- create, update, delete, activate, pause, deactivate, trigger_run |
529
+ | Channel Settings | `channel_settings_read` -- channel_settings.get, channel_design.get | `channel_settings_write` -- channel_settings.update, channel_design.update |
530
+ | File Assets | `file_assets_read` -- list, get | `file_assets_write` -- upload, upload_base64, delete |
449
531
  | Users (admin token only) | `users_read` -- list, get, channel_permissions, system_permissions | `users_write` -- create, update, deactivate, activate, delete, permission writes |
450
532
 
451
533
  ---
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "broadcast-python"
7
- version = "0.5.0"
7
+ version = "0.7.0"
8
8
  description = "Python client for the Broadcast email platform. Subscribers, sequences, broadcasts, segments, templates, autopilot, webhooks, and transactional email."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -6,8 +6,10 @@ from .connection import Connection
6
6
  from .resources.autopilots import Autopilots
7
7
  from .resources.broadcasts import Broadcasts
8
8
  from .resources.channel_design import ChannelDesign
9
+ from .resources.channel_settings import ChannelSettings
9
10
  from .resources.discovery import Discovery
10
11
  from .resources.email_servers import EmailServers
12
+ from .resources.file_assets import FileAssets
11
13
  from .resources.global_suppressions import GlobalSuppressions
12
14
  from .resources.migration import Migration
13
15
  from .resources.opt_in_forms import OptInForms
@@ -43,8 +45,13 @@ class Broadcast:
43
45
  self.segments = Segments(self)
44
46
  self.topics = Topics(self)
45
47
  self.templates = Templates(self)
46
- #: The channel's brand kit (Settings -> Design). Read-only.
48
+ #: The channel's brand kit (Settings -> Brand Kit). Update needs Broadcast 2.46.0.
47
49
  self.channel_design = ChannelDesign(self)
50
+ #: The channel's settings (Broadcast 2.46.0 or later).
51
+ self.channel_settings = ChannelSettings(self)
52
+ #: The channel's file library: upload images and get their public URLs
53
+ #: (Broadcast 2.48.0 or later).
54
+ self.file_assets = FileAssets(self)
48
55
  self.webhook_endpoints = WebhookEndpoints(self)
49
56
  self.transactionals = Transactionals(self)
50
57
  self.opt_in_forms = OptInForms(self)
@@ -126,9 +133,10 @@ class Broadcast:
126
133
  body_or_params: Any = None,
127
134
  headers: Optional[Dict[str, Any]] = None,
128
135
  raw: bool = False,
136
+ multipart: bool = False,
129
137
  ) -> Any:
130
138
  payload = self._inject_channel_scope(body_or_params)
131
- return self._connection.request(method, path, payload, headers=headers, raw=raw)
139
+ return self._connection.request(method, path, payload, headers=headers, raw=raw, multipart=multipart)
132
140
 
133
141
  @property
134
142
  def _active_channel_id(self) -> Optional[Id]:
@@ -17,6 +17,7 @@ from typing import Any, Dict, List, Optional, Tuple
17
17
  from urllib.error import HTTPError, URLError
18
18
  from urllib.parse import urlencode, urljoin, urlparse
19
19
 
20
+ from . import multipart as multipart_body
20
21
  from .configuration import Configuration
21
22
  from .errors import (
22
23
  APIError,
@@ -66,10 +67,13 @@ class Connection:
66
67
  payload: Any = None,
67
68
  headers: Optional[Dict[str, Any]] = None,
68
69
  raw: bool = False,
70
+ multipart: bool = False,
69
71
  ) -> Any:
72
+ """``multipart=True`` sends the payload as multipart/form-data
73
+ (``FilePart`` values become file parts) instead of JSON."""
70
74
  url = self._build_url(path, method, payload)
71
75
  return self._retry_with_backoff(
72
- lambda: self._execute(method, url, payload, headers or {}, raw, 0)
76
+ lambda: self._execute(method, url, payload, headers or {}, raw, 0, multipart)
73
77
  )
74
78
 
75
79
  # --- Request building ---------------------------------------------------
@@ -81,15 +85,25 @@ class Connection:
81
85
  return url
82
86
 
83
87
  def _execute(
84
- self, method: str, url: str, payload: Any, extra_headers: Dict[str, Any], raw: bool, redirects: int
88
+ self,
89
+ method: str,
90
+ url: str,
91
+ payload: Any,
92
+ extra_headers: Dict[str, Any],
93
+ raw: bool,
94
+ redirects: int,
95
+ multipart: bool = False,
85
96
  ) -> Any:
86
97
  body = None
87
- if method != "GET" and _present(payload):
98
+ content_type = "application/json"
99
+ if multipart:
100
+ body, content_type = multipart_body.build(payload or {})
101
+ elif method != "GET" and _present(payload):
88
102
  body = json.dumps(payload).encode("utf-8")
89
103
 
90
104
  request = urllib.request.Request(url, data=body, method=method)
91
105
  request.add_header("Authorization", "Bearer {}".format(self.config.api_token))
92
- request.add_header("Content-Type", "application/json")
106
+ request.add_header("Content-Type", content_type)
93
107
  request.add_header("User-Agent", "broadcast-python/{}".format(VERSION))
94
108
  for key, value in (extra_headers or {}).items():
95
109
  if value is None:
@@ -0,0 +1,59 @@
1
+ """multipart/form-data bodies, built by hand on the standard library.
2
+
3
+ urllib has no multipart encoder, and the package takes no runtime
4
+ dependencies, so the body is assembled here. It is built from bytes (not a
5
+ stream) on every attempt, which keeps a retry safe.
6
+ """
7
+
8
+ import uuid
9
+ from typing import Any, Dict, NamedTuple, Tuple
10
+
11
+
12
+ class FilePart(NamedTuple):
13
+ """A file in a multipart request: the bytes, and the filename and content
14
+ type sent with them."""
15
+
16
+ data: bytes
17
+ filename: str
18
+ content_type: str
19
+
20
+ def __repr__(self) -> str:
21
+ # Never the bytes: a repr ends up in logs and tracebacks.
22
+ return "FilePart({!r}, {}, {} bytes)".format(self.filename, self.content_type, len(self.data))
23
+
24
+
25
+ def build(fields: Dict[str, Any]) -> Tuple[bytes, str]:
26
+ """Returns the body and the Content-Type header. ``None`` values are left
27
+ out; booleans are sent as ``true``/``false``, as Rails reads them."""
28
+ boundary = "BroadcastPython{}".format(uuid.uuid4().hex)
29
+ body = bytearray()
30
+
31
+ for name, value in fields.items():
32
+ if value is None:
33
+ continue
34
+
35
+ body += "--{}\r\n".format(boundary).encode("ascii")
36
+ if isinstance(value, FilePart):
37
+ body += 'Content-Disposition: form-data; name="{}"; filename="{}"\r\n'.format(
38
+ _quote(name), _quote(value.filename)
39
+ ).encode("utf-8")
40
+ body += "Content-Type: {}\r\n\r\n".format(value.content_type).encode("utf-8")
41
+ body += value.data
42
+ else:
43
+ body += 'Content-Disposition: form-data; name="{}"\r\n\r\n'.format(_quote(name)).encode("utf-8")
44
+ body += _stringify(value).encode("utf-8")
45
+ body += b"\r\n"
46
+
47
+ body += "--{}--\r\n".format(boundary).encode("ascii")
48
+ return bytes(body), "multipart/form-data; boundary={}".format(boundary)
49
+
50
+
51
+ def _quote(value: Any) -> str:
52
+ """A quote or a line break in a name would end the header early."""
53
+ return str(value).replace("\r", " ").replace("\n", " ").replace('"', "%22")
54
+
55
+
56
+ def _stringify(value: Any) -> str:
57
+ if isinstance(value, bool):
58
+ return "true" if value else "false"
59
+ return str(value)
@@ -0,0 +1,39 @@
1
+ from typing import Any, Dict
2
+
3
+ from .base import BaseResource
4
+
5
+
6
+ class ChannelDesign(BaseResource):
7
+ """The brand kit of the token's channel (Settings -> Brand Kit).
8
+
9
+ Reading requires the ``templates_read`` permission (the kit is design
10
+ metadata for templates) or, from Broadcast 2.46.0, ``channel_settings_read``.
11
+ Changing it requires ``channel_settings_write`` (Broadcast 2.46.0 or later).
12
+ There is no channel argument; the server reads the channel the token
13
+ resolves to (for an admin token, the one set with ``with_channel`` or
14
+ ``broadcast_channel_id``).
15
+ """
16
+
17
+ def get(self) -> Any:
18
+ """The channel's brand kit, fully resolved (defaults filled in).
19
+
20
+ Returns ``colors``, ``typography`` (``font`` key and the email-safe
21
+ ``font_stack``), ``layout`` (``width``, ``radius``), and ``brand``
22
+ (``logo_url`` as a public URL or None, ``logo_width``, ``website_url``,
23
+ ``social_links``, ``social_icon_style``). Block emails built with the
24
+ drag-and-drop editor inherit these values.
25
+ """
26
+ return self._get("/api/v1/channel/design")
27
+
28
+ def update(self, design: Dict[str, Any]) -> Any:
29
+ """Change the brand kit (Broadcast 2.46.0 or later).
30
+
31
+ Partial: send only what changes, in the shape ``get()`` returns. An
32
+ empty string or None puts a value back to its default;
33
+ ``social_links`` replaces the list; ``font_stack`` and ``width`` are
34
+ read-only. Set the logo with the ``logo_url`` of an image in the
35
+ channel's file library (the server never fetches a URL). Returns the
36
+ whole kit after the change; read ``result.warnings`` for anything the
37
+ server ignored.
38
+ """
39
+ return self._patch("/api/v1/channel/design", {"channel_design": design})
@@ -0,0 +1,31 @@
1
+ from typing import Any, Dict
2
+
3
+ from .base import BaseResource
4
+
5
+
6
+ class ChannelSettings(BaseResource):
7
+ """The settings of the token's channel (Broadcast 2.46.0 or later).
8
+
9
+ Grouped like the Settings pages: ``general``, ``sender``,
10
+ ``unsubscribe_page`` (with the privacy policy and legal notice links),
11
+ ``confirmation_page`` and ``opt_in_forms`` (``match_brand_kit``: whether
12
+ new opt-in forms start from the brand kit). The brand kit switches are set
13
+ here, not with ``channel_design.update()``. Reading requires ``channel_settings_read`` and
14
+ changing requires ``channel_settings_write``. There is no channel
15
+ argument; the server reads the channel the token resolves to.
16
+ """
17
+
18
+ def get(self) -> Any:
19
+ """Every channel setting, with defaults resolved."""
20
+ return self._get("/api/v1/channel/settings")
21
+
22
+ def update(self, settings: Dict[str, Any]) -> Any:
23
+ """Change channel settings.
24
+
25
+ Partial: send only the groups and keys that change. Nested text and
26
+ colours merge, an empty string or None removes a value, and
27
+ ``languages`` replaces the list. Returns every setting after the
28
+ change; read ``result.warnings`` for anything the server ignored or
29
+ changed.
30
+ """
31
+ return self._patch("/api/v1/channel/settings", {"channel_settings": settings})
@@ -0,0 +1,112 @@
1
+ import os
2
+ from typing import IO, Any, Optional, Tuple, Union
3
+
4
+ from ..multipart import FilePart
5
+ from .base import BaseResource, compact
6
+ from .transactionals import _idempotency_headers
7
+
8
+ Id = Union[str, int]
9
+
10
+ CONTENT_TYPES = {
11
+ ".png": "image/png",
12
+ ".jpg": "image/jpeg",
13
+ ".jpeg": "image/jpeg",
14
+ ".gif": "image/gif",
15
+ }
16
+
17
+
18
+ class FileAssets(BaseResource):
19
+ """The channel's file library (Broadcast 2.48.0 or later): images (PNG,
20
+ JPEG, GIF, under 10 MB) with public URLs that never expire. Use a file's
21
+ ``url`` in emails, or as the brand kit logo::
22
+
23
+ file = client.file_assets.upload("logo.png")
24
+ client.channel_design.update({"brand": {"logo_url": file["url"]}})
25
+
26
+ Reading needs ``file_assets_read``; uploading and deleting need
27
+ ``file_assets_write``.
28
+ """
29
+
30
+ def list(self, **params: Any) -> Any:
31
+ """Files the channel can see, newest first. ``filter`` searches file
32
+ names (closest matches first); ``limit`` and ``offset`` page.
33
+
34
+ Returns ``{"data": [...], "total": n}``.
35
+ """
36
+ return self._get("/api/v1/file_assets", params)
37
+
38
+ def get(self, id: Id) -> Any: # noqa: A002
39
+ return self._get("/api/v1/file_assets/{}".format(id), {})
40
+
41
+ def upload(
42
+ self,
43
+ file: Union[str, "os.PathLike[str]", bytes, IO[bytes]],
44
+ filename: Optional[str] = None,
45
+ content_type: Optional[str] = None,
46
+ channel_restricted: Optional[bool] = None,
47
+ idempotency_key: Optional[str] = None,
48
+ ) -> Any:
49
+ """Upload a file as multipart/form-data.
50
+
51
+ ``file`` is a path, a binary file object, or bytes. Bytes, and a file
52
+ object without a ``name``, need ``filename``. The content type follows
53
+ the file extension unless ``content_type`` is given.
54
+
55
+ ``channel_restricted=True`` links the file to this channel only (the
56
+ default is every channel, as in the web app). ``idempotency_key``
57
+ makes a retry safe; see ``transactionals.create``.
58
+ """
59
+ data, name = _read_file(file, filename)
60
+ part = FilePart(data, name, content_type or _content_type_for(name))
61
+ fields = compact({"file": part, "channel_restricted": channel_restricted})
62
+
63
+ headers = _idempotency_headers(idempotency_key)
64
+ return self._client.request("POST", "/api/v1/file_assets", fields, headers=headers, multipart=True)
65
+
66
+ def upload_base64(
67
+ self,
68
+ filename: str,
69
+ data: str,
70
+ content_type: Optional[str] = None,
71
+ channel_restricted: Optional[bool] = None,
72
+ idempotency_key: Optional[str] = None,
73
+ ) -> Any:
74
+ """Upload base64-encoded bytes in a JSON body, for callers that
75
+ already hold base64 (or cannot send multipart)."""
76
+ payload = compact(
77
+ {
78
+ "filename": filename,
79
+ "data": data,
80
+ "content_type": content_type,
81
+ "channel_restricted": channel_restricted,
82
+ }
83
+ )
84
+ return self._post("/api/v1/file_assets", payload, headers=_idempotency_headers(idempotency_key))
85
+
86
+ def delete(self, id: Id) -> Any: # noqa: A002
87
+ """A file that is a channel's brand kit logo is refused with
88
+ ``ValidationError`` (422): choose another logo first."""
89
+ return self._delete("/api/v1/file_assets/{}".format(id))
90
+
91
+
92
+ def _read_file(file: Any, filename: Optional[str]) -> Tuple[bytes, str]:
93
+ if isinstance(file, (bytes, bytearray)):
94
+ if filename is None:
95
+ raise ValueError("filename is required when uploading bytes")
96
+ return bytes(file), filename
97
+
98
+ if hasattr(file, "read"):
99
+ name = filename
100
+ if name is None and isinstance(getattr(file, "name", None), str):
101
+ name = os.path.basename(file.name)
102
+ if name is None:
103
+ raise ValueError("filename is required when uploading from a file object without a name")
104
+ return file.read(), name
105
+
106
+ path = os.fspath(file)
107
+ with open(path, "rb") as f:
108
+ return f.read(), filename or os.path.basename(path)
109
+
110
+
111
+ def _content_type_for(name: str) -> str:
112
+ return CONTENT_TYPES.get(os.path.splitext(name)[1].lower(), "application/octet-stream")
@@ -9,7 +9,18 @@ Id = Union[str, int]
9
9
  #: visitor sees a popup. ``always`` and ``every_visit`` mean the same. The server
10
10
  #: refuses any other word with 422 (:class:`ValidationError`); the client does
11
11
  #: not check, so an older server is never refused a word it would take.
12
- TRIGGER_FREQUENCIES = ("always", "every_visit", "once_per_session", "once_per_day", "once_per_week", "once")
12
+ #: ``once_per_days`` (Broadcast 2.49 and later) shows the popup again after
13
+ #: ``trigger_settings["frequency_cookie_days"]`` days: a whole number, 1 or
14
+ #: more; blank means 30.
15
+ TRIGGER_FREQUENCIES = (
16
+ "always",
17
+ "every_visit",
18
+ "once_per_session",
19
+ "once_per_day",
20
+ "once_per_week",
21
+ "once",
22
+ "once_per_days",
23
+ )
13
24
 
14
25
 
15
26
  class OptInForms(BaseResource):
@@ -1,3 +1,3 @@
1
1
  # Kept in sync with pyproject.toml by tests/test_package.py — a User-Agent that
2
2
  # lies about its version misattributes server-side client analytics.
3
- VERSION = "0.5.0"
3
+ VERSION = "0.7.0"
@@ -1,24 +0,0 @@
1
- from typing import Any
2
-
3
- from .base import BaseResource
4
-
5
-
6
- class ChannelDesign(BaseResource):
7
- """The brand kit of the token's channel (Settings -> Design). Read-only.
8
-
9
- Requires the ``templates_read`` permission: the kit is design metadata for
10
- templates. There is no channel argument; the server reads the channel the
11
- token resolves to (for an admin token, the one set with ``with_channel`` or
12
- ``broadcast_channel_id``).
13
- """
14
-
15
- def get(self) -> Any:
16
- """The channel's brand kit, fully resolved (defaults filled in).
17
-
18
- Returns ``colors``, ``typography`` (``font`` key and the email-safe
19
- ``font_stack``), ``layout`` (``width``, ``radius``), and ``brand``
20
- (``logo_url`` as a public URL or None, ``logo_width``, ``website_url``,
21
- ``social_links``, ``social_icon_style``). Block emails built with the
22
- drag-and-drop editor inherit these values.
23
- """
24
- return self._get("/api/v1/channel/design")