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.
Files changed (38) hide show
  1. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/CHANGELOG.md +21 -0
  2. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/PKG-INFO +85 -5
  3. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/README.md +84 -4
  4. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/pyproject.toml +1 -1
  5. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/client.py +10 -2
  6. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/connection.py +18 -4
  7. broadcast_python-0.6.0/src/broadcast_python/multipart.py +59 -0
  8. broadcast_python-0.6.0/src/broadcast_python/resources/channel_design.py +39 -0
  9. broadcast_python-0.6.0/src/broadcast_python/resources/channel_settings.py +31 -0
  10. broadcast_python-0.6.0/src/broadcast_python/resources/file_assets.py +112 -0
  11. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/version.py +1 -1
  12. broadcast_python-0.5.0/src/broadcast_python/resources/channel_design.py +0 -24
  13. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/.gitignore +0 -0
  14. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/LICENSE +0 -0
  15. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/__init__.py +0 -0
  16. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/configuration.py +0 -0
  17. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/errors.py +0 -0
  18. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/py.typed +0 -0
  19. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/__init__.py +0 -0
  20. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/autopilots.py +0 -0
  21. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/base.py +0 -0
  22. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/broadcasts.py +0 -0
  23. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/discovery.py +0 -0
  24. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/email_servers.py +0 -0
  25. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/global_suppressions.py +0 -0
  26. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/migration.py +0 -0
  27. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/opt_in_forms.py +0 -0
  28. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/segments.py +0 -0
  29. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/sequences.py +0 -0
  30. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/subscribers.py +0 -0
  31. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/suppressions.py +0 -0
  32. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/templates.py +0 -0
  33. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/topics.py +0 -0
  34. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/transactionals.py +0 -0
  35. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/users.py +0 -0
  36. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/resources/webhook_endpoints.py +0 -0
  37. {broadcast_python-0.5.0 → broadcast_python-0.6.0}/src/broadcast_python/response.py +0 -0
  38. {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.5.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
 
@@ -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
- 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.
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 **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
 
@@ -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
- 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.
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.5.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 -> 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")
@@ -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.6.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")