broadcast-python 0.5.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.
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/CHANGELOG.md +21 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/PKG-INFO +85 -5
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/README.md +84 -4
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/pyproject.toml +1 -1
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/client.py +10 -2
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/connection.py +18 -4
- broadcast_python-0.6.0/src/broadcast_python/multipart.py +59 -0
- broadcast_python-0.6.0/src/broadcast_python/resources/channel_design.py +39 -0
- broadcast_python-0.6.0/src/broadcast_python/resources/channel_settings.py +31 -0
- broadcast_python-0.6.0/src/broadcast_python/resources/file_assets.py +112 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/version.py +1 -1
- broadcast_python-0.5.0/src/broadcast_python/resources/channel_design.py +0 -24
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/.gitignore +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/LICENSE +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/__init__.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/configuration.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/errors.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/py.typed +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/__init__.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/autopilots.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/base.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/broadcasts.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/discovery.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/email_servers.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/global_suppressions.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/migration.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/opt_in_forms.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/segments.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/sequences.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/subscribers.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/suppressions.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/templates.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/topics.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/transactionals.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/users.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/webhook_endpoints.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/response.py +0 -0
- {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/webhook.py +0 -0
|
@@ -4,6 +4,27 @@ 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
|
+
|
|
7
28
|
## [0.5.0] - 2026-10-10
|
|
8
29
|
|
|
9
30
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: broadcast-python
|
|
3
|
-
Version: 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 **
|
|
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
|
|
|
@@ -289,9 +289,87 @@ kit["typography"]["font_stack"] # email-safe CSS font stack
|
|
|
289
289
|
kit["brand"]["logo_url"] # public URL, or None
|
|
290
290
|
```
|
|
291
291
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
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`.
|
|
295
373
|
|
|
296
374
|
### Email servers
|
|
297
375
|
|
|
@@ -470,6 +548,8 @@ integration requires.
|
|
|
470
548
|
| Email Servers | `email_servers_read` -- list, get | `email_servers_write` -- create, update, delete, test_connection, copy_to_channel (admin) |
|
|
471
549
|
| Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
|
|
472
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 |
|
|
473
553
|
| Users (admin token only) | `users_read` -- list, get, channel_permissions, system_permissions | `users_write` -- create, update, deactivate, activate, delete, permission writes |
|
|
474
554
|
|
|
475
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 **
|
|
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
|
|
|
@@ -265,9 +265,87 @@ kit["typography"]["font_stack"] # email-safe CSS font stack
|
|
|
265
265
|
kit["brand"]["logo_url"] # public URL, or None
|
|
266
266
|
```
|
|
267
267
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
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`.
|
|
271
349
|
|
|
272
350
|
### Email servers
|
|
273
351
|
|
|
@@ -446,6 +524,8 @@ integration requires.
|
|
|
446
524
|
| Email Servers | `email_servers_read` -- list, get | `email_servers_write` -- create, update, delete, test_connection, copy_to_channel (admin) |
|
|
447
525
|
| Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
|
|
448
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 |
|
|
449
529
|
| Users (admin token only) | `users_read` -- list, get, channel_permissions, system_permissions | `users_write` -- create, update, deactivate, activate, delete, permission writes |
|
|
450
530
|
|
|
451
531
|
---
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "broadcast-python"
|
|
7
|
-
version = "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"
|
|
@@ -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 ->
|
|
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,
|
|
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
|
-
|
|
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",
|
|
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")
|
|
@@ -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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/__init__.py
RENAMED
|
File without changes
|
{broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/autopilots.py
RENAMED
|
File without changes
|
|
File without changes
|
{broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/broadcasts.py
RENAMED
|
File without changes
|
{broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/discovery.py
RENAMED
|
File without changes
|
{broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/email_servers.py
RENAMED
|
File without changes
|
|
File without changes
|
{broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/migration.py
RENAMED
|
File without changes
|
{broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/opt_in_forms.py
RENAMED
|
File without changes
|
{broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/segments.py
RENAMED
|
File without changes
|
{broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/sequences.py
RENAMED
|
File without changes
|
{broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/subscribers.py
RENAMED
|
File without changes
|
{broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/suppressions.py
RENAMED
|
File without changes
|
{broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/templates.py
RENAMED
|
File without changes
|
|
File without changes
|
{broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/transactionals.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|