broadcast-python 0.4.0__tar.gz → 0.6.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.4.0 → broadcast_python-0.6.0}/CHANGELOG.md +47 -0
  2. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/PKG-INFO +94 -6
  3. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/README.md +93 -5
  4. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/pyproject.toml +1 -1
  5. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/__init__.py +2 -0
  6. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/client.py +10 -2
  7. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/connection.py +18 -4
  8. broadcast_python-0.6.0/src/broadcast_python/multipart.py +59 -0
  9. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/autopilots.py +2 -1
  10. broadcast_python-0.6.0/src/broadcast_python/resources/channel_design.py +39 -0
  11. broadcast_python-0.6.0/src/broadcast_python/resources/channel_settings.py +31 -0
  12. broadcast_python-0.6.0/src/broadcast_python/resources/file_assets.py +112 -0
  13. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/opt_in_forms.py +9 -0
  14. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/subscribers.py +5 -1
  15. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/version.py +1 -1
  16. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/webhook.py +2 -0
  17. broadcast_python-0.4.0/src/broadcast_python/resources/channel_design.py +0 -24
  18. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/.gitignore +0 -0
  19. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/LICENSE +0 -0
  20. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/configuration.py +0 -0
  21. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/errors.py +0 -0
  22. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/py.typed +0 -0
  23. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/__init__.py +0 -0
  24. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/base.py +0 -0
  25. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/broadcasts.py +0 -0
  26. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/discovery.py +0 -0
  27. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/email_servers.py +0 -0
  28. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/global_suppressions.py +0 -0
  29. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/migration.py +0 -0
  30. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/segments.py +0 -0
  31. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/sequences.py +0 -0
  32. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/suppressions.py +0 -0
  33. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/templates.py +0 -0
  34. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/topics.py +0 -0
  35. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/transactionals.py +0 -0
  36. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/users.py +0 -0
  37. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/webhook_endpoints.py +0 -0
  38. {broadcast_python-0.4.0 → broadcast_python-0.6.0}/src/broadcast_python/response.py +0 -0
@@ -4,6 +4,53 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.0] - 2026-10-16
8
+
9
+ ### Added
10
+
11
+ - `client.file_assets` for the File Assets API (Broadcast 2.48.0): `list(filter=,
12
+ limit=, offset=)`, `get(id)`, `upload(path_or_file_or_bytes, filename=,
13
+ content_type=, channel_restricted=, idempotency_key=)` (multipart),
14
+ `upload_base64(filename=, data=, ...)` (JSON) and `delete(id)`. The returned
15
+ `url` is what `channel_design.update({"brand": {"logo_url": ...}})` takes.
16
+ Deleting a brand kit logo raises `ValidationError` (422). Needs the new
17
+ `file_assets` permissions.
18
+ - multipart/form-data requests in the transport, built on the standard
19
+ library (still no runtime dependencies). The debug log never shows the body.
20
+ - `client.channel_settings.get()` and `client.channel_settings.update(settings)`
21
+ for `GET`/`PATCH /api/v1/channel/settings` (Broadcast 2.46.0): general,
22
+ sender, unsubscribe page (with the privacy policy and legal notice links),
23
+ confirmation page and opt-in forms (`opt_in_forms.match_brand_kit`) settings. Needs the new `channel_settings` permissions.
24
+ - `client.channel_design.update(kit)` for `PATCH /api/v1/channel/design`
25
+ (Broadcast 2.46.0): change the brand kit. Needs `channel_settings_write`.
26
+ The logo is the `logo_url` of an image in the channel's file library.
27
+
28
+ ## [0.5.0] - 2026-10-10
29
+
30
+ ### Added
31
+
32
+ - `email.send_delayed` and `broadcast.batch_completed` in `EVENT_TYPES` (and in
33
+ `EMAIL_EVENTS` / `BROADCAST_EVENTS`), matching the server (Broadcast 2.43.0).
34
+ `email.send_delayed` fires when a transactional email waits for room under
35
+ its server's hourly limit.
36
+ - `excluded_segment_ids` on broadcasts, sequences and autopilots: segments a
37
+ send never reaches. The server refuses a segment that is both sent to and
38
+ excluded with 422.
39
+ - `subscribers.create(unsubscribed_at=...)`: creates a subscriber who has
40
+ already unsubscribed, in one request, with any token. Before, the server
41
+ ignored the field and a migration had to create, then unsubscribe.
42
+
43
+ - `TRIGGER_FREQUENCIES`: the words `trigger_settings["frequency"]` accepts
44
+ (`always`, `every_visit`, `once_per_session`, `once_per_day`,
45
+ `once_per_week`, `once`).
46
+
47
+ ### Changed
48
+
49
+ - Opt-in forms: the server now refuses an unknown `trigger_settings`
50
+ frequency word with 422 (`ValidationError`). Before, it saved any word, and
51
+ an unknown one showed the popup on every visit. The client still sends what
52
+ it is given.
53
+
7
54
  ## [0.4.0] - 2026-10-06
8
55
 
9
56
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: broadcast-python
3
- Version: 0.4.0
3
+ Version: 0.6.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
 
@@ -240,6 +240,8 @@ client.sequences.move_step(id, step_id, under_id)
240
240
  client.segments.create(name="VIPs")
241
241
  client.templates.create(label="Welcome", subject="Hi", body="...")
242
242
  client.opt_in_forms.analytics(id, start_date=date(2026, 1, 1))
243
+ # How often the same visitor sees a popup: one of TRIGGER_FREQUENCIES, or ValidationError (422).
244
+ client.opt_in_forms.update(id, trigger_settings={"frequency": "once_per_day"})
243
245
  client.opt_in_forms.create_variant(id, name="B", weight=50)
244
246
  client.opt_in_forms.duplicate(id, label="Copy")
245
247
  ```
@@ -264,6 +266,12 @@ client.topics.delete(topic["id"]) # 422 while a broadcast or sequence uses it
264
266
  # Send to a topic: (segments) AND topic
265
267
  client.broadcasts.create(subject="Webinar", body="...", segment_ids=[2], topic_id=topic["id"])
266
268
 
269
+ # Send to everyone except a segment (also on sequences and autopilots)
270
+ client.broadcasts.create(subject="Offer", body="...", excluded_segment_ids=[competitors["id"]])
271
+
272
+ # Migrate someone who already unsubscribed, in one request
273
+ client.subscribers.create(email="opted-out@example.com", unsubscribed_at="2025-03-14T09:30:00Z")
274
+
267
275
  # Change only the keys you send (None deletes a key); the default replaces custom_data
268
276
  client.subscribers.update("jane@example.com", custom_data={"sub_webinars": False}, custom_data_mode="merge")
269
277
  ```
@@ -281,9 +289,87 @@ kit["typography"]["font_stack"] # email-safe CSS font stack
281
289
  kit["brand"]["logo_url"] # public URL, or None
282
290
  ```
283
291
 
284
- Read-only, for the token's own channel, fully resolved (defaults filled in).
285
- Needs `templates_read`. Block emails built with the drag-and-drop editor inherit
286
- these values.
292
+ For the token's own channel, fully resolved (defaults filled in). Reading
293
+ needs `templates_read` (or, from Broadcast 2.46.0, `channel_settings_read`).
294
+ Block emails built with the drag-and-drop editor inherit these values.
295
+
296
+ From Broadcast 2.46.0 you can change it. This needs `channel_settings_write`:
297
+
298
+ ```python
299
+ kit = client.channel_design.update({
300
+ "colors": {"accent": "#ff5500"},
301
+ "typography": {"font": "georgia"},
302
+ "brand": {"logo_url": "https://your-broadcast.example/files/eyJ...-logo.png"},
303
+ })
304
+ kit.warnings # e.g. a key the server did not recognize
305
+ ```
306
+
307
+ Send only what changes, in the shape `get()` returns. An empty string or
308
+ `None` puts a value back to its default, and `social_links` replaces the list.
309
+ `font_stack` and `width` are read-only. The logo must be the `logo_url` of an
310
+ image in the channel's file library; the server never fetches a URL. A value
311
+ the Brand Kit page would refuse raises `ValidationError` (422), and nothing is
312
+ saved.
313
+
314
+ ### Channel settings
315
+
316
+ ```python
317
+ settings = client.channel_settings.get()
318
+ settings["unsubscribe_page"]["legal_notice_url"] # "https://example.com/impressum"
319
+
320
+ updated = client.channel_settings.update({
321
+ "unsubscribe_page": {
322
+ "privacy_policy_url": "https://example.com/privacy",
323
+ "legal_notice_url": "https://example.com/impressum",
324
+ },
325
+ })
326
+ updated.warnings
327
+ ```
328
+
329
+ Broadcast 2.46.0 or later. The settings are grouped like the Settings pages:
330
+ `general`, `sender`, `unsubscribe_page` (with the legal links),
331
+ `confirmation_page` and `opt_in_forms` (`match_brand_kit`: whether new opt-in
332
+ forms start from the brand kit). Updates are partial: nested text and colours merge, an
333
+ empty string or `None` removes a value, and `languages` replaces the list.
334
+ Reading needs `channel_settings_read`, changing needs `channel_settings_write`;
335
+ both are off on every token until you tick them.
336
+
337
+ The `match_brand_kit` switches (`unsubscribe_page`, `confirmation_page`
338
+ and `opt_in_forms`) decide where the brand kit is used. They are set
339
+ here, not with the brand kit update.
340
+
341
+ ### File assets
342
+
343
+ The channel's file library (Broadcast 2.48.0 or later): PNG, JPEG and GIF
344
+ images under 10 MB, each with a public URL that never expires. Use the URL in
345
+ emails, or as the brand kit logo.
346
+
347
+ ```python
348
+ # Upload from a path (multipart). The content type follows the extension.
349
+ file = client.file_assets.upload("logo.png")
350
+ file["url"] # "https://your-broadcast.example/files/eyJ...-logo.png"
351
+
352
+ # From a file object or bytes, with a filename; link it to this channel only; safe to retry
353
+ client.file_assets.upload(png_bytes, filename="banner.gif", channel_restricted=True,
354
+ idempotency_key="banner-{}".format(campaign_id))
355
+
356
+ # Already have base64? Send it as JSON.
357
+ client.file_assets.upload_base64(filename="logo.png", content_type="image/png",
358
+ data=base64.b64encode(png_bytes).decode())
359
+
360
+ # Use it as the brand kit logo
361
+ client.channel_design.update({"brand": {"logo_url": file["url"]}})
362
+
363
+ client.file_assets.list(filter="logo", limit=20) # {"data": [...], "total": n}
364
+ client.file_assets.get(file["id"])
365
+ client.file_assets.delete(file["id"])
366
+ ```
367
+
368
+ A file that is not a PNG, JPEG or GIF, a file of 10 MB or more, or invalid
369
+ base64 raises `ValidationError` (422). So does deleting a file that is a
370
+ channel's brand kit logo: set another logo first. The server never fetches a
371
+ URL; upload the bytes. Reading needs `file_assets_read`; uploading and
372
+ deleting need `file_assets_write`.
287
373
 
288
374
  ### Email servers
289
375
 
@@ -439,7 +525,7 @@ every rejection rather than distinguishing them.
439
525
  Pass the **raw** request body. Re-serialising a parsed dict changes the bytes
440
526
  and verification will fail.
441
527
 
442
- `broadcast_python.EVENT_TYPES` lists all 35 event names. `SUBSCRIBER_EVENTS`
528
+ `broadcast_python.EVENT_TYPES` lists all 37 event names. `SUBSCRIBER_EVENTS`
443
529
  includes `subscribers.purged` and `subscribers.purge_failed`: a purge of the
444
530
  whole list sends one of these instead of a `subscriber.deleted` per subscriber.
445
531
 
@@ -462,6 +548,8 @@ integration requires.
462
548
  | Email Servers | `email_servers_read` -- list, get | `email_servers_write` -- create, update, delete, test_connection, copy_to_channel (admin) |
463
549
  | Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
464
550
  | Autopilot | `autopilot_read` -- list, get, runs | `autopilot_write` -- create, update, delete, activate, pause, deactivate, trigger_run |
551
+ | Channel Settings | `channel_settings_read` -- channel_settings.get, channel_design.get | `channel_settings_write` -- channel_settings.update, channel_design.update |
552
+ | File Assets | `file_assets_read` -- list, get | `file_assets_write` -- upload, upload_base64, delete |
465
553
  | Users (admin token only) | `users_read` -- list, get, channel_permissions, system_permissions | `users_write` -- create, update, deactivate, activate, delete, permission writes |
466
554
 
467
555
  ---
@@ -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
 
@@ -216,6 +216,8 @@ client.sequences.move_step(id, step_id, under_id)
216
216
  client.segments.create(name="VIPs")
217
217
  client.templates.create(label="Welcome", subject="Hi", body="...")
218
218
  client.opt_in_forms.analytics(id, start_date=date(2026, 1, 1))
219
+ # How often the same visitor sees a popup: one of TRIGGER_FREQUENCIES, or ValidationError (422).
220
+ client.opt_in_forms.update(id, trigger_settings={"frequency": "once_per_day"})
219
221
  client.opt_in_forms.create_variant(id, name="B", weight=50)
220
222
  client.opt_in_forms.duplicate(id, label="Copy")
221
223
  ```
@@ -240,6 +242,12 @@ client.topics.delete(topic["id"]) # 422 while a broadcast or sequence uses it
240
242
  # Send to a topic: (segments) AND topic
241
243
  client.broadcasts.create(subject="Webinar", body="...", segment_ids=[2], topic_id=topic["id"])
242
244
 
245
+ # Send to everyone except a segment (also on sequences and autopilots)
246
+ client.broadcasts.create(subject="Offer", body="...", excluded_segment_ids=[competitors["id"]])
247
+
248
+ # Migrate someone who already unsubscribed, in one request
249
+ client.subscribers.create(email="opted-out@example.com", unsubscribed_at="2025-03-14T09:30:00Z")
250
+
243
251
  # Change only the keys you send (None deletes a key); the default replaces custom_data
244
252
  client.subscribers.update("jane@example.com", custom_data={"sub_webinars": False}, custom_data_mode="merge")
245
253
  ```
@@ -257,9 +265,87 @@ kit["typography"]["font_stack"] # email-safe CSS font stack
257
265
  kit["brand"]["logo_url"] # public URL, or None
258
266
  ```
259
267
 
260
- Read-only, for the token's own channel, fully resolved (defaults filled in).
261
- Needs `templates_read`. Block emails built with the drag-and-drop editor inherit
262
- these values.
268
+ For the token's own channel, fully resolved (defaults filled in). Reading
269
+ needs `templates_read` (or, from Broadcast 2.46.0, `channel_settings_read`).
270
+ Block emails built with the drag-and-drop editor inherit these values.
271
+
272
+ From Broadcast 2.46.0 you can change it. This needs `channel_settings_write`:
273
+
274
+ ```python
275
+ kit = client.channel_design.update({
276
+ "colors": {"accent": "#ff5500"},
277
+ "typography": {"font": "georgia"},
278
+ "brand": {"logo_url": "https://your-broadcast.example/files/eyJ...-logo.png"},
279
+ })
280
+ kit.warnings # e.g. a key the server did not recognize
281
+ ```
282
+
283
+ Send only what changes, in the shape `get()` returns. An empty string or
284
+ `None` puts a value back to its default, and `social_links` replaces the list.
285
+ `font_stack` and `width` are read-only. The logo must be the `logo_url` of an
286
+ image in the channel's file library; the server never fetches a URL. A value
287
+ the Brand Kit page would refuse raises `ValidationError` (422), and nothing is
288
+ saved.
289
+
290
+ ### Channel settings
291
+
292
+ ```python
293
+ settings = client.channel_settings.get()
294
+ settings["unsubscribe_page"]["legal_notice_url"] # "https://example.com/impressum"
295
+
296
+ updated = client.channel_settings.update({
297
+ "unsubscribe_page": {
298
+ "privacy_policy_url": "https://example.com/privacy",
299
+ "legal_notice_url": "https://example.com/impressum",
300
+ },
301
+ })
302
+ updated.warnings
303
+ ```
304
+
305
+ Broadcast 2.46.0 or later. The settings are grouped like the Settings pages:
306
+ `general`, `sender`, `unsubscribe_page` (with the legal links),
307
+ `confirmation_page` and `opt_in_forms` (`match_brand_kit`: whether new opt-in
308
+ forms start from the brand kit). Updates are partial: nested text and colours merge, an
309
+ empty string or `None` removes a value, and `languages` replaces the list.
310
+ Reading needs `channel_settings_read`, changing needs `channel_settings_write`;
311
+ both are off on every token until you tick them.
312
+
313
+ The `match_brand_kit` switches (`unsubscribe_page`, `confirmation_page`
314
+ and `opt_in_forms`) decide where the brand kit is used. They are set
315
+ here, not with the brand kit update.
316
+
317
+ ### File assets
318
+
319
+ The channel's file library (Broadcast 2.48.0 or later): PNG, JPEG and GIF
320
+ images under 10 MB, each with a public URL that never expires. Use the URL in
321
+ emails, or as the brand kit logo.
322
+
323
+ ```python
324
+ # Upload from a path (multipart). The content type follows the extension.
325
+ file = client.file_assets.upload("logo.png")
326
+ file["url"] # "https://your-broadcast.example/files/eyJ...-logo.png"
327
+
328
+ # From a file object or bytes, with a filename; link it to this channel only; safe to retry
329
+ client.file_assets.upload(png_bytes, filename="banner.gif", channel_restricted=True,
330
+ idempotency_key="banner-{}".format(campaign_id))
331
+
332
+ # Already have base64? Send it as JSON.
333
+ client.file_assets.upload_base64(filename="logo.png", content_type="image/png",
334
+ data=base64.b64encode(png_bytes).decode())
335
+
336
+ # Use it as the brand kit logo
337
+ client.channel_design.update({"brand": {"logo_url": file["url"]}})
338
+
339
+ client.file_assets.list(filter="logo", limit=20) # {"data": [...], "total": n}
340
+ client.file_assets.get(file["id"])
341
+ client.file_assets.delete(file["id"])
342
+ ```
343
+
344
+ A file that is not a PNG, JPEG or GIF, a file of 10 MB or more, or invalid
345
+ base64 raises `ValidationError` (422). So does deleting a file that is a
346
+ channel's brand kit logo: set another logo first. The server never fetches a
347
+ URL; upload the bytes. Reading needs `file_assets_read`; uploading and
348
+ deleting need `file_assets_write`.
263
349
 
264
350
  ### Email servers
265
351
 
@@ -415,7 +501,7 @@ every rejection rather than distinguishing them.
415
501
  Pass the **raw** request body. Re-serialising a parsed dict changes the bytes
416
502
  and verification will fail.
417
503
 
418
- `broadcast_python.EVENT_TYPES` lists all 35 event names. `SUBSCRIBER_EVENTS`
504
+ `broadcast_python.EVENT_TYPES` lists all 37 event names. `SUBSCRIBER_EVENTS`
419
505
  includes `subscribers.purged` and `subscribers.purge_failed`: a purge of the
420
506
  whole list sends one of these instead of a `subscriber.deleted` per subscriber.
421
507
 
@@ -438,6 +524,8 @@ integration requires.
438
524
  | Email Servers | `email_servers_read` -- list, get | `email_servers_write` -- create, update, delete, test_connection, copy_to_channel (admin) |
439
525
  | Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
440
526
  | Autopilot | `autopilot_read` -- list, get, runs | `autopilot_write` -- create, update, delete, activate, pause, deactivate, trigger_run |
527
+ | Channel Settings | `channel_settings_read` -- channel_settings.get, channel_design.get | `channel_settings_write` -- channel_settings.update, channel_design.update |
528
+ | File Assets | `file_assets_read` -- list, get | `file_assets_write` -- upload, upload_base64, delete |
441
529
  | Users (admin token only) | `users_read` -- list, get, channel_permissions, system_permissions | `users_write` -- create, update, deactivate, activate, delete, permission writes |
442
530
 
443
531
  ---
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "broadcast-python"
7
- version = "0.4.0"
7
+ version = "0.6.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"
@@ -28,6 +28,7 @@ from .errors import (
28
28
  )
29
29
  from .resources.email_servers import REDACTED_FIELDS
30
30
  from .resources.migration import COLLECTIONS
31
+ from .resources.opt_in_forms import TRIGGER_FREQUENCIES
31
32
  from .resources.transactionals import MAX_IDEMPOTENCY_KEY_LENGTH
32
33
  from .response import RateLimit, Response, Warning_
33
34
  from .version import VERSION
@@ -42,6 +43,7 @@ __all__ = [
42
43
  "EVENT_TYPES",
43
44
  "MAX_IDEMPOTENCY_KEY_LENGTH",
44
45
  "REDACTED_FIELDS",
46
+ "TRIGGER_FREQUENCIES",
45
47
  "VERSION",
46
48
  "WARNINGS_MODES",
47
49
  "APIError",
@@ -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)
@@ -36,7 +36,8 @@ class Autopilots(BaseResource):
36
36
  write-only. Scheduling takes ``schedule_frequency`` (daily, weekly,
37
37
  biweekly, monthly), ``schedule_day_of_week``, ``schedule_day_of_month``,
38
38
  ``schedule_time``, ``schedule_timezone``. ``segment_ids`` restricts the
39
- newsletter's audience.
39
+ newsletter's audience; ``excluded_segment_ids`` lists segments its
40
+ drafts never reach.
40
41
  """
41
42
  return self._post("/api/v1/autopilots", {"autopilot": attrs})
42
43
 
@@ -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")
@@ -5,6 +5,12 @@ from .base import BaseResource, compact
5
5
 
6
6
  Id = Union[str, int]
7
7
 
8
+ #: The words ``trigger_settings["frequency"]`` accepts: how often the same
9
+ #: visitor sees a popup. ``always`` and ``every_visit`` mean the same. The server
10
+ #: refuses any other word with 422 (:class:`ValidationError`); the client does
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")
13
+
8
14
 
9
15
  class OptInForms(BaseResource):
10
16
  def list(self, **params: Any) -> Any:
@@ -23,6 +29,9 @@ class OptInForms(BaseResource):
23
29
  Nested settings dicts (``theme_settings``, ``automation_settings``,
24
30
  ``security_settings``, ``trigger_settings``, ``widget_settings``) and
25
31
  the block arrays are passed through verbatim.
32
+ ``trigger_settings["frequency"]`` must be one of
33
+ :data:`TRIGGER_FREQUENCIES`; any other word raises
34
+ :class:`ValidationError` (422).
26
35
  """
27
36
  return self._post("/api/v1/opt_in_forms", {"opt_in_form": attrs})
28
37
 
@@ -33,7 +33,11 @@ class Subscribers(BaseResource):
33
33
  timestamp when migrating an already-confirmed list off another provider.
34
34
  It is ignored (with a warning) on update.
35
35
 
36
- ``unsubscribed_at`` is never settable here; use :meth:`unsubscribe`.
36
+ ``unsubscribed_at`` (any token, create only, ISO 8601) stores the
37
+ subscriber as unsubscribed in the same request, for migrating a list
38
+ with its opt-outs intact: no confirmation email, 422 if it is in the
39
+ future or sent with ``is_active=True``. To unsubscribe an existing
40
+ subscriber, use :meth:`unsubscribe`.
37
41
  """
38
42
  double_opt_in = attrs.pop("double_opt_in", None)
39
43
  confirmation_template_id = attrs.pop("confirmation_template_id", None)
@@ -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.4.0"
3
+ VERSION = "0.6.0"
@@ -15,6 +15,7 @@ EMAIL_EVENTS = (
15
15
  "email.sent",
16
16
  "email.delivered",
17
17
  "email.delivery_delayed",
18
+ "email.send_delayed",
18
19
  "email.complained",
19
20
  "email.bounced",
20
21
  "email.opened",
@@ -45,6 +46,7 @@ BROADCAST_EVENTS = (
45
46
  "broadcast.partial_failure",
46
47
  "broadcast.aborted",
47
48
  "broadcast.paused",
49
+ "broadcast.batch_completed",
48
50
  )
49
51
 
50
52
  SEQUENCE_EVENTS = (
@@ -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")