diff --git a/.claude/skills/gumlet-python-sdk/SKILL.md b/.claude/skills/gumlet-python-sdk/SKILL.md
index dc42511..42cea29 100644
--- a/.claude/skills/gumlet-python-sdk/SKILL.md
+++ b/.claude/skills/gumlet-python-sdk/SKILL.md
@@ -77,5 +77,5 @@ except APIStatusError as err:
## Reference files
-- [README.md](../../../README.md) — full feature tour: client options, retries and timeouts, logging.
+- [README.md](../../../README.md) — full feature tour: client options, retries and timeouts, webhooks, logging.
- [api.md](../../../api.md) — complete catalogue of every operation with request and response types.
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index b3e3395..4c313f9 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "1.3.3"
+ ".": "1.4.0"
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index de21ab3..67134f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
# Changelog
+## [1.4.0](https://github.com/gumlet/python-sdk/compare/v1.3.3...v1.4.0) (2026-11-05)
+
+
+### ⚠ BREAKING CHANGES
+
+* **api:** 4 breaking changes to the SDK surface.
+ - Added required body field `workspace_id` to `videoAssets.upload`.
+ - Added required body field `workspace_id` to `videoAssets.deleteMany`.
+ - Added required body field `workspace_id` to `videoAssets.tagMany`.
+ - Added required body field `workspace_id` to `videoPlaylists.create`.
+
+### Features
+
+* **api:** update SDK surface (8 changes) ([1380d95](https://github.com/gumlet/python-sdk/commit/1380d95c4d96245759dccdff50755915b8703c3f))
+
+
+### Chores
+
+* release 1.4.0 ([08ba2c0](https://github.com/gumlet/python-sdk/commit/08ba2c036f720e69bcb7962e529713a22ebb560a))
+
## [1.3.3](https://github.com/gumlet/python-sdk/compare/v1.3.2...v1.3.3) (2026-10-25)
diff --git a/README.md b/README.md
index 793d08d..b78f429 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ The full API of this library can be found in [api.md](./api.md).
- [Errors](#errors)
- [Client Options](#client-options)
- [Retries and Timeouts](#retries-and-timeouts)
+- [Webhooks](#webhooks)
- [Helpers](#helpers)
- [Logging](#logging)
- [Requirements](#requirements)
@@ -149,6 +150,24 @@ Generated clients support request timeouts and retry temporary failures such as
+## Webhooks
+
+The webhook helper verifies the signature of each delivery your server receives, then decodes its body into a typed event. Pass it the request body exactly as it arrived, before any JSON parsing, together with the request headers: the signature covers the raw body, so one that was parsed and re-serialized fails verification. A delivery whose signature does not verify is rejected before it is decoded.
+
+```python
+# `payload` is the raw request body; `headers` holds the request headers.
+event = client.webhooks.unwrap(payload, headers=headers)
+```
+
+The signing secret comes from the client's `webhook_secret` option, which defaults to the `GUMLET_WEBHOOK_SECRET` environment variable. Pass `key` to use a different secret for one call.
+
+The same resource also has:
+
+- `client.webhooks.verify_signature(payload, headers)` checks a delivery's signature, and fails when it does not verify.
+- `client.webhooks.parse_unsafe(payload)` decodes a body into a typed event without checking its signature. Use it only for a body verified elsewhere, or in tests.
+
+
+
## Helpers
- Use `client.with_raw_response..(...)` to access the raw `httpx.Response` and parse it yourself.
@@ -160,6 +179,7 @@ Generated clients support request timeouts and retry temporary failures such as
- Set the `GUMLET_LOG` environment variable to `info` or `debug` to enable HTTP logging.
- Logs are emitted through the standard `logging` module under the `gumlet` logger.
+- Importing the SDK wraps the process's log record factory: records from the SDK, `httpx`, `httpcore`, `hpack` and `websockets` loggers have credential headers, credential query parameters and URL userinfo logged as `***`. A record factory installed later that does not chain to the previous one turns this off.
diff --git a/SKILL.md b/SKILL.md
index 4cb6bd0..35ab3c9 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -77,5 +77,5 @@ except APIStatusError as err:
## Reference files
-- [README.md](./README.md) — full feature tour: client options, retries and timeouts, logging.
+- [README.md](./README.md) — full feature tour: client options, retries and timeouts, webhooks, logging.
- [api.md](./api.md) — complete catalogue of every operation with request and response types.
diff --git a/api.md b/api.md
index f9e7f3f..3880d7f 100644
--- a/api.md
+++ b/api.md
@@ -185,6 +185,7 @@ video_asset = client.video_assets.upload(
],
playlist_id="6597acd5ed6f26a9c5ca9633",
folder="697375fbfa2d1037283140e4",
+ workspace_id="646df1c9173a4a2fcac180b4",
)
```
@@ -330,6 +331,7 @@ Delete multiple VOD assets at once.
video_asset = client.video_assets.delete_many(
asset_list=["64249a8858fd3a208b987702", "64784bae843b155b829bbf84"],
source_id="60bd2ba353ff754d28179ee6",
+ workspace_id="60bd2ba353ff754d28179ee6",
)
```
@@ -348,6 +350,7 @@ video_asset = client.video_assets.tag_many(
source_id="60bd2ba353ff754d28179ee6",
add_tags=["tag-1"],
remove_tags=["playlist-1"],
+ workspace_id="60bd2ba353ff754d28179ee6",
)
```
@@ -647,6 +650,7 @@ video_playlist = client.video_playlists.create(
collection_id="{{video-source-id}}",
title="Playlist-Title",
description="This is description for playlist.",
+ workspace_id="{{video-source-id}}",
)
```
@@ -660,9 +664,7 @@ Get all playlists for given workspace
| Response | [`VideoPlaylistListAllResponse`](./src/gumlet/types/video_playlist_list_all_response.py) |
```python
-video_playlist = client.video_playlists.list_all(
- collection_id="collection_id",
-)
+video_playlist = client.video_playlists.list_all()
```
### Add asset to playlist
diff --git a/pyproject.toml b/pyproject.toml
index 4e3f3e7..f992083 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "gumlet"
-version = "1.3.3" # x-release-please-version
+version = "1.4.0" # x-release-please-version
description = "Gumlet helps developers deliver online video and images. This API encompasses Gumlet Video, Image and Video Analytics functionality to help you build your products better and faster than ever before."
readme = "README.md"
license = "Apache-2.0"
@@ -13,7 +13,6 @@ dependencies = [
"pydantic>=1.9.0, <3",
"typing-extensions>=4.14, <5",
"anyio>=3.5.0, <5",
- "distro>=1.7.0, <2",
"sniffio",
]
@@ -28,7 +27,7 @@ documentation = "https://docs.gumlet.com/reference"
[project.optional-dependencies]
aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"]
-webhooks = ["standardwebhooks >= 1.0.1, < 2"]
+webhooks = []
[build-system]
requires = ["hatchling>=1.18.0"]
diff --git a/scalar-sdk.manifest.json b/scalar-sdk.manifest.json
index 6bf503b..2a8caa4 100644
--- a/scalar-sdk.manifest.json
+++ b/scalar-sdk.manifest.json
@@ -1,7 +1,7 @@
{
"name": "Gumlet",
"slug": "gumlet",
- "generatorVersion": "0.36.9",
+ "generatorVersion": "0.38.0",
"servers": [
"https://api.gumlet.com/v1"
],
@@ -1548,7 +1548,8 @@
"enable_drm",
"call_to_actions",
"playlist_id",
- "folder"
+ "folder",
+ "workspace_id"
],
"publicBodyParams": [
"collection_id",
@@ -1576,12 +1577,13 @@
"enable_drm",
"call_to_actions",
"playlist_id",
- "folder"
+ "folder",
+ "workspace_id"
],
"bodyParamDetails": [
{
"name": "collection_id",
- "required": true
+ "required": false
},
{
"name": "profile_id",
@@ -1682,6 +1684,10 @@
{
"name": "folder",
"required": false
+ },
+ {
+ "name": "workspace_id",
+ "required": true
}
],
"pathParamDetails": [],
@@ -3062,11 +3068,13 @@
"publicHeaderParams": [],
"bodyParams": [
"asset_list",
- "source_id"
+ "source_id",
+ "workspace_id"
],
"publicBodyParams": [
"asset_list",
- "source_id"
+ "source_id",
+ "workspace_id"
],
"bodyParamDetails": [
{
@@ -3075,6 +3083,10 @@
},
{
"name": "source_id",
+ "required": false
+ },
+ {
+ "name": "workspace_id",
"required": true
}
],
@@ -3199,13 +3211,15 @@
"asset_list",
"source_id",
"add_tags",
- "remove_tags"
+ "remove_tags",
+ "workspace_id"
],
"publicBodyParams": [
"asset_list",
"source_id",
"add_tags",
- "remove_tags"
+ "remove_tags",
+ "workspace_id"
],
"bodyParamDetails": [
{
@@ -3214,7 +3228,7 @@
},
{
"name": "source_id",
- "required": true
+ "required": false
},
{
"name": "add_tags",
@@ -3223,6 +3237,10 @@
{
"name": "remove_tags",
"required": true
+ },
+ {
+ "name": "workspace_id",
+ "required": true
}
],
"pathParamDetails": [],
@@ -4208,6 +4226,7 @@
"queryParams": [
"start_at",
"end_at",
+ "workspace_id",
"collection_id",
"page",
"page_size"
@@ -4215,6 +4234,7 @@
"publicQueryParams": [
"start_at",
"end_at",
+ "workspace_id",
"collection_id",
"page",
"page_size"
@@ -4238,6 +4258,12 @@
"style": "form",
"explode": true
},
+ {
+ "name": "workspace_id",
+ "required": false,
+ "style": "form",
+ "explode": true
+ },
{
"name": "collection_id",
"required": false,
@@ -5551,17 +5577,19 @@
"bodyParams": [
"collection_id",
"title",
- "description"
+ "description",
+ "workspace_id"
],
"publicBodyParams": [
"collection_id",
"title",
- "description"
+ "description",
+ "workspace_id"
],
"bodyParamDetails": [
{
"name": "collection_id",
- "required": true
+ "required": false
},
{
"name": "title",
@@ -5570,6 +5598,10 @@
{
"name": "description",
"required": false
+ },
+ {
+ "name": "workspace_id",
+ "required": true
}
],
"pathParamDetails": [],
@@ -5686,9 +5718,11 @@
"pathParams": [],
"publicPathParams": [],
"queryParams": [
+ "workspace_id",
"collection_id"
],
"publicQueryParams": [
+ "workspace_id",
"collection_id"
],
"headerParams": [],
@@ -5698,9 +5732,15 @@
"bodyParamDetails": [],
"pathParamDetails": [],
"queryParamDetails": [
+ {
+ "name": "workspace_id",
+ "required": false,
+ "style": "form",
+ "explode": true
+ },
{
"name": "collection_id",
- "required": true,
+ "required": false,
"style": "form",
"explode": true
}
@@ -13451,6 +13491,7 @@
"publicPathParams": [],
"queryParams": [
"search_query",
+ "workspace_id",
"collection_id",
"size",
"assets_offset",
@@ -13460,6 +13501,7 @@
],
"publicQueryParams": [
"search_query",
+ "workspace_id",
"collection_id",
"size",
"assets_offset",
@@ -13480,6 +13522,12 @@
"style": "form",
"explode": true
},
+ {
+ "name": "workspace_id",
+ "required": false,
+ "style": "form",
+ "explode": true
+ },
{
"name": "collection_id",
"required": false,
diff --git a/src/gumlet/__init__.py b/src/gumlet/__init__.py
index bd4cc7c..f2b5cbf 100644
--- a/src/gumlet/__init__.py
+++ b/src/gumlet/__init__.py
@@ -26,6 +26,8 @@
UnprocessableEntityError,
APIResponseValidationError,
APIWebhookValidationError,
+ WebhookVerificationError,
+ WebhookMissingHeadersError,
)
from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient
from ._utils._logs import setup_logging as _setup_logging
@@ -49,6 +51,8 @@
"APIConnectionError",
"APIResponseValidationError",
"APIWebhookValidationError",
+ "WebhookVerificationError",
+ "WebhookMissingHeadersError",
"BadRequestError",
"AuthenticationError",
"PermissionDeniedError",
diff --git a/src/gumlet/_base_client.py b/src/gumlet/_base_client.py
index f9b71da..7e12f5c 100644
--- a/src/gumlet/_base_client.py
+++ b/src/gumlet/_base_client.py
@@ -4,6 +4,7 @@
import json
import time
import uuid
+import zlib
import email
import asyncio
import inspect
@@ -36,13 +37,13 @@
import anyio
import httpx
-import distro
import pydantic
from httpx import URL
from pydantic import PrivateAttr
from . import _exceptions
from ._qs import Querystring
+from ._decoding import CodingRefused, BoundedDecoder, decoding_error
from ._files import to_httpx_files, async_to_httpx_files
from ._types import (
Body,
@@ -70,6 +71,10 @@
APIResponse,
BaseAPIResponse,
AsyncAPIResponse,
+ BinaryAPIResponse,
+ AsyncBinaryAPIResponse,
+ StreamedBinaryAPIResponse,
+ AsyncStreamedBinaryAPIResponse,
extract_response_type,
)
from ._constants import (
@@ -83,6 +88,7 @@
)
from ._streaming import Stream, SSEDecoder, AsyncStream, SSEBytesDecoder
from ._exceptions import (
+ APIError,
APIStatusError,
APITimeoutError,
APIConnectionError,
@@ -113,10 +119,422 @@
try:
from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT
except ImportError:
- # taken from https://github.com/encode/httpx/blob/3ba5fe0d7ac70222590e759c31442b1cab263791/httpx/_config.py#L366
+ # httpx's own default of five seconds, for a version that no longer exposes the constant.
HTTPX_DEFAULT_TIMEOUT = Timeout(5.0)
+# The redirect statuses the SDK follows, the ones the fetch spec follows. A 300 only offers choices
+# and a 304 tells a conditional request its copy is current, so neither is a hop: like a redirect
+# without a `Location`, each reaches the caller as a status error.
+_REDIRECT_STATUSES: frozenset[int] = frozenset({301, 302, 303, 307, 308})
+
+# How many redirects one request follows before giving up with a connection error.
+MAX_REDIRECTS = 20
+
+# Every header a credential travels in, lower-cased: `Authorization`, `Proxy-Authorization`, `Cookie`,
+# the conventional key headers, and each header this SDK's auth schemes place (an `apiKey` scheme
+# names its own). A redirect that leaves the origin drops all of them; the log masks the same set.
+_CREDENTIAL_HEADERS: frozenset[str] = frozenset(
+ ("authorization", "proxy-authorization", "api-key", "x-api-key", "cookie", "set-cookie")
+)
+
+# The query parameters this SDK's `apiKey` schemes place a credential in.
+_CREDENTIAL_QUERY: tuple[str, ...] = ()
+
+# The headers that describe a request body, dropped with the body when a redirect turns the request
+# into a bodiless GET (the fetch spec's list, plus the framing headers httpx sets).
+_BODY_HEADERS: tuple[str, ...] = (
+ "content-type",
+ "content-length",
+ "content-encoding",
+ "content-language",
+ "content-location",
+ "transfer-encoding",
+)
+
+_DEFAULT_PORTS: Dict[str, int] = {"http": 80, "https": 443}
+
+# The most bytes a buffered response body may decode to (after transparent decompression), so a
+# hostile or broken server cannot make the client hold an unbounded body in memory. A binary
+# download or a streamed response is handed to the caller unread and is not capped.
+MAX_RESPONSE_BODY_BYTES = 64 * 1024 * 1024
+
+# The most bytes of an error response's body read for its message and `body`; the rest is dropped.
+MAX_ERROR_BODY_BYTES = 64 * 1024
+
+
+class _ResponseRefused(Exception):
+ """A response the SDK will not act on, reported to the caller as an SDK error with `message`."""
+
+ def __init__(self, message: str, *, request: httpx.Request) -> None:
+ super().__init__(message)
+ self.message = message
+ self.request = request
+
+
+class _RedirectRefused(_ResponseRefused):
+ """A redirect the SDK will not follow; it reaches the caller as an `APIConnectionError`."""
+
+
+class _BodyRefused(_ResponseRefused):
+ """A buffered body the SDK will not read; it reaches the caller as an `APIError` and is not retried."""
+
+
+class _BodyTooLarge(_BodyRefused):
+ """A buffered body larger than `MAX_RESPONSE_BODY_BYTES`."""
+
+
+def _origin(url: URL) -> tuple[str, str, int | None]:
+ """Scheme, host and port of `url`, the default port spelled out, so `https://h:443` is `https://h`."""
+ scheme = url.scheme.lower()
+ return (scheme, url.host.lower(), url.port if url.port is not None else _DEFAULT_PORTS.get(scheme))
+
+
+def _is_followed_redirect(response: httpx.Response) -> bool:
+ """Whether `response` is a redirect the SDK follows: a 301, 302, 303, 307 or 308 naming a `Location`."""
+ return response.status_code in _REDIRECT_STATUSES and bool(response.headers.get("location", "").strip())
+
+
+def _is_replayable(stream: object) -> bool:
+ """Whether a request body can be sent again on the next hop.
+
+ Bytes can, and so can a multipart body whose files are bytes or seekable (httpx rewinds each one
+ as it renders the part). An iterator or an unseekable file was consumed by the first request, so
+ resending it would deliver an empty or truncated body the server would act on.
+ """
+ if isinstance(stream, httpx.ByteStream):
+ return True
+ fields = getattr(stream, "fields", None)
+ if fields is None:
+ return False
+ for field in cast("list[Any]", fields):
+ file = getattr(field, "file", None)
+ if file is None or isinstance(file, (bytes, str)):
+ continue
+ seekable = getattr(file, "seekable", None)
+ try:
+ if not (callable(seekable) and seekable()):
+ return False
+ except Exception:
+ return False
+ return True
+
+
+def _redirect_request(
+ request: httpx.Request, response: httpx.Response, *, sent_headers: httpx.Headers, keep_credentials: bool
+) -> tuple[httpx.Request, bool]:
+ """The request a followed redirect sends next, and whether it still carries the credentials.
+
+ `Location` is resolved against the URL that was redirected (RFC 3986 section 5.2). Only http and
+ https are followed, and never from https down to http, which would send the request and its
+ credentials in clear.
+
+ A 303 answering anything but HEAD, and a 301 or 302 answering a POST, is fetched again as a
+ bodiless GET, as the fetch spec does. Every other redirect keeps its method and resends its body
+ (a PUT answered with a 301 must not become a GET that succeeds without doing what was asked), and
+ a body that cannot be replayed is refused rather than resent empty.
+
+ Leaving the origin (scheme, host and port) drops every credential header, whether the client or a
+ per-call `extra_headers` set it, and the drop holds for the rest of the chain, so a chain that
+ comes back to the first origin arrives without them. A query-placed key cannot leave at all: the
+ target's query is the `Location`'s own. On the same origin a relative `Location` cannot carry the
+ query key, so it is carried over exactly as a header credential is.
+
+ The next hop is built from `sent_headers`, the headers as they stood before httpx ran the auth flow:
+ an `httpx.Auth` signs the request object in place, so its headers (whatever their names) must not
+ be copied onto a hop it did not sign. A hop that stays on the origin is signed again on send.
+ """
+ location = response.headers.get("location", "").strip()
+ try:
+ target = request.url.join(location)
+ except Exception as exc:
+ raise _RedirectRefused("Redirect with an unusable Location header", request=request) from exc
+
+ scheme = target.scheme.lower()
+ if scheme not in ("http", "https") or not target.host:
+ raise _RedirectRefused("Redirect to an unsupported URL scheme", request=request)
+ if request.url.scheme.lower() == "https" and scheme == "http":
+ raise _RedirectRefused("Redirect from https to insecure http", request=request)
+ target = target.copy_with(fragment=None)
+
+ headers = httpx.Headers(sent_headers)
+ method = request.method.upper()
+ status = response.status_code
+ stream: Any = request.stream
+ if (status == 303 and method != "HEAD") or (status in (301, 302) and method == "POST"):
+ method = "GET"
+ stream = None
+ for name in _BODY_HEADERS:
+ headers.pop(name, None)
+ elif not _is_replayable(stream):
+ raise _RedirectRefused("Redirect would resend a request body that cannot be replayed", request=request)
+
+ if keep_credentials and _origin(request.url) == _origin(target):
+ present = set(target.params.keys())
+ carried = [
+ (key, value)
+ for key, value in request.url.params.multi_items()
+ if key in _CREDENTIAL_QUERY and key not in present
+ ]
+ if carried:
+ target = target.copy_merge_params(tuple(carried))
+ _merge_set_cookies(headers, response, target)
+ else:
+ keep_credentials = False
+ for name in [name for name in headers if name.lower() in _CREDENTIAL_HEADERS]:
+ del headers[name]
+
+ # httpx only fills `Host` in for a request built without a stream, so it is set here for both.
+ headers["Host"] = target.netloc.decode("ascii")
+ extensions = dict(request.extensions)
+ if "_" in target.host:
+ # work around https://github.com/encode/httpx/discussions/2880, as `_build_request` does
+ extensions["sni_hostname"] = target.host.replace("_", "-")
+ else:
+ extensions.pop("sni_hostname", None)
+
+ return httpx.Request(method, target, headers=headers, stream=stream, extensions=extensions), keep_credentials
+
+
+def _merge_set_cookies(headers: httpx.Headers, response: httpx.Response, target: httpx.URL) -> None:
+ """Folds the cookies a same-origin redirect sets into the next hop's `Cookie` header.
+
+ httpx writes a request's cookies into a header when the request is built, so without this a cookie
+ the 3xx itself sets (a session, say) would be missing from the next hop, as it is not when httpx
+ follows. A cookie the redirect sets replaces the one of the same name the header carried.
+ """
+ if not response.headers.get_list("set-cookie"):
+ return
+ fresh = httpx.Cookies()
+ fresh.extract_cookies(response)
+ probe = httpx.Request("GET", target)
+ fresh.set_cookie_header(probe)
+ added = probe.headers.get("cookie")
+ if not added:
+ return
+ pairs: Dict[str, str] = {}
+ for part in [*headers.get("cookie", "").split(";"), *added.split(";")]:
+ part = part.strip()
+ if part:
+ pairs[part.partition("=")[0]] = part
+ headers["Cookie"] = "; ".join(pairs.values())
+
+
+def _describe_body(value: object) -> str:
+ """A request body as the debug log shows it: its shape and size, never its values.
+
+ A mapping lists its keys, which are the API's field names; every value, nested ones included,
+ stays out of the log.
+ """
+ if isinstance(value, (bytes, bytearray)):
+ return f"<{len(value)} bytes>"
+ if isinstance(value, str):
+ return f"<{len(value)} characters>"
+ if is_mapping(value):
+ keys = ", ".join(sorted(str(key) for key in value))
+ return f""
+ if is_list(value) or isinstance(value, tuple):
+ return f"<{len(cast('list[object]', value))} items>"
+ return f"<{type(value).__name__}>"
+
+
+def _read_deadline(request: httpx.Request, started: float) -> float | None:
+ """When reading a buffered body must have finished: the request's read timeout after `started`, when its headers arrived.
+
+ httpx applies its read timeout to each socket read, so a server trickling one byte per read keeps
+ a body arriving forever; this turns the same number into a deadline for reading the whole body.
+ """
+ timeout = cast("Mapping[str, Any]", request.extensions.get("timeout") or {})
+ read = timeout.get("read")
+ return None if read is None else started + float(read)
+
+
+def _is_unbuffered_cast(cast_to: object) -> bool:
+ """Whether `cast_to` hands the body to the caller as it arrived: a binary download or a raw `httpx.Response`.
+
+ Those bodies are the caller's to bound, so neither the size cap nor the read deadline applies.
+ The legacy binary wrapper is matched by name, since its module ships only with the legacy surface.
+ """
+ origin = get_origin(cast_to) or cast_to
+ if not inspect.isclass(origin):
+ return False
+ if issubclass(
+ origin,
+ (
+ httpx.Response,
+ BinaryAPIResponse,
+ AsyncBinaryAPIResponse,
+ StreamedBinaryAPIResponse,
+ AsyncStreamedBinaryAPIResponse,
+ ),
+ ):
+ return True
+ return any(base.__name__ == "HttpxBinaryResponseContent" for base in origin.__mro__)
+
+
+def _body_limit(response: httpx.Response, *, capped: bool) -> tuple[int | None, bool]:
+ """The byte limit for reading `response`, and whether reaching it truncates rather than refuses.
+
+ An error body is read for its message only, so it is cut at `MAX_ERROR_BODY_BYTES`; a success body
+ is refused past `MAX_RESPONSE_BODY_BYTES`, unless `capped` is off for a body the caller takes raw.
+ """
+ if not response.is_success:
+ return MAX_ERROR_BODY_BYTES, True
+ return (MAX_RESPONSE_BODY_BYTES if capped else None), False
+
+
+def _too_large(response: httpx.Response, limit: int) -> _BodyTooLarge:
+ """The error for a success body that decoded past `limit` bytes."""
+ return _BodyTooLarge(
+ f"Response body exceeds the {limit} byte limit ({limit // (1024 * 1024)} MiB) for a buffered response",
+ request=response.request,
+ )
+
+
+class _BodyBuffer:
+ """The decoded bytes of a buffered body read so far, held to the limit `_body_limit` sets."""
+
+ def __init__(self, response: httpx.Response, *, capped: bool) -> None:
+ self._response = response
+ self._limit, self._truncate = _body_limit(response, capped=capped)
+ self._chunks: List[bytes] = []
+ self._size = 0
+
+ def add(self, pieces: Iterable[bytes]) -> bool:
+ """Appends `pieces`; True once a truncated body is full and reading should stop.
+
+ A body refused past the limit raises `_BodyTooLarge` as soon as a piece would cross it, so a
+ decompression bomb stops being inflated there.
+ """
+ for piece in pieces:
+ if self._limit is not None and self._size + len(piece) > self._limit:
+ if not self._truncate:
+ raise _too_large(self._response, self._limit)
+ self._chunks.append(piece[: self._limit - self._size])
+ return True
+ self._size += len(piece)
+ self._chunks.append(piece)
+ return False
+
+ @property
+ def truncates(self) -> bool:
+ """Whether this is an error body, read for its message: past the limit or the deadline it is cut, not refused."""
+ return self._truncate
+
+ def content(self) -> bytes:
+ """The body read, decoded and joined."""
+ return b"".join(self._chunks)
+
+
+def _read_body(response: httpx.Response, *, capped: bool, deadline: float | None) -> None:
+ """Reads `response`'s body into memory, bounded in size and, when `deadline` is set, in time.
+
+ The bytes are counted after transparent decompression, and gzip and deflate (stacked or not) are
+ inflated by `BoundedDecoder` in bounded steps, so a small compressed body cannot expand past the
+ limit. A read still running at `deadline` raises `httpx.ReadTimeout`, which the request loop
+ retries or reports like any other timeout, except on an error body, which keeps what arrived by
+ then so its status error still stands; httpx cannot interrupt one blocked socket read, so the
+ deadline can be overrun by at most one read timeout.
+ """
+ body = _BodyBuffer(response, capped=capped)
+ try:
+ decoder = BoundedDecoder.for_response(response, capped=capped)
+ if decoder is None:
+ for chunk in response.iter_bytes():
+ if body.add((chunk,)):
+ break
+ if deadline is not None and time.monotonic() > deadline:
+ if body.truncates:
+ break
+ raise httpx.ReadTimeout("Timed out reading the response body", request=response.request)
+ else:
+ for raw in response.iter_raw():
+ if body.add(decoder.decode(raw)):
+ break
+ if deadline is not None and time.monotonic() > deadline:
+ if body.truncates:
+ break
+ raise httpx.ReadTimeout("Timed out reading the response body", request=response.request)
+ else:
+ body.add(decoder.flush())
+ except CodingRefused as exc:
+ raise _BodyRefused(exc.message, request=response.request) from None
+ except zlib.error as exc:
+ raise decoding_error(response, exc) from exc
+ finally:
+ response.close()
+ response._content = body.content() # pyright: ignore[reportPrivateUsage]
+
+
+async def _aread_body(response: httpx.Response, *, capped: bool, deadline: float | None) -> None:
+ """The async twin of `_read_body`, where the deadline cancels a read that is still blocked."""
+ body = _BodyBuffer(response, capped=capped)
+
+ async def read() -> None:
+ """Reads the body into `body` until it ends or `body` is full."""
+ decoder = BoundedDecoder.for_response(response, capped=capped)
+ if decoder is None:
+ async for chunk in response.aiter_bytes():
+ if body.add((chunk,)):
+ return
+ return
+ async for raw in response.aiter_raw():
+ if body.add(decoder.decode(raw)):
+ return
+ body.add(decoder.flush())
+
+ try:
+ if deadline is None:
+ await read()
+ else:
+ try:
+ with anyio.fail_after(max(0.0, deadline - time.monotonic())):
+ await read()
+ except TimeoutError as exc:
+ if not body.truncates:
+ raise httpx.ReadTimeout("Timed out reading the response body", request=response.request) from exc
+ except CodingRefused as exc:
+ raise _BodyRefused(exc.message, request=response.request) from None
+ except zlib.error as exc:
+ raise decoding_error(response, exc) from exc
+ finally:
+ await response.aclose()
+ response._content = body.content() # pyright: ignore[reportPrivateUsage]
+
+
+@dataclass
+class SchemeCredentials:
+ """The credential one security scheme puts on a request, split by where it is sent."""
+
+ headers: Dict[str, str] = field(default_factory=dict)
+ query: Dict[str, str] = field(default_factory=dict)
+ cookies: Dict[str, str] = field(default_factory=dict)
+
+ @property
+ def configured(self) -> bool:
+ """Whether the scheme's client option(s) are set, so it has a credential to send."""
+ return bool(self.headers or self.query or self.cookies)
+
+
+def _merge_scheme_credentials(parts: List[SchemeCredentials]) -> SchemeCredentials:
+ """The credentials of several schemes sent together, one request's worth."""
+ merged = SchemeCredentials()
+ for part in parts:
+ merged.headers.update(part.headers)
+ merged.query.update(part.query)
+ merged.cookies.update(part.cookies)
+ return merged
+
+
+@dataclass(frozen=True)
+class AuthLocations:
+ """The lower-cased header names, and the query and cookie names, the client's credentials use."""
+
+ headers: frozenset[str] = frozenset()
+ query: frozenset[str] = frozenset()
+ cookies: frozenset[str] = frozenset()
+
+
@dataclass
class CursorPageConfig:
"""Per-operation cursor pagination descriptor baked into a page by the generated list method.
@@ -239,6 +657,17 @@ def _info_to_options(self, info: PageInfo) -> FinalRequestOptions:
return options
if not isinstance(info.url, NotGiven):
+ # The next request carries this client's credentials, so a page URL a response names on
+ # another scheme, host or port is refused rather than dialed: a server must not be able
+ # to send the caller's key elsewhere by naming it as the next page. The message names
+ # neither URL, since either may carry a token. The origin compared is the one that served
+ # this page: an operation with its own `servers` entry is sent to an absolute URL, else
+ # to the client's base URL. `_client` is declared by the sync and async page classes,
+ # each with its own client type.
+ current = URL(self._options.url)
+ served_by: URL = current if current.is_absolute_url else cast(Any, self)._client.base_url
+ if info.url.is_absolute_url and _origin(info.url) != _origin(served_by):
+ raise RuntimeError("Refusing to follow a next page URL that points away from the API's origin")
params = self._params_from_url(info.url)
url = info.url.copy_with(params=params)
options.params = dict(url.params)
@@ -260,6 +689,25 @@ def _info_to_options(self, info: PageInfo) -> FinalRequestOptions:
raise ValueError("Unexpected PageInfo state")
+ def _next_page_repeats(self) -> bool:
+ """Whether the next page's request would be exactly the request that fetched this page.
+
+ A server that answers with the cursor (or link, or body token) it was just given would make
+ auto-pagination fetch the same page forever, so the walk stops there instead.
+ """
+ info = self.next_page_info()
+ if info is None:
+ return False
+ try:
+ options = self._info_to_options(info)
+ except (TypeError, ValueError):
+ return False
+ return (
+ options.url == self._options.url
+ and options.params == self._options.params
+ and options.json_data == self._options.json_data
+ )
+
class BaseSyncPage(BasePage[_T], Generic[_T]):
_client: SyncAPIClient = pydantic.PrivateAttr()
@@ -296,7 +744,7 @@ def iter_pages(self: SyncPageT) -> Iterator[SyncPageT]:
page = self
while True:
yield page
- if page.has_next_page():
+ if page.has_next_page() and not page._next_page_repeats():
page = page.get_next_page()
else:
return
@@ -384,7 +832,7 @@ async def iter_pages(self: AsyncPageT) -> AsyncIterator[AsyncPageT]:
page = self
while True:
yield page
- if page.has_next_page():
+ if page.has_next_page() and not page._next_page_repeats():
page = await page.get_next_page()
else:
return
@@ -479,21 +927,106 @@ def _make_status_error(
) -> _exceptions.APIStatusError:
raise NotImplementedError()
+ @property
+ def _auth_by_scheme(self) -> dict[str, SchemeCredentials]:
+ """The credentials each security scheme is sent with, by the scheme's name in the document."""
+ return {}
+
+ def _select_auth(self, security: SecurityOptions) -> SchemeCredentials:
+ """
+ The credentials an operation with its own `security` requirements is sent with.
+
+ The first alternative whose schemes all have a configured credential wins, and only its
+ credentials are sent: two schemes can share a wire slot (`Authorization` for Basic and
+ Bearer), so merging every configured credential would let one overwrite the one the
+ operation asked for. An empty alternative is the anonymous one and selects nothing.
+
+ With no alternative met, the configured part of the first alternative that has one is sent:
+ the request only goes out at all when the caller supplies the rest some other way (see
+ `_credential_supplied`), and withholding the half the client holds would leave it incomplete.
+ An empty (anonymous) alternative counts as met, so an operation that also accepts `{}` is sent
+ nothing rather than a partial credential. Only schemes the operation names are ever sent.
+ """
+ available = self._auth_by_scheme
+ partial: SchemeCredentials | None = None
+ for alternative in security:
+ parts = [available.get(scheme) for scheme in alternative]
+ configured = [part for part in parts if part is not None and part.configured]
+ if len(configured) == len(parts):
+ return _merge_scheme_credentials(configured)
+ if partial is None and configured:
+ partial = _merge_scheme_credentials(configured)
+ return partial if partial is not None else SchemeCredentials()
+
+ @property
+ def _auth_locations(self) -> AuthLocations:
+ """Where the client's credentials travel, configured or not."""
+ return AuthLocations()
+
+ def _credential_supplied(
+ self,
+ security: SecurityOptions,
+ headers: Mapping[str, str],
+ custom_headers: Headers,
+ params: Query,
+ cookies: Mapping[str, str],
+ ) -> bool:
+ """
+ Whether the request already carries a credential the caller set some other way than a client
+ option — the client's `default_headers` or `default_query`, or the call's `extra_headers` or
+ `extra_query` — or omits a credential header on purpose, as the client-wide check accepts.
+
+ A value `_select_auth` put on the request is the client's own partial credential, not the
+ caller's, so it never counts: a half-configured alternative is refused unless the caller
+ supplies the rest.
+ """
+ locations = self._auth_locations
+ selected = self._select_auth(security)
+ selected_headers = {name.lower(): value for name, value in selected.headers.items()}
+ return (
+ any(
+ name.lower() in locations.headers and value and selected_headers.get(name.lower()) != value
+ for name, value in headers.items()
+ )
+ or any(
+ name.lower() in locations.headers and isinstance(value, Omit) for name, value in custom_headers.items()
+ )
+ or any(
+ params.get(name) is not None and params.get(name) != selected.query.get(name)
+ for name in locations.query
+ )
+ or any(
+ cookies.get(name) is not None and cookies.get(name) != selected.cookies.get(name)
+ for name in locations.cookies
+ )
+ )
+
+ def _security_met(self, security: SecurityOptions) -> bool:
+ """
+ Whether an operation's own requirements can be met: it is public (`[]`), lists the anonymous
+ alternative (an empty one), or the credentials of some alternative are all configured.
+ """
+ available = self._auth_by_scheme
+ return not security or any(
+ all((part := available.get(scheme)) is not None and part.configured for scheme in alternative)
+ for alternative in security
+ )
+
def _auth_query(
self,
- security: SecurityOptions, # noqa: ARG002
+ security: SecurityOptions | None, # noqa: ARG002
) -> dict[str, str]:
return {}
def _auth_cookies(
self,
- security: SecurityOptions, # noqa: ARG002
+ security: SecurityOptions | None, # noqa: ARG002
) -> dict[str, str]:
return {}
def _custom_auth(
self,
- security: SecurityOptions, # noqa: ARG002
+ security: SecurityOptions | None, # noqa: ARG002
) -> httpx.Auth | None:
# Single hook for custom httpx auth on both the sync and async request paths. It defaults to
# the legacy public `custom_auth` property so subclasses that already override that property
@@ -512,8 +1045,38 @@ def _build_headers(
custom_headers = options.headers or {}
# Auth headers are folded into `default_headers` (matching the reference SDKs), so
# merging `default_headers` here already applies the configured credentials.
- headers_dict = _merge_mappings(self.default_headers, custom_headers)
- self._validate_headers(headers_dict, custom_headers, params, cookies)
+ default_headers = self.default_headers
+ if options.security is not None:
+ # The operation names its own requirements: swap the client-wide credential headers for
+ # the selected alternative's, keeping any header the caller configured on the client.
+ auth_names = {name.lower() for name in self.auth_headers}
+ custom_names = {name.lower() for name in self._custom_headers}
+ default_headers = {
+ **{
+ name: value
+ for name, value in default_headers.items()
+ if name.lower() not in auth_names or name.lower() in custom_names
+ },
+ **self._select_auth(options.security).headers,
+ **self._custom_headers,
+ }
+ headers_dict = _merge_mappings(default_headers, custom_headers)
+ if options.security is None:
+ # The client-wide check that some credential is configured.
+ self._validate_headers(headers_dict, custom_headers, params, cookies)
+ elif not self._security_met(options.security) and not self._credential_supplied(
+ options.security, headers_dict, custom_headers, params, cookies
+ ):
+ # An operation naming its own requirements is checked against those alone: a public one
+ # or one met by a query key is never refused for want of a header, and the client-wide
+ # check would both pass a document whose root allows anonymous calls and name credentials
+ # that cannot meet this operation. A credential the caller supplies outside the client's
+ # options takes the place of a configured one; the selected credentials are already
+ # swapped in above, so an unselected configured one does not count.
+ alternatives = " or ".join(" + ".join(alternative) for alternative in options.security)
+ raise TypeError(
+ f"Could not resolve authentication method for this operation. Expected the credentials of {alternatives} to be set."
+ )
# headers are case-insensitive while dictionaries are not.
headers = httpx.Headers(headers_dict)
@@ -541,13 +1104,15 @@ def _prepare_url(self, url: str) -> URL:
Merge a URL argument together with any 'base_url' on the client,
to create the URL used for the outgoing request.
"""
- # Copied from httpx's `_merge_url` method.
- merge_url = URL(url)
- if merge_url.is_relative_url:
- merge_raw_path = self.base_url.raw_path + merge_url.raw_path.lstrip(b"/")
- return self.base_url.copy_with(raw_path=merge_raw_path)
+ target = URL(url)
+ if target.is_absolute_url:
+ return target
- return merge_url
+ # A relative URL always extends the base path, even when it starts with "/": operation paths
+ # are written from the API root, which is wherever `base_url` points. Exactly one "/" joins
+ # the two halves. `raw_path` carries the query string, so a query on `url` is kept.
+ base_path = self.base_url.raw_path.rstrip(b"/")
+ return self.base_url.copy_with(raw_path=b"/".join((base_path, target.raw_path.lstrip(b"/"))))
def _make_sse_decoder(self) -> SSEDecoder | SSEBytesDecoder:
return SSEDecoder()
@@ -559,19 +1124,19 @@ def _build_request(
retries_taken: int = 0,
) -> httpx.Request:
if log.isEnabledFor(logging.DEBUG):
- log.debug(
- "Request options: %s",
- model_dump(
- options,
- exclude_unset=True,
- # Pydantic v1 can't dump every type we support in content, so we exclude it for now.
- exclude={
- "content",
- }
- if PYDANTIC_V1
- else {},
- ),
+ # The request bodies are described, never dumped: a body carries whatever the caller
+ # sends (passwords, tokens, personal data), and the log redaction only knows credential
+ # header and query names. The headers and params are dumped, masked by that redaction.
+ dumped = model_dump(
+ options,
+ exclude_unset=True,
+ exclude={"content", "json_data", "extra_json", "files"},
)
+ for name in ("content", "json_data", "extra_json", "files"):
+ value: object = getattr(options, name, None)
+ if value is not None and is_given(value):
+ dumped[name] = _describe_body(value)
+ log.debug("Request options: %s", dumped)
kwargs: dict[str, Any] = {}
json_data = options.json_data
@@ -752,6 +1317,9 @@ def auth_headers(self) -> dict[str, str]:
def default_headers(self) -> dict[str, str | Omit]:
return {
"Accept": "application/json",
+ # Only the codings `BoundedDecoder` inflates in bounded steps; httpx would otherwise add br
+ # and zstd whenever their optional packages are installed, and inflates those unbounded.
+ "Accept-Encoding": "gzip, deflate",
"Content-Type": "application/json",
"User-Agent": self.user_agent,
**self.platform_headers(),
@@ -897,7 +1465,8 @@ class _DefaultHttpxClient(httpx.Client):
def __init__(self, **kwargs: Any) -> None:
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS)
- kwargs.setdefault("follow_redirects", True)
+ # The SDK follows redirects itself (see `_send_following_redirects`), so httpx never does.
+ kwargs.setdefault("follow_redirects", False)
super().__init__(**kwargs)
@@ -1019,6 +1588,48 @@ def _prepare_request(
"""
return None
+ def _send_following_redirects(
+ self,
+ request: httpx.Request,
+ *,
+ options: FinalRequestOptions,
+ auth: httpx.Auth | None,
+ ) -> httpx.Response:
+ """Sends `request`, following redirects in the SDK rather than in httpx, and returns the final response unread.
+
+ httpx is told not to follow on every send, so a caller-supplied `http_client` built with
+ `follow_redirects=True` cannot follow either: httpx strips only `Authorization` on a
+ cross-origin hop and would resend an API key header, cookies and a 307/308 body to whatever
+ host the server names. See `_redirect_request` for what each hop keeps. A hop that has left
+ the origin is sent with a no-op `auth`, so neither `custom_auth`, the caller's client-level
+ `auth=` nor a `.netrc` entry (httpx < 0.24 reads one by default) signs it.
+
+ `options.follow_redirects=False` turns following off altogether: the 3xx itself reaches the
+ caller as a status error. Unset or `True`, the SDK follows at most `MAX_REDIRECTS` hops.
+ """
+ keep_credentials = True
+ hops = 0
+ while True:
+ kwargs: HttpxSendArgs = {"follow_redirects": False}
+ if not keep_credentials:
+ kwargs["auth"] = httpx.Auth()
+ elif auth is not None:
+ kwargs["auth"] = auth
+ sent_headers = httpx.Headers(request.headers)
+ response = self._client.send(request, stream=True, **kwargs)
+ if options.follow_redirects is False or not _is_followed_redirect(response):
+ return response
+ try:
+ if hops >= MAX_REDIRECTS:
+ raise _RedirectRefused(f"Maximum redirects ({MAX_REDIRECTS}) exceeded", request=request)
+ request, keep_credentials = _redirect_request(
+ request, response, sent_headers=sent_headers, keep_credentials=keep_credentials
+ )
+ finally:
+ response.close()
+ hops += 1
+ log.debug("Following redirect %i to %s", response.status_code, request.url)
+
@overload
def request(
self,
@@ -1078,23 +1689,32 @@ def request(
request = self._build_request(options, retries_taken=retries_taken)
self._prepare_request(request)
- kwargs: HttpxSendArgs = {}
custom_auth = self._custom_auth(options.security)
- if custom_auth is not None:
- kwargs["auth"] = custom_auth
-
- if options.follow_redirects is not None:
- kwargs["follow_redirects"] = options.follow_redirects
+ streamed = stream or self._should_stream_response_body(request=request)
log.debug("Sending HTTP Request: %s %s", request.method, request.url)
response = None
try:
- response = self._client.send(
- request,
- stream=stream or self._should_stream_response_body(request=request),
- **kwargs,
- )
+ response = self._send_following_redirects(request, options=options, auth=custom_auth)
+ # The body's deadline starts once the headers are in: sending the request and waiting
+ # for the answer are bounded by httpx's own write and read timeouts.
+ started = time.monotonic()
+ if not streamed:
+ # Only a successful download is the caller's to bound; an error body is read for
+ # its message, capped and on the deadline like any other.
+ unbounded = _is_unbuffered_cast(cast_to) and response.is_success
+ _read_body(
+ response,
+ capped=not unbounded,
+ deadline=None if unbounded else _read_deadline(request, started),
+ )
+ except _RedirectRefused as err:
+ log.debug("Refusing redirect: %s", err.message)
+ raise APIConnectionError(message=err.message, request=err.request) from None
+ except _BodyRefused as err:
+ log.debug("Refusing response body: %s", err.message)
+ raise APIError(err.message, err.request, body=None) from None
except httpx.TimeoutException as err:
log.debug("Encountered httpx.TimeoutException", exc_info=True)
@@ -1126,8 +1746,8 @@ def request(
log.debug(
'HTTP Response: %s %s "%i %s" %s',
- request.method,
- request.url,
+ response.request.method,
+ response.request.url,
response.status_code,
response.reason_phrase,
response.headers,
@@ -1135,7 +1755,7 @@ def request(
try:
response.raise_for_status()
- except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
+ except httpx.HTTPStatusError as err: # thrown on 3xx (one not followed), 4xx and 5xx
log.debug("Encountered httpx.HTTPStatusError", exc_info=True)
if remaining_retries > 0 and self._should_retry(err.response):
@@ -1148,10 +1768,13 @@ def request(
)
continue
- # If the response is streamed then we need to explicitly read the response
- # to completion before attempting to access the response text.
+ # A streamed response is still unread: read the first `MAX_ERROR_BODY_BYTES` of it
+ # for the message, within the read deadline, never the whole body a hostile server
+ # could make endless or trickle forever.
if not err.response.is_closed:
- err.response.read()
+ _read_body(
+ err.response, capped=True, deadline=_read_deadline(err.response.request, time.monotonic())
+ )
log.debug("Re-raising status error")
raise self._make_status_error_from_response(err.response) from None
@@ -1466,7 +2089,8 @@ class _DefaultAsyncHttpxClient(httpx.AsyncClient):
def __init__(self, **kwargs: Any) -> None:
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS)
- kwargs.setdefault("follow_redirects", True)
+ # The SDK follows redirects itself (see `_send_following_redirects`), so httpx never does.
+ kwargs.setdefault("follow_redirects", False)
super().__init__(**kwargs)
@@ -1483,7 +2107,8 @@ class _DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore
def __init__(self, **kwargs: Any) -> None:
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS)
- kwargs.setdefault("follow_redirects", True)
+ # The SDK follows redirects itself (see `_send_following_redirects`), so httpx never does.
+ kwargs.setdefault("follow_redirects", False)
super().__init__(**kwargs)
@@ -1608,6 +2233,37 @@ async def _prepare_request(
"""
return None
+ async def _send_following_redirects(
+ self,
+ request: httpx.Request,
+ *,
+ options: FinalRequestOptions,
+ auth: httpx.Auth | None,
+ ) -> httpx.Response:
+ """The async twin of `SyncAPIClient._send_following_redirects`, which documents the rules."""
+ keep_credentials = True
+ hops = 0
+ while True:
+ kwargs: HttpxSendArgs = {"follow_redirects": False}
+ if not keep_credentials:
+ kwargs["auth"] = httpx.Auth()
+ elif auth is not None:
+ kwargs["auth"] = auth
+ sent_headers = httpx.Headers(request.headers)
+ response = await self._client.send(request, stream=True, **kwargs)
+ if options.follow_redirects is False or not _is_followed_redirect(response):
+ return response
+ try:
+ if hops >= MAX_REDIRECTS:
+ raise _RedirectRefused(f"Maximum redirects ({MAX_REDIRECTS}) exceeded", request=request)
+ request, keep_credentials = _redirect_request(
+ request, response, sent_headers=sent_headers, keep_credentials=keep_credentials
+ )
+ finally:
+ await response.aclose()
+ hops += 1
+ log.debug("Following redirect %i to %s", response.status_code, request.url)
+
@overload
async def request(
self,
@@ -1672,23 +2328,32 @@ async def request(
request = self._build_request(options, retries_taken=retries_taken)
await self._prepare_request(request)
- kwargs: HttpxSendArgs = {}
custom_auth = self._custom_auth(options.security)
- if custom_auth is not None:
- kwargs["auth"] = custom_auth
-
- if options.follow_redirects is not None:
- kwargs["follow_redirects"] = options.follow_redirects
+ streamed = stream or self._should_stream_response_body(request=request)
log.debug("Sending HTTP Request: %s %s", request.method, request.url)
response = None
try:
- response = await self._client.send(
- request,
- stream=stream or self._should_stream_response_body(request=request),
- **kwargs,
- )
+ response = await self._send_following_redirects(request, options=options, auth=custom_auth)
+ # The body's deadline starts once the headers are in: sending the request and waiting
+ # for the answer are bounded by httpx's own write and read timeouts.
+ started = time.monotonic()
+ if not streamed:
+ # Only a successful download is the caller's to bound; an error body is read for
+ # its message, capped and on the deadline like any other.
+ unbounded = _is_unbuffered_cast(cast_to) and response.is_success
+ await _aread_body(
+ response,
+ capped=not unbounded,
+ deadline=None if unbounded else _read_deadline(request, started),
+ )
+ except _RedirectRefused as err:
+ log.debug("Refusing redirect: %s", err.message)
+ raise APIConnectionError(message=err.message, request=err.request) from None
+ except _BodyRefused as err:
+ log.debug("Refusing response body: %s", err.message)
+ raise APIError(err.message, err.request, body=None) from None
except httpx.TimeoutException as err:
log.debug("Encountered httpx.TimeoutException", exc_info=True)
@@ -1720,8 +2385,8 @@ async def request(
log.debug(
'HTTP Response: %s %s "%i %s" %s',
- request.method,
- request.url,
+ response.request.method,
+ response.request.url,
response.status_code,
response.reason_phrase,
response.headers,
@@ -1729,7 +2394,7 @@ async def request(
try:
response.raise_for_status()
- except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
+ except httpx.HTTPStatusError as err: # thrown on 3xx (one not followed), 4xx and 5xx
log.debug("Encountered httpx.HTTPStatusError", exc_info=True)
if remaining_retries > 0 and self._should_retry(err.response):
@@ -1742,10 +2407,13 @@ async def request(
)
continue
- # If the response is streamed then we need to explicitly read the response
- # to completion before attempting to access the response text.
+ # A streamed response is still unread: read the first `MAX_ERROR_BODY_BYTES` of it
+ # for the message, within the read deadline, never the whole body a hostile server
+ # could make endless or trickle forever.
if not err.response.is_closed:
- await err.response.aread()
+ await _aread_body(
+ err.response, capped=True, deadline=_read_deadline(err.response.request, time.monotonic())
+ )
log.debug("Re-raising status error")
raise self._make_status_error_from_response(err.response) from None
@@ -2143,15 +2811,15 @@ def get_platform() -> Platform:
# system is Linux and platform_name is a string like 'Linux-5.10.81-android12-9-00001-geba40aecb3b7-ab8534902-aarch64-with-libc'
return "Android"
- if system == "linux":
- # https://distro.readthedocs.io/en/latest/#distro.id
- distro_id = distro.id()
- if distro_id == "freebsd":
- return "FreeBSD"
+ # The BSDs are not Linux distributions: `platform.system()` reports them by name (`FreeBSD`,
+ # `OpenBSD`), so the kernel name alone tells them apart and no distribution lookup is needed.
+ if system == "freebsd":
+ return "FreeBSD"
- if distro_id == "openbsd":
- return "OpenBSD"
+ if system == "openbsd":
+ return "OpenBSD"
+ if system == "linux":
return "Linux"
if platform_name:
diff --git a/src/gumlet/_client.py b/src/gumlet/_client.py
index b93ca50..c219d2d 100644
--- a/src/gumlet/_client.py
+++ b/src/gumlet/_client.py
@@ -24,8 +24,11 @@
from ._utils import is_given, is_mapping_t, get_async_library
from ._compat import cached_property
from ._exceptions import APIStatusError, GumletError
+from ._models import SecurityOptions
from ._base_client import (
DEFAULT_MAX_RETRIES,
+ AuthLocations,
+ SchemeCredentials,
SyncAPIClient,
AsyncAPIClient,
)
@@ -107,7 +110,8 @@ def __init__(
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
# Configure a custom httpx client.
- # We provide a `DefaultHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`.
+ # We provide a `DefaultHttpxClient` class that you can pass to retain the default values we use for `limits` & `timeout`.
+ # The SDK follows redirects itself, whatever the client's `follow_redirects` says.
# See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details.
http_client: httpx.Client | None = None,
# Enable or disable schema validation for data returned by the API.
@@ -326,15 +330,29 @@ def auth_headers(self) -> dict[str, str]:
}
@override
- def _auth_query(self, security: dict[str, bool]) -> dict[str, str]:
- _ = security
+ def _auth_query(self, security: SecurityOptions | None) -> dict[str, str]:
+ if security is not None:
+ return self._select_auth(security).query
return {}
@override
- def _auth_cookies(self, security: dict[str, bool]) -> dict[str, str]:
- _ = security
+ def _auth_cookies(self, security: SecurityOptions | None) -> dict[str, str]:
+ if security is not None:
+ return self._select_auth(security).cookies
return {}
+ @property
+ @override
+ def _auth_by_scheme(self) -> dict[str, SchemeCredentials]:
+ return {
+ "API_KEY": SchemeCredentials(headers=self._api_key_header_auth),
+ }
+
+ @property
+ @override
+ def _auth_locations(self) -> AuthLocations:
+ return AuthLocations(headers=frozenset({"authorization"}))
+
@property
def _api_key_header_auth(self) -> dict[str, str]:
value = self.api_key
@@ -450,7 +468,8 @@ def __init__(
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
# Configure a custom httpx client.
- # We provide a `DefaultAsyncHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`.
+ # We provide a `DefaultAsyncHttpxClient` class that you can pass to retain the default values we use for `limits` & `timeout`.
+ # The SDK follows redirects itself, whatever the client's `follow_redirects` says.
# See the [httpx documentation](https://www.python-httpx.org/api/#asyncclient) for more details.
http_client: httpx.AsyncClient | None = None,
# Enable or disable schema validation for data returned by the API.
@@ -669,15 +688,29 @@ def auth_headers(self) -> dict[str, str]:
}
@override
- def _auth_query(self, security: dict[str, bool]) -> dict[str, str]:
- _ = security
+ def _auth_query(self, security: SecurityOptions | None) -> dict[str, str]:
+ if security is not None:
+ return self._select_auth(security).query
return {}
@override
- def _auth_cookies(self, security: dict[str, bool]) -> dict[str, str]:
- _ = security
+ def _auth_cookies(self, security: SecurityOptions | None) -> dict[str, str]:
+ if security is not None:
+ return self._select_auth(security).cookies
return {}
+ @property
+ @override
+ def _auth_by_scheme(self) -> dict[str, SchemeCredentials]:
+ return {
+ "API_KEY": SchemeCredentials(headers=self._api_key_header_auth),
+ }
+
+ @property
+ @override
+ def _auth_locations(self) -> AuthLocations:
+ return AuthLocations(headers=frozenset({"authorization"}))
+
@property
def _api_key_header_auth(self) -> dict[str, str]:
value = self.api_key
diff --git a/src/gumlet/_compat.py b/src/gumlet/_compat.py
index e6690a4..fb3d763 100644
--- a/src/gumlet/_compat.py
+++ b/src/gumlet/_compat.py
@@ -1,15 +1,14 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload
+from typing import TYPE_CHECKING, Any, Union, TypeVar, cast
from datetime import date, datetime
-from typing_extensions import Self, Literal, TypedDict
+from typing_extensions import Literal, TypedDict
import pydantic
from pydantic.fields import FieldInfo
from ._types import IncEx, StrBytesIntFloat
-_T = TypeVar("_T")
_ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel)
# --------------- Pydantic v2, v3 compatibility ---------------
@@ -192,35 +191,5 @@ class GenericModel(pydantic.BaseModel): ...
# cached properties
if TYPE_CHECKING:
cached_property = property
-
- # we define a separate type (copied from typeshed)
- # that represents that `cached_property` is `set`able
- # at runtime, which differs from `@property`.
- #
- # this is a separate type as editors likely special case
- # `@property` and we don't want to cause issues just to have
- # more helpful internal types.
-
- class typed_cached_property(Generic[_T]):
- func: Callable[[Any], _T]
- attrname: str | None
-
- def __init__(self, func: Callable[[Any], _T]) -> None: ...
-
- @overload
- def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ...
-
- @overload
- def __get__(self, instance: object, owner: type[Any] | None = None) -> _T: ...
-
- def __get__(self, instance: object, owner: type[Any] | None = None) -> _T | Self:
- raise NotImplementedError()
-
- def __set_name__(self, owner: type[Any], name: str) -> None: ...
-
- # __set__ is not defined at runtime, but @cached_property is designed to be settable
- def __set__(self, instance: object, value: _T) -> None: ...
else:
from functools import cached_property as cached_property
-
- typed_cached_property = cached_property
diff --git a/src/gumlet/_decoding.py b/src/gumlet/_decoding.py
new file mode 100644
index 0000000..2b38a5c
--- /dev/null
+++ b/src/gumlet/_decoding.py
@@ -0,0 +1,187 @@
+"""Bounded decoding of a response's content codings.
+
+httpx inflates a whole network read in one call, so with stacked codings (`gzip, gzip`) a few KiB read
+can expand to gigabytes before any size limit sees a byte. Buffered bodies and streamed ones (SSE,
+JSON Lines) read their bytes through `BoundedDecoder` instead, which inflates gzip and deflate in
+steps of at most `INFLATE_STEP` bytes, so every reader can stop at its own limit after each step.
+"""
+
+from __future__ import annotations
+
+import zlib
+from typing import Dict, List, Mapping, Iterator, AsyncIterator, cast
+
+import httpx
+
+from ._exceptions import APIError
+
+# The most bytes one inflate step produces.
+INFLATE_STEP = 64 * 1024
+
+# The content codings `BoundedDecoder` undoes itself, with the zlib window bits that read each one.
+ZLIB_CODINGS: Dict[str, int] = {
+ "gzip": zlib.MAX_WBITS | 16,
+ "x-gzip": zlib.MAX_WBITS | 16,
+ "deflate": zlib.MAX_WBITS,
+}
+
+# Codings only httpx can undo, each through an optional package and without bounding the output, so a
+# body that uses one is refused. httpx skips one whose package is missing, and so does `BoundedDecoder`.
+HTTPX_ONLY_CODINGS = frozenset({"br", "zstd"})
+
+
+class CodingRefused(Exception):
+ """A success body coded br or zstd, which no limit could bound; the caller reports it as an SDK error."""
+
+ def __init__(self, message: str) -> None:
+ super().__init__(message)
+ self.message = message
+
+
+class Inflater:
+ """One gzip or deflate layer, inflated at most `INFLATE_STEP` bytes per step.
+
+ A deflate body should be zlib-wrapped, but some servers send it raw; as httpx does, a stream whose
+ first bytes are not a zlib header is read again as raw deflate.
+ """
+
+ def __init__(self, wbits: int) -> None:
+ self._wbits = wbits
+ self._decompressor = zlib.decompressobj(wbits)
+ self._started = False
+
+ def feed(self, data: bytes) -> Iterator[bytes]:
+ """Inflates `data`, yielding the output in pieces of at most `INFLATE_STEP` bytes."""
+ while not self._decompressor.eof:
+ try:
+ out = self._decompressor.decompress(data, INFLATE_STEP)
+ except zlib.error:
+ if self._started or self._wbits != zlib.MAX_WBITS:
+ raise
+ self._wbits = -zlib.MAX_WBITS
+ self._decompressor = zlib.decompressobj(self._wbits)
+ continue
+ self._started = True
+ # Input past the step stays in `unconsumed_tail`; an empty step with nothing left means
+ # zlib needs more input.
+ data = self._decompressor.unconsumed_tail
+ if out:
+ yield out
+ elif not data:
+ break
+
+ def flush(self) -> Iterator[bytes]:
+ """Whatever the layer still holds once the body has ended; `feed` has drained it to a few bytes."""
+ out = self._decompressor.flush()
+ if out:
+ yield out
+
+
+def httpx_decoders() -> Mapping[str, object]:
+ """The content codings the installed httpx decodes; br and zstd appear only with their optional packages."""
+ try:
+ from httpx._decoders import SUPPORTED_DECODERS # pyright: ignore[reportPrivateUsage]
+ except ImportError:
+ return {}
+ return cast("Mapping[str, object]", SUPPORTED_DECODERS)
+
+
+class BoundedDecoder:
+ """Undoes a response's gzip and deflate content codings, stacked ones included, in bounded steps.
+
+ Each layer hands the next a piece of at most `INFLATE_STEP` bytes, so a reader can stop at its
+ limit after every step instead of after httpx has inflated a whole network read at once.
+ """
+
+ def __init__(self, layers: List[Inflater]) -> None:
+ self._layers = layers
+
+ @classmethod
+ def for_response(cls, response: httpx.Response, *, capped: bool) -> BoundedDecoder | None:
+ """The decoder for `response`'s `Content-Encoding`, or None when httpx must decode it.
+
+ httpx decodes a body read uncapped and a body it already holds (a transport may hand back a
+ response read in full): that one has no raw stream left and is served from memory. Codings
+ are undone last-applied first. `identity` and codings httpx does not know pass through
+ unchanged, as they do in httpx. A br or zstd coding httpx would decode raises `CodingRefused`
+ on a success; an error body with one is read raw.
+ """
+ if hasattr(response, "_content") or not capped:
+ # An uncapped body (a successful binary download) is the caller's to bound, so httpx
+ # decodes it as it would.
+ return None
+ codings = [coding.strip().lower() for coding in response.headers.get("content-encoding", "").split(",")]
+ if any(coding in HTTPX_ONLY_CODINGS and coding in httpx_decoders() for coding in codings):
+ # httpx inflates br and zstd a whole network read per call, which no limit can stop, and the
+ # SDK never asks for them (`Accept-Encoding: gzip, deflate`).
+ if not response.is_success:
+ # An error body is read for its message only: keep its raw bytes, so the status error
+ # (and its retry) still stands.
+ return cls([])
+ raise CodingRefused(
+ "A br or zstd content coding is not decoded for a buffered or streamed response; "
+ "the SDK accepts gzip and deflate"
+ )
+ return cls([Inflater(ZLIB_CODINGS[coding]) for coding in reversed(codings) if coding in ZLIB_CODINGS])
+
+ def decode(self, data: bytes) -> Iterator[bytes]:
+ """Decodes one network read, yielding the body in bounded pieces."""
+ return self._push(0, data)
+
+ def flush(self) -> Iterator[bytes]:
+ """Decodes what the layers still hold once the body has ended."""
+ for index, layer in enumerate(self._layers):
+ for piece in layer.flush():
+ yield from self._push(index + 1, piece)
+
+ def _push(self, index: int, data: bytes) -> Iterator[bytes]:
+ """Runs `data` through the layers from `index` on."""
+ if index == len(self._layers):
+ yield data
+ return
+ for piece in self._layers[index].feed(data):
+ yield from self._push(index + 1, piece)
+
+
+def decoding_error(response: httpx.Response, exc: zlib.error) -> httpx.DecodingError:
+ """The error for a body that is not valid for its `Content-Encoding`, as httpx raises it."""
+ return httpx.DecodingError(f"Error decoding the response body: {exc}", request=response.request)
+
+
+def _stream_decoder(response: httpx.Response) -> BoundedDecoder | None:
+ """The bounded decoder for a streamed body, reporting a br or zstd one as an `APIError`."""
+ try:
+ return BoundedDecoder.for_response(response, capped=True)
+ except CodingRefused as exc:
+ raise APIError(exc.message, response.request, body=None) from None
+
+
+def iter_decoded(response: httpx.Response) -> Iterator[bytes]:
+ """A streamed body's bytes, decoded in bounded pieces as they arrive (SSE and JSON Lines read this)."""
+ decoder = _stream_decoder(response)
+ if decoder is None:
+ yield from response.iter_bytes()
+ return
+ try:
+ for raw in response.iter_raw():
+ yield from decoder.decode(raw)
+ yield from decoder.flush()
+ except zlib.error as exc:
+ raise decoding_error(response, exc) from exc
+
+
+async def aiter_decoded(response: httpx.Response) -> AsyncIterator[bytes]:
+ """The async twin of `iter_decoded`."""
+ decoder = _stream_decoder(response)
+ if decoder is None:
+ async for chunk in response.aiter_bytes():
+ yield chunk
+ return
+ try:
+ async for raw in response.aiter_raw():
+ for piece in decoder.decode(raw):
+ yield piece
+ for piece in decoder.flush():
+ yield piece
+ except zlib.error as exc:
+ raise decoding_error(response, exc) from exc
diff --git a/src/gumlet/_exceptions.py b/src/gumlet/_exceptions.py
index 7a877af..5128ca4 100644
--- a/src/gumlet/_exceptions.py
+++ b/src/gumlet/_exceptions.py
@@ -16,6 +16,8 @@
"RateLimitError",
"InternalServerError",
"APIWebhookValidationError",
+ "WebhookVerificationError",
+ "WebhookMissingHeadersError",
"WebSocketConnectionClosedError",
"WebSocketQueueFullError",
]
@@ -61,6 +63,23 @@ class APIWebhookValidationError(APIError):
pass
+try:
+ from standardwebhooks.webhooks import WebhookVerificationError as _StandardWebhooksVerificationError # type: ignore
+except ImportError:
+
+ class WebhookVerificationError(GumletError):
+ """Raised when a webhook delivery's signature, timestamp or signed headers do not verify."""
+
+else:
+
+ class WebhookVerificationError(GumletError, _StandardWebhooksVerificationError): # type: ignore
+ """Raised when a webhook delivery's signature, timestamp or signed headers do not verify."""
+
+
+class WebhookMissingHeadersError(WebhookVerificationError, ValueError):
+ """Raised when a webhook delivery lacks a signed header; it is also a `ValueError`, so `except ValueError` handlers catch it."""
+
+
class APIStatusError(APIError):
"""Raised when an API response has a status code of 4xx or 5xx."""
diff --git a/src/gumlet/_models.py b/src/gumlet/_models.py
index ce728eb..d385cd4 100644
--- a/src/gumlet/_models.py
+++ b/src/gumlet/_models.py
@@ -237,6 +237,12 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride]
_extra = {}
for key, value in values.items():
if key not in model_fields:
+ if PYDANTIC_V1 and hasattr(__cls, key):
+ # Pydantic v1 keeps an extra key in the instance `__dict__`, which outranks the
+ # class: a response key named `json`, `dict` or `to_dict` would replace that method
+ # on this object with the server's value. Such a key is dropped instead; v2 keeps
+ # extras in `__pydantic_extra__`, which only answers names the class lacks.
+ continue
parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value
if PYDANTIC_V1:
@@ -251,10 +257,11 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride]
# init_private_attributes() does not exist in v2
m._init_private_attributes() # type: ignore
- # copied from Pydantic v1's `construct()` method
+ # Record which fields were set, under the attribute Pydantic v1 reads them from.
object.__setattr__(m, "__fields_set__", _fields_set)
else:
- # these properties are copied from Pydantic's `model_construct()` method
+ # Pydantic v2 reads private, extra and set fields from these attributes; a model built
+ # without validation has to set all three itself.
object.__setattr__(m, "__pydantic_private__", None)
object.__setattr__(m, "__pydantic_extra__", _extra)
object.__setattr__(m, "__pydantic_fields_set__", _fields_set)
@@ -291,33 +298,26 @@ def model_dump(
fallback: Callable[[Any], Any] | None = None,
serialize_as_any: bool = False,
) -> dict[str, Any]:
- """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump
+ """Returns the model's fields as a dictionary, the Pydantic v2 `model_dump` call on Pydantic v1.
- Generate a dictionary representation of the model, optionally specifying which fields to include or exclude.
+ See https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_dump for the
+ full semantics.
Args:
- mode: The mode in which `to_python` should run.
- If mode is 'json', the output will only contain JSON serializable types.
- If mode is 'python', the output may contain non-JSON-serializable Python objects.
- include: A set of fields to include in the output.
- exclude: A set of fields to exclude from the output.
- context: Additional context to pass to the serializer.
- by_alias: Whether to use the field's alias in the dictionary key if defined.
- exclude_unset: Whether to exclude fields that have not been explicitly set.
- exclude_defaults: Whether to exclude fields that are set to their default value.
- exclude_none: Whether to exclude fields that have a value of `None`.
- exclude_computed_fields: Whether to exclude computed fields.
- While this can be useful for round-tripping, it is usually recommended to use the dedicated
- `round_trip` parameter instead.
- round_trip: If True, dumped values should be valid as input for non-idempotent types such as Json[T].
- warnings: How to handle serialization errors. False/"none" ignores them, True/"warn" logs errors,
- "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError].
- fallback: A function to call when an unknown value is encountered. If not provided,
- a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised.
- serialize_as_any: Whether to serialize fields with duck-typing serialization behavior.
-
- Returns:
- A dictionary representation of the model.
+ mode: `"python"` keeps Python objects such as `datetime`; `"json"` converts every value
+ to a JSON-compatible type.
+ include: The fields to keep, as a set or a nested mapping.
+ exclude: The fields to drop, as a set or a nested mapping.
+ by_alias: Key the output by each field's alias rather than its attribute name.
+ exclude_unset: Drop fields that were never set explicitly.
+ exclude_defaults: Drop fields still equal to their default.
+ exclude_none: Drop fields whose value is `None`.
+ context: Pydantic v2 only; must keep its default, or this raises `ValueError`.
+ exclude_computed_fields: Pydantic v2 only; must keep its default, or this raises `ValueError`.
+ round_trip: Pydantic v2 only; must keep its default, or this raises `ValueError`.
+ warnings: Pydantic v2 only; must keep its default, or this raises `ValueError`.
+ fallback: Pydantic v2 only; must keep its default, or this raises `ValueError`.
+ serialize_as_any: Pydantic v2 only; must keep its default, or this raises `ValueError`.
"""
if mode not in {"json", "python"}:
raise ValueError("mode must be either 'json' or 'python'")
@@ -363,23 +363,26 @@ def model_dump_json(
fallback: Callable[[Any], Any] | None = None,
serialize_as_any: bool = False,
) -> str:
- """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json
+ """Returns the model serialized as a JSON string, the Pydantic v2 `model_dump_json` call on Pydantic v1.
- Generates a JSON representation of the model using Pydantic's `to_json` method.
+ See https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_dump_json
+ for the full semantics.
Args:
- indent: Indentation to use in the JSON output. If None is passed, the output will be compact.
- include: Field(s) to include in the JSON output. Can take either a string or set of strings.
- exclude: Field(s) to exclude from the JSON output. Can take either a string or set of strings.
- by_alias: Whether to serialize using field aliases.
- exclude_unset: Whether to exclude fields that have not been explicitly set.
- exclude_defaults: Whether to exclude fields that have the default value.
- exclude_none: Whether to exclude fields that have a value of `None`.
- round_trip: Whether to use serialization/deserialization between JSON and class instance.
- warnings: Whether to show any warnings that occurred during serialization.
-
- Returns:
- A JSON string representation of the model.
+ indent: Spaces to indent nested values by; `None` writes compact JSON.
+ include: The fields to keep, as a set or a nested mapping.
+ exclude: The fields to drop, as a set or a nested mapping.
+ by_alias: Key the output by each field's alias rather than its attribute name.
+ exclude_unset: Drop fields that were never set explicitly.
+ exclude_defaults: Drop fields still equal to their default.
+ exclude_none: Drop fields whose value is `None`.
+ ensure_ascii: Pydantic v2 only; must keep its default, or this raises `ValueError`.
+ context: Pydantic v2 only; must keep its default, or this raises `ValueError`.
+ exclude_computed_fields: Pydantic v2 only; must keep its default, or this raises `ValueError`.
+ round_trip: Pydantic v2 only; must keep its default, or this raises `ValueError`.
+ warnings: Pydantic v2 only; must keep its default, or this raises `ValueError`.
+ fallback: Pydantic v2 only; must keep its default, or this raises `ValueError`.
+ serialize_as_any: Pydantic v2 only; must keep its default, or this raises `ValueError`.
"""
if round_trip != False:
raise ValueError("round_trip is only supported in Pydantic v2")
@@ -871,9 +874,12 @@ def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]:
return RootModel[type_] # type: ignore
-class SecurityOptions(TypedDict, total=False):
- api_key_auth: bool
- bearer_auth: bool
+# An operation's own security requirements, by security scheme name: alternatives (any one
+# suffices), each the schemes sent together. The client sends only the schemes these name: the
+# first alternative whose credentials are all configured, else the configured part of the first
+# alternative that has one. An empty list sends no credential. `None` is an operation that inherits
+# the client's default and is sent every configured credential.
+SecurityOptions: TypeAlias = List[List[str]]
class FinalRequestOptionsInput(TypedDict, total=False):
@@ -904,10 +910,7 @@ class FinalRequestOptions(pydantic.BaseModel):
idempotency_key: Union[str, None] = None
post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven()
follow_redirects: Union[bool, None] = None
- security: SecurityOptions = {
- "api_key_auth": True,
- "bearer_auth": True,
- }
+ security: Union[SecurityOptions, None] = None
content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None
# It should be noted that we cannot use `json` here as that would override
diff --git a/src/gumlet/_qs.py b/src/gumlet/_qs.py
index 4127c19..7ab0802 100644
--- a/src/gumlet/_qs.py
+++ b/src/gumlet/_qs.py
@@ -118,13 +118,11 @@ def _stringify_item(
return [(key, serialised)]
def _primitive_value_to_str(self, value: PrimitiveData) -> str:
- # copied from httpx
- if value is True:
- return "true"
- elif value is False:
- return "false"
- elif value is None:
+ """Renders one query value: booleans as lowercase `true` / `false`, and `None` as empty."""
+ if value is None:
return ""
+ if isinstance(value, bool):
+ return "true" if value else "false"
return str(value)
diff --git a/src/gumlet/_streaming.py b/src/gumlet/_streaming.py
index c1e7590..36ee8a6 100644
--- a/src/gumlet/_streaming.py
+++ b/src/gumlet/_streaming.py
@@ -1,6 +1,7 @@
-# Note: initially copied from https://github.com/florimondmanca/httpx-sse/blob/master/src/httpx_sse/_decoders.py
from __future__ import annotations
+import re
+import sys
import json
import inspect
from types import TracebackType
@@ -10,6 +11,8 @@
import httpx
from ._utils import extract_type_var_from_base
+from ._decoding import iter_decoded, aiter_decoded
+from ._exceptions import GumletError
if TYPE_CHECKING:
from ._client import Gumlet, AsyncGumlet
@@ -49,7 +52,7 @@ def __iter__(self) -> Iterator[_T]:
yield item
def _iter_events(self) -> Iterator[ServerSentEvent]:
- yield from self._decoder.iter_bytes(self.response.iter_bytes())
+ yield from self._decoder.iter_bytes(iter_decoded(self.response))
def __stream__(self) -> Iterator[_T]:
cast_to = cast(Any, self._cast_to)
@@ -120,7 +123,7 @@ async def __aiter__(self) -> AsyncIterator[_T]:
yield item
async def _iter_events(self) -> AsyncIterator[ServerSentEvent]:
- async for sse in self._decoder.aiter_bytes(self.response.aiter_bytes()):
+ async for sse in self._decoder.aiter_bytes(aiter_decoded(self.response)):
yield sse
async def __stream__(self) -> AsyncIterator[_T]:
@@ -203,110 +206,169 @@ def __repr__(self) -> str:
return f"ServerSentEvent(event={self.event}, data={self.data}, id={self.id}, retry={self.retry})"
+_LINE_TERMINATOR = re.compile(rb"\r\n|\r|\n")
+_UTF8_BOM = b"\xef\xbb\xbf"
+
+# The most bytes one line still waiting for its terminator, or one event still waiting for the blank
+# line that dispatches it, may hold. A server that never ends a line or an event would otherwise
+# make the decoder buffer without bound.
+MAX_PENDING_BYTES = 64 * 1024 * 1024
+
+# What one pending `data` line costs besides the string itself: its slot in the list of lines.
+_LIST_SLOT_BYTES = 8
+
+
+def _pending_too_large(what: str) -> GumletError:
+ """The error for a stream whose pending `what` grew past `MAX_PENDING_BYTES`."""
+ return GumletError(
+ f"Server-sent event stream {what} exceeds the {MAX_PENDING_BYTES} byte limit "
+ f"({MAX_PENDING_BYTES // (1024 * 1024)} MiB)"
+ )
+
+
class SSEDecoder:
- _data: list[str]
- _event: str | None
- _retry: int | None
- _last_event_id: str | None
+ """Incremental decoder for a `text/event-stream` body.
+
+ Follows "Interpreting an event stream" in the WHATWG HTML standard:
+ https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
+
+ Bytes arrive in arbitrary network chunks, so an unfinished line is buffered until its line
+ terminator arrives. As the standard requires, a byte order mark opening the stream is skipped,
+ bytes that are not valid UTF-8 decode to U+FFFD, and an event still pending when the stream ends
+ (it never saw the blank line that dispatches it) is dropped.
+ """
def __init__(self) -> None:
- self._event = None
- self._data = []
- self._last_event_id = None
- self._retry = None
+ self._event: str | None = None
+ self._data: list[str] = []
+ # Characters held in `_data`, kept beside it so the event cap costs nothing per line.
+ self._data_size = 0
+ self._last_event_id: str | None = None
+ self._retry: int | None = None
+ # The pieces of a line whose terminator has not arrived yet, joined once it does so a long
+ # line arriving in many chunks is copied once rather than once per chunk.
+ self._pending: list[bytes] = []
+ # Bytes held in `_pending`, kept beside it so the line cap costs nothing per chunk.
+ self._pending_size = 0
+ # Set when a chunk ended on `\r`: a `\n` opening the next chunk completes that `\r\n`, so it
+ # must not be read as a second, empty line (which would dispatch an event).
+ self._skip_leading_lf = False
+ # The opening bytes of the stream, held until there are enough to tell whether they are a
+ # byte order mark (which can itself arrive split across chunks); `None` once that is settled.
+ self._head: bytes | None = b""
def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]:
- """Given an iterator that yields raw binary data, iterate over it & yield every event encountered"""
- for chunk in self._iter_chunks(iterator):
- # Split before decoding so splitlines() only uses \r and \n
- for raw_line in chunk.splitlines():
- line = raw_line.decode("utf-8")
- sse = self.decode(line)
- if sse:
- yield sse
-
- def _iter_chunks(self, iterator: Iterator[bytes]) -> Iterator[bytes]:
- """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks"""
- data = b""
+ """Decodes raw body chunks and yields each event as soon as its blank line arrives."""
for chunk in iterator:
- for line in chunk.splitlines(keepends=True):
- data += line
- if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")):
- yield data
- data = b""
- if data:
- yield data
+ yield from self._feed(chunk)
async def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]:
- """Given an iterator that yields raw binary data, iterate over it & yield every event encountered"""
- async for chunk in self._aiter_chunks(iterator):
- # Split before decoding so splitlines() only uses \r and \n
- for raw_line in chunk.splitlines():
- line = raw_line.decode("utf-8")
- sse = self.decode(line)
- if sse:
- yield sse
-
- async def _aiter_chunks(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[bytes]:
- """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks"""
- data = b""
+ """Decodes raw async body chunks and yields each event as soon as its blank line arrives."""
async for chunk in iterator:
- for line in chunk.splitlines(keepends=True):
- data += line
- if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")):
- yield data
- data = b""
- if data:
- yield data
+ for sse in self._feed(chunk):
+ yield sse
+
+ def _feed(self, chunk: bytes) -> Iterator[ServerSentEvent]:
+ """Consumes one chunk and yields the events completed by the lines it finishes."""
+ if not chunk:
+ return
+ if self._head is not None:
+ chunk = self._head + chunk
+ if len(chunk) < len(_UTF8_BOM) and _UTF8_BOM.startswith(chunk):
+ self._head = chunk
+ return
+ self._head = None
+ if chunk.startswith(_UTF8_BOM):
+ chunk = chunk[len(_UTF8_BOM) :]
+ if not chunk:
+ return
+ if self._skip_leading_lf and chunk.startswith(b"\n"):
+ chunk = chunk[1:]
+ self._skip_leading_lf = chunk.endswith(b"\r")
+
+ # Lines are split on the raw bytes, which no UTF-8 continuation byte can be mistaken for,
+ # and each complete line is decoded on its own so a character split across chunks waits
+ # in `_pending` with the rest of its line. Only the new chunk is scanned: `_pending` holds
+ # no terminator, and a `\r\n` split across chunks was handled above.
+ *finished, unfinished = _LINE_TERMINATOR.split(chunk)
+ if not finished:
+ self._hold(unfinished)
+ return
+ if self._pending_size + len(finished[0]) > MAX_PENDING_BYTES:
+ raise _pending_too_large("line")
+ lines = [b"".join([*self._pending, finished[0]]), *finished[1:]]
+ self._pending = []
+ self._pending_size = 0
+ self._hold(unfinished)
+ for line in lines:
+ sse = self.decode(line.decode("utf-8", errors="replace"))
+ if sse is not None:
+ yield sse
+
+ def _hold(self, piece: bytes) -> None:
+ """Keeps `piece`, the start of a line whose terminator has not arrived, within the line cap."""
+ if not piece:
+ return
+ self._pending_size += len(piece)
+ if self._pending_size > MAX_PENDING_BYTES:
+ raise _pending_too_large("line")
+ self._pending.append(piece)
def decode(self, line: str) -> ServerSentEvent | None:
- # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501
-
+ """Processes one line, without its terminator, and returns the event a blank line dispatches."""
if not line:
- if not self._event and not self._data and not self._last_event_id and self._retry is None:
- return None
-
- sse = ServerSentEvent(
- event=self._event,
- data="\n".join(self._data),
- id=self._last_event_id,
- retry=self._retry,
- )
-
- # NOTE: as per the SSE spec, do not reset last_event_id.
- self._event = None
- self._data = []
- self._retry = None
-
- return sse
+ return self._dispatch()
if line.startswith(":"):
+ # A comment, such as a keep-alive.
return None
- fieldname, _, value = line.partition(":")
-
+ # A line with no colon names a field whose value is empty.
+ name, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
- if fieldname == "event":
- self._event = value
- elif fieldname == "data":
+ if name == "data":
+ # Each `data` line is held as its own string in a list until the event ends, so it is
+ # charged what that costs in memory: the string's own size (up to 4 bytes a character
+ # for astral text, plus its header) and its list slot. An empty line is not free.
+ self._data_size += sys.getsizeof(value) + _LIST_SLOT_BYTES
+ if self._data_size > MAX_PENDING_BYTES:
+ raise _pending_too_large("event")
self._data.append(value)
- elif fieldname == "id":
- if "\0" in value:
- pass
- else:
+ elif name == "event":
+ self._event = value
+ elif name == "id":
+ # The standard ignores an id containing NUL rather than truncating it.
+ if "\0" not in value:
self._last_event_id = value
- elif fieldname == "retry":
- try:
+ elif name == "retry":
+ # Only ASCII digits count; `int()` alone would also take a sign, spaces or other scripts' digits.
+ if value.isascii() and value.isdigit():
self._retry = int(value)
- except (TypeError, ValueError):
- pass
- else:
- pass # Field is ignored.
-
+ # Any other field name is ignored.
return None
+ def _dispatch(self) -> ServerSentEvent | None:
+ """Builds the event a blank line completes, and resets the per-event fields."""
+ # An event with no data still dispatches when it set a name, an id or a retry, so a consumer
+ # sees named events that carry no payload. The last event id persists across events, so
+ # once one is set every blank line dispatches.
+ if not (self._data or self._event or self._last_event_id or self._retry is not None):
+ return None
+
+ sse = ServerSentEvent(
+ event=self._event,
+ data="\n".join(self._data),
+ id=self._last_event_id,
+ retry=self._retry,
+ )
+ self._event = None
+ self._data = []
+ self._data_size = 0
+ self._retry = None
+ return sse
+
@runtime_checkable
class SSEBytesDecoder(Protocol):
diff --git a/src/gumlet/_types.py b/src/gumlet/_types.py
index 3a1d634..0d3a6d7 100644
--- a/src/gumlet/_types.py
+++ b/src/gumlet/_types.py
@@ -223,9 +223,14 @@ def get(self, __key: str) -> str | None: ...
StrBytesIntFloat = Union[str, bytes, int, float]
-# Note: copied from Pydantic
-# https://github.com/pydantic/pydantic/blob/6f31f8f68ef011f84357330186f603ff295312fd/pydantic/main.py#L79
-IncEx: TypeAlias = Union[Set[int], Set[str], Mapping[int, Union["IncEx", bool]], Mapping[str, Union["IncEx", bool]]]
+# The `include` / `exclude` selection `model_dump` takes: a set of field names (or sequence indexes),
+# or a mapping from each one to `True` or a nested selection for that field's own value.
+IncEx: TypeAlias = Union[
+ Set[str],
+ Set[int],
+ Mapping[str, Union["IncEx", bool]],
+ Mapping[int, Union["IncEx", bool]],
+]
PostParser = Callable[[Any], Any]
diff --git a/src/gumlet/_utils/_datetime_parse.py b/src/gumlet/_utils/_datetime_parse.py
index 7cb9d9e..3c6b525 100644
--- a/src/gumlet/_utils/_datetime_parse.py
+++ b/src/gumlet/_utils/_datetime_parse.py
@@ -1,136 +1,156 @@
"""
-This file contains code from https://github.com/pydantic/pydantic/blob/main/pydantic/v1/datetime_parse.py
-without the Pydantic v1 specific errors.
+Parsing for the `date` and `date-time` values a response carries.
+
+Models built without validation (`construct_type`) turn these values into `date` and `datetime`
+objects here. A string is read as a date or date-time in the RFC 3339 / ISO 8601 shape, with the
+departures `_DATE` and `_DATE_TIME` list; a number, or a string or bytes holding one, is read as a
+Unix timestamp.
+
+`datetime.fromisoformat` is not used: before Python 3.11 it rejects a `Z` offset and fractions that
+are not exactly three or six digits, and the supported floor is lower than that.
"""
from __future__ import annotations
import re
-from typing import Dict, Union, Optional
+from typing import Union, Optional
from datetime import date, datetime, timezone, timedelta
from .._types import StrBytesIntFloat
-date_expr = r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})"
-time_expr = (
- r"(?P\d{1,2}):(?P\d{1,2})"
- r"(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?"
- r"(?PZ|[+-]\d{2}(?::?\d{2})?)?$"
+# RFC 3339 `full-date`, loosened to accept a one-digit month or day.
+_DATE = re.compile(r"(\d{4})-(\d\d?)-(\d\d?)$")
+
+# RFC 3339 `date-time`, loosened the same way: a space may stand in for `T`, the seconds may be left
+# out, and the offset may be `Z`, `+HH`, `+HHMM` or `+HH:MM`, or missing for a local time. It is also
+# stricter in two places: `T` and `Z` must be uppercase, and a leap second (`:60`) is rejected,
+# because `datetime` cannot hold one.
+_DATE_TIME = re.compile(
+ r"""
+ (\d{4}) - (\d\d?) - (\d\d?)
+ [T ]
+ (\d\d?) : (\d\d?)
+ (?: : (\d\d?) (?: \. (\d{1,12}) )? )?
+ (Z | [+-] \d\d (?: :? \d\d )? )?
+ $
+ """,
+ re.VERBOSE,
)
-date_re = re.compile(f"{date_expr}$")
-datetime_re = re.compile(f"{date_expr}[T ]{time_expr}")
+_UNIX_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
+# A timestamp whose magnitude is above this is taken to be in milliseconds (or a finer unit): as
+# seconds it would land after the year 2603.
+_SECONDS_LIMIT = 2e10
+# Past this magnitude no unit brings the value inside the range `datetime` can represent.
+_CLAMP_LIMIT = 3e20
-EPOCH = datetime(1970, 1, 1)
-# if greater than this, the number is in ms, if less than or equal it's in seconds
-# (in seconds this is 11th October 2603, in ms it's 20th August 1970)
-MS_WATERSHED = int(2e10)
-# slightly more than datetime.max in ns - (datetime.max - EPOCH).total_seconds() * 1e9
-MAX_NUMBER = int(3e20)
+def _number_or_text(value: object, kind: str) -> Union[int, float, str]:
+ """Sorts an input into a timestamp (returned as a number) or text to parse as a `kind`.
-
-def _get_numeric(value: StrBytesIntFloat, native_expected_type: str) -> Union[None, int, float]:
+ Text that reads as a number, such as `"1700000000"` or even `"2024"`, is a timestamp. Any other
+ type `float()` accepts, such as `Decimal` or a NumPy scalar, is a timestamp too; everything else
+ raises `TypeError`.
+ """
if isinstance(value, (int, float)):
return value
+ if isinstance(value, bytes):
+ value = value.decode()
+ if isinstance(value, str):
+ try:
+ return float(value)
+ except ValueError:
+ return value
try:
- return float(value)
- except ValueError:
- return None
- except TypeError:
- raise TypeError(f"invalid type; expected {native_expected_type}, string, bytes, int or float") from None
+ return float(value) # type: ignore[arg-type]
+ except (TypeError, ValueError):
+ raise TypeError(f"cannot parse a {type(value).__name__} as a {kind}") from None
-def _from_unix_seconds(seconds: Union[int, float]) -> datetime:
- if seconds > MAX_NUMBER:
- return datetime.max
- elif seconds < -MAX_NUMBER:
- return datetime.min
+def _from_timestamp(number: Union[int, float]) -> datetime:
+ """Converts a Unix timestamp to an aware UTC `datetime`.
- while abs(seconds) > MS_WATERSHED:
- seconds /= 1000
- dt = EPOCH + timedelta(seconds=seconds)
- return dt.replace(tzinfo=timezone.utc)
-
-
-def _parse_timezone(value: Optional[str]) -> Union[None, int, timezone]:
- if value == "Z":
- return timezone.utc
- elif value is not None:
- offset_mins = int(value[-2:]) if len(value) > 3 else 0
- offset = 60 * int(value[1:3]) + offset_mins
- if value[0] == "-":
- offset = -offset
- return timezone(timedelta(minutes=offset))
- else:
+ The unit is inferred from the size: a value too large to be seconds is read as milliseconds,
+ one too large for that as microseconds, and so on. A value too large for any unit is clamped to
+ the naive `datetime.max` or `datetime.min`.
+ """
+ magnitude = abs(number)
+ if magnitude > _CLAMP_LIMIT:
+ return datetime.max if number > 0 else datetime.min
+ divisor = 1
+ while magnitude > _SECONDS_LIMIT * divisor:
+ divisor *= 1000
+ return _UNIX_EPOCH + timedelta(seconds=number / divisor)
+
+
+def _zone(offset: Optional[str]) -> Optional[timezone]:
+ """Returns the fixed-offset zone a `Z` or `±HH[[:]MM]` suffix names, or `None` for no suffix."""
+ if offset is None:
return None
+ if offset == "Z":
+ return timezone.utc
+ digits = offset[1:].replace(":", "")
+ delta = timedelta(hours=int(digits[:2]), minutes=int(digits[2:] or 0))
+ # `timezone` itself rejects an offset of a day or more with a `ValueError`.
+ return timezone(-delta if offset.startswith("-") else delta)
def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime:
"""
- Parse a datetime/int/float/string and return a datetime.datetime.
+ Parses a date-time string, or a Unix timestamp in seconds or milliseconds, into a `datetime`.
- This function supports time zone offsets. When the input contains one,
- the output uses a timezone with a fixed offset from UTC.
+ A string with an offset gives an aware `datetime` with that fixed offset, and one without gives
+ a naive one. A timestamp gives an aware UTC `datetime`, except that one beyond the representable
+ range is clamped to the naive `datetime.max` or `datetime.min`. A `datetime` is returned unchanged.
- Raise ValueError if the input is well formatted but not a valid datetime.
- Raise ValueError if the input isn't well formatted.
+ Raises `ValueError` for a malformed string or out-of-range fields, and `TypeError` for any other
+ input type.
"""
if isinstance(value, datetime):
return value
- number = _get_numeric(value, "datetime")
- if number is not None:
- return _from_unix_seconds(number)
-
- if isinstance(value, bytes):
- value = value.decode()
-
- assert not isinstance(value, (float, int))
+ parsed = _number_or_text(value, "datetime")
+ if not isinstance(parsed, str):
+ return _from_timestamp(parsed)
- match = datetime_re.match(value)
+ match = _DATE_TIME.match(parsed)
if match is None:
- raise ValueError("invalid datetime format")
-
- kw = match.groupdict()
- if kw["microsecond"]:
- kw["microsecond"] = kw["microsecond"].ljust(6, "0")
-
- tzinfo = _parse_timezone(kw.pop("tzinfo"))
- kw_: Dict[str, Union[None, int, timezone]] = {k: int(v) for k, v in kw.items() if v is not None}
- kw_["tzinfo"] = tzinfo
-
- return datetime(**kw_) # type: ignore
+ raise ValueError("invalid date-time format")
+ year, month, day, hour, minute, second, fraction, offset = match.groups()
+ # Pad the fraction out to microseconds, or cut it down to them.
+ microsecond = int(f"{fraction or ''}000000"[:6])
+ return datetime(
+ int(year),
+ int(month),
+ int(day),
+ int(hour),
+ int(minute),
+ int(second or 0),
+ microsecond,
+ tzinfo=_zone(offset),
+ )
def parse_date(value: Union[date, StrBytesIntFloat]) -> date:
"""
- Parse a date/int/float/string and return a datetime.date.
+ Parses a date string, or a Unix timestamp in seconds or milliseconds, into a `date`.
+
+ A `datetime` gives its date part, and a `date` is returned unchanged.
- Raise ValueError if the input is well formatted but not a valid date.
- Raise ValueError if the input isn't well formatted.
+ Raises `ValueError` for a malformed string or an impossible date, and `TypeError` for any other
+ input type.
"""
+ if isinstance(value, datetime):
+ return value.date()
if isinstance(value, date):
- if isinstance(value, datetime):
- return value.date()
- else:
- return value
-
- number = _get_numeric(value, "date")
- if number is not None:
- return _from_unix_seconds(number).date()
+ return value
- if isinstance(value, bytes):
- value = value.decode()
+ parsed = _number_or_text(value, "date")
+ if not isinstance(parsed, str):
+ return _from_timestamp(parsed).date()
- assert not isinstance(value, (float, int))
- match = date_re.match(value)
+ match = _DATE.match(parsed)
if match is None:
raise ValueError("invalid date format")
-
- kw = {k: int(v) for k, v in match.groupdict().items()}
-
- try:
- return date(**kw)
- except ValueError:
- raise ValueError("invalid date format") from None
+ year, month, day = (int(part) for part in match.groups())
+ return date(year, month, day)
diff --git a/src/gumlet/_utils/_logs.py b/src/gumlet/_utils/_logs.py
index 1c057d4..efcfaf7 100644
--- a/src/gumlet/_utils/_logs.py
+++ b/src/gumlet/_utils/_logs.py
@@ -1,5 +1,336 @@
+from __future__ import annotations
+
import os
+import re
import logging
+from typing import Any, cast
+from collections.abc import Mapping
+
+REDACTED = "***"
+
+# Credential headers, lower-cased: the conventional ones plus every header this SDK's auth schemes
+# write (an `apiKey` scheme names its own, which no fixed list could know).
+_REDACTED_HEADERS: frozenset[str] = frozenset(
+ ("authorization", "proxy-authorization", "api-key", "x-api-key", "cookie", "set-cookie")
+)
+
+# Query parameters this SDK sends a credential in, from its `apiKey` schemes placed in the query.
+# A credential there is worse off than one in a header: httpx logs every request URL at `info`.
+_REDACTED_QUERY: tuple[str, ...] = ()
+
+# The user part of a URL's authority, where a token often rides (`https://user:token@host`). Stops
+# at `/`, `?`, `#` and whitespace, so an `@` in a path or query is left alone.
+_USERINFO = re.compile(r"(//)[^/?#@\s]+@")
+
+# How deep `_redact_value` follows nested mappings and sequences before it falls back to masking
+# the value's text, so a self-referencing structure cannot recurse without end.
+_MAX_DEPTH = 8
+
+
+def _name_pattern(name: str) -> str:
+ """A pattern matching one credential query name in every spelling a URL can carry it in.
+
+ Each character as written or percent-encoded, at any depth and in either hex case (`k`, `%6B`,
+ `%6b`, `%256B`), and a space also as `+`. A non-ASCII character is matched as written or as its
+ UTF-8 bytes encoded. A lone surrogate has no UTF-8 bytes, so it is matched only as written:
+ this runs at import time, and a spec can name a scheme that way. Only the hex ignores case, not
+ the name.
+ """
+ parts: list[str] = []
+ for char in name:
+ try:
+ utf8 = char.encode("utf-8")
+ except UnicodeEncodeError:
+ utf8 = b""
+ encoded = "".join(f"%(?:25)*(?i:{byte:02x})" for byte in utf8)
+ alternatives = f"|{encoded}" if encoded else ""
+ plus = "|\\+" if char == " " else ""
+ parts.append(f"(?:{re.escape(char)}{alternatives}{plus})")
+ return "".join(parts)
+
+
+def _query_pattern() -> re.Pattern[str] | None:
+ """The pattern behind `redact()`'s query masking, or `None` when no credential is placed in the query."""
+ names = [_name_pattern(name) for name in _REDACTED_QUERY if name]
+ if not names:
+ return None
+ # A name counts after a separator, `?`, `&` or `;`, either literal or percent-encoded any number
+ # of times over (`%3F`, `%253F`), since a server can echo a whole URL encoded inside another
+ # parameter's value, as a redirect's `return_to` does. `;` also counts as a separator; masking an
+ # unrelated `;name=` value as well is harmless. The `=` after it can be encoded the same way. The
+ # value then runs to the next literal `&`, `#` or whitespace: an encoded `%26` may end a nested
+ # value, but stopping there would leak the rest of a value a sloppy encoder left half-encoded,
+ # and masking too much is harmless. `<`, `>` and `"` end it too, since no valid URL carries them
+ # unencoded, so a `Link` header's `` keeps its closing delimiter. `'` does not: a query value
+ # can hold it raw. A JSON-escaped `\u0026` (hex digits in either case) separates too, since
+ # `encoding/json` and others write `&` that way in a URL quoted inside a JSON body. No quantifier
+ # nests inside another, so matching stays linear on hostile input.
+ return re.compile(
+ r"((?:[?&;]|%(?:25)*(?i:3f|26|3b)|\\(?i:u0026))(?:" + "|".join(names) + r")(?:=|%(?:25)*(?i:3d)))[^\s<>\"]*"
+ )
+
+
+_QUERY = _query_pattern()
+
+# A header as httpcore's trace logging prints it, `(b'set-cookie', b'session=...')`: the response
+# headers of every attempt go out at DEBUG as a list of these byte-string pairs, in whichever quote
+# style each bytes repr picks. Matched pair by pair so a value is masked inside its own quotes and the
+# trace keeps its `'),` structure. Each quoted part is a run of non-quote, non-backslash characters
+# or escapes, so matching stays linear.
+_BYTE_PAIR = re.compile(r"""(\(b?['"])([^'"\\]*)(['"],\s*)(b?)(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)")(\))""")
+
+
+def _redact_plain(text: str) -> str:
+ """Masks URL userinfo and every credential query value in `text`."""
+ text = _USERINFO.sub(r"\1" + REDACTED + "@", text)
+ if _QUERY is None:
+ return text
+ return _QUERY.sub(r"\1" + REDACTED, text)
+
+
+def _redact_pair(match: re.Match[str]) -> str:
+ """One httpcore header pair with its value masked: whole for a credential header, else inside."""
+ open_, name, between, prefix, single, double, close = match.groups()
+ if name.lower() in _REDACTED_HEADERS:
+ return f"{open_}{name}{between}{prefix}'{REDACTED}'{close}"
+ if single is not None:
+ return f"{open_}{name}{between}{prefix}'{_redact_plain(single)}'{close}"
+ return f'{open_}{name}{between}{prefix}"{_redact_plain(double)}"{close}'
+
+
+def redact(text: str) -> str:
+ """Masks URL userinfo, credential header pairs and every credential query value in `text`.
+
+ Works on free text rather than a parsed URL, so it covers a URL quoted in an error message, a
+ `Location` or `Link` header, and a credential nested, percent-encoded, in another parameter's
+ value (`/login?return_to=%2Fv1%3Fapi_key%3D...`) alike. httpcore's header pairs are masked one
+ by one and the text between them on its own, so a masked value cannot run past its pair.
+ """
+ parts: list[str] = []
+ end = 0
+ for match in _BYTE_PAIR.finditer(text):
+ parts.append(_redact_plain(text[end : match.start()]))
+ parts.append(_redact_pair(match))
+ end = match.end()
+ parts.append(_redact_plain(text[end:]))
+ return "".join(parts)
+
+
+def _is_secret_key(key: object) -> bool:
+ """Whether a mapping key names a credential: a redacted header (any case) or query parameter."""
+ return isinstance(key, str) and (key.lower() in _REDACTED_HEADERS or key in _REDACTED_QUERY)
+
+
+def _is_secret_header(name: object) -> bool:
+ """Whether `name`, as text or bytes (hpack and httpcore hold headers as bytes), is a credential header."""
+ if isinstance(name, (bytes, bytearray)):
+ name = bytes(name).decode("latin-1")
+ return isinstance(name, str) and name.lower() in _REDACTED_HEADERS
+
+
+def _masked_like(value: object) -> object:
+ """`REDACTED` in the type `value` had, bytes for bytes, so a `b'...'` repr stays one."""
+ return REDACTED.encode("ascii") if isinstance(value, (bytes, bytearray)) else REDACTED
+
+
+def _redact_value(value: object, depth: int = 0) -> object:
+ """A log argument with every credential in it masked.
+
+ A mapping (response headers, a request's `headers` or `params`) has the values of credential
+ keys replaced whole, and so does a `(name, value)` pair whose name is a credential header, the
+ shape hpack logs a header in (`HeaderTuple`, text or bytes). Every string, and every byte string
+ read as Latin-1, is passed through `redact()`. Numbers keep their type, so a `%i` or `%f`
+ placeholder still formats. Any other object is replaced by its masked text only when that text
+ held a credential (an `httpx.URL`, a request's repr), so its own formatting survives otherwise.
+ """
+ if value is None or isinstance(value, (bool, int, float)):
+ return value
+ if isinstance(value, str):
+ return redact(value)
+ if isinstance(value, (bytes, bytearray)):
+ text = bytes(value).decode("latin-1")
+ masked = redact(text)
+ return value if masked == text else masked.encode("latin-1")
+ if depth < _MAX_DEPTH:
+ if isinstance(value, Mapping):
+ return {
+ key: REDACTED if _is_secret_key(key) else _redact_value(item, depth + 1)
+ for key, item in cast("Mapping[Any, Any]", value).items()
+ }
+ if isinstance(value, list):
+ return [_redact_value(item, depth + 1) for item in cast("list[Any]", value)]
+ if isinstance(value, tuple):
+ items = cast("tuple[Any, ...]", value)
+ if len(items) == 2 and _is_secret_header(items[0]):
+ return (items[0], _masked_like(items[1]))
+ return tuple(_redact_value(item, depth + 1) for item in items)
+ text = str(value)
+ masked = redact(text)
+ return value if masked == text else masked
+
+
+# The loggers whose records are masked, each with its children: this package's own, and the HTTP
+# libraries under it, which log on loggers of their own: the request URL (httpx, at `info`), every
+# response header (httpcore, at `debug`), every header an HTTP/2 connection encodes or decodes
+# (hpack, at `debug`), and a websocket handshake's request line and headers (websockets, at `debug`).
+_REDACTED_LOGGERS: tuple[str, ...] = ("gumlet", "httpx", "httpcore", "hpack", "websockets")
+
+
+def _redacts(name: object) -> bool:
+ """Whether a record logged under `name` is one this SDK masks.
+
+ `logging.makeLogRecord` builds its record with no name and fills it in afterwards, so a name
+ that is not a string is simply not this SDK's.
+ """
+ return isinstance(name, str) and any(name == root or name.startswith(root + ".") for root in _REDACTED_LOGGERS)
+
+
+def _redact_arg(value: object) -> object:
+ """`_redact_value`, failing closed: an argument that cannot be masked is logged as `REDACTED`."""
+ try:
+ return _redact_value(value)
+ except Exception:
+ return REDACTED
+
+
+def _redact_text(text: str) -> str:
+ """`redact`, failing closed: text that cannot be masked is logged as `REDACTED`."""
+ try:
+ return redact(text)
+ except Exception:
+ return REDACTED
+
+
+# The placeholders a line logging one header as two arguments, its name then its value, joins them
+# with: websockets' handshake trace (`> %s: %s`), hpack's `Adding %s=%s to the header table` and
+# `Evicting %s: %s from the header table`.
+_HEADER_LINE_JOINS: tuple[str, ...] = ("%s: %s", "%s=%s")
+
+
+def _redact_header_lines(msg: str, args: tuple[object, ...]) -> tuple[object, ...]:
+ """Masks the value of a credential header a line logs as a name argument and a value argument.
+
+ The name alone says which value is a secret, so the value is masked whole, in its own type.
+ """
+ if not any(join in msg for join in _HEADER_LINE_JOINS):
+ return args
+ masked = list(args)
+ for index, arg in enumerate(args[:-1]):
+ if _is_secret_header(arg):
+ masked[index + 1] = _masked_like(args[index + 1])
+ return tuple(masked)
+
+
+def _is_hpack(name: str) -> bool:
+ """Whether a record comes from hpack's own loggers."""
+ return name == "hpack" or name.startswith("hpack.")
+
+
+def _is_byte_string(value: object) -> bool:
+ """Whether `value` is raw bytes, the way hpack hands it a header block."""
+ return isinstance(value, (bytes, bytearray, memoryview))
+
+
+def _redact_header_blocks(msg: str, args: tuple[object, ...]) -> tuple[object, ...]:
+ """Masks every byte string an hpack record logs other than one header's name and value.
+
+ `Encoded header block to %s` and `Decoding %s` log a whole encoded header block, each header's
+ name and value in it as written or Huffman-coded, which is no protection: no pattern can find a
+ credential in it, so the block is masked whole. A line that logs one header as a name and a
+ value (`_HEADER_LINE_JOINS`) keeps both, its credential values already masked.
+ """
+ if any(join in msg for join in _HEADER_LINE_JOINS):
+ return args
+ return tuple(_masked_like(arg) if _is_byte_string(arg) else arg for arg in args)
+
+
+def _redact_record(record: logging.LogRecord) -> None:
+ """Masks every credential a log record carries, in place.
+
+ The arguments are masked one by one rather than the formatted message as a whole, so a header
+ mapping is masked by its keys and a URL argument's masked value ends where the URL does, not at
+ the next space of the surrounding message. A record with no arguments has its message masked
+ instead, whatever it is: a logged `httpx.URL` carries the query too. Each part fails closed: one
+ that cannot be masked is replaced by `REDACTED` rather than logged as it was.
+
+ An attached traceback is formatted and masked into `exc_text`, which `logging.Formatter` uses as
+ is (httpx quotes the request URL in a status error's message). `exc_info` itself is left alone,
+ since a handler may need the exception object, so a formatter that reads `exc_info` rather than
+ `exc_text` still sees the raw traceback.
+ """
+ if isinstance(record.args, Mapping):
+ mapping = cast("Mapping[Any, Any]", record.args)
+ record.args = {key: REDACTED if _is_secret_key(key) else _redact_arg(item) for key, item in mapping.items()}
+ elif isinstance(record.args, tuple) and record.args:
+ args = tuple(_redact_arg(arg) for arg in record.args)
+ if isinstance(record.msg, str):
+ args = _redact_header_lines(record.msg, args)
+ if _is_hpack(record.name):
+ args = _redact_header_blocks(record.msg, args)
+ record.args = args
+ elif isinstance(record.msg, str):
+ record.msg = _redact_text(record.msg)
+ else:
+ record.msg = _redact_arg(record.msg)
+ if record.exc_info and not record.exc_text:
+ try:
+ record.exc_text = redact(logging.Formatter().formatException(record.exc_info))
+ except Exception:
+ record.exc_text = REDACTED
+ if isinstance(record.stack_info, str):
+ record.stack_info = _redact_text(record.stack_info)
+
+
+def _blank_record(record: logging.LogRecord) -> None:
+ """Replaces everything a record could print with `REDACTED`, for one that could not be masked.
+
+ The traceback goes too, `exc_info` as well as `exc_text`, since a handler that formats the
+ exception itself would otherwise print it raw.
+ """
+ try:
+ had_traceback = bool(record.exc_info or record.exc_text)
+ record.msg = REDACTED
+ record.args = ()
+ record.exc_info = None
+ record.exc_text = REDACTED if had_traceback else None
+ record.stack_info = None
+ except Exception:
+ return
+
+
+def _install_record_factory() -> None:
+ """Wraps the process's log record factory so this SDK's records are masked as they are made.
+
+ A record factory rather than a logger filter, because a logger's filters see only the records
+ logged on that logger itself: every runtime module logs on a child (`._base_client`),
+ and httpx, httpcore and websockets on children of their own. Masking at creation also comes
+ before every filter and handler, whoever configured them. The factory already installed is
+ wrapped, not replaced, so another library's factory keeps working, and records of every other
+ logger pass through untouched. A factory installed later that does not chain to this one turns
+ the masking off.
+ """
+ previous = logging.getLogRecordFactory()
+
+ def factory(*args: Any, **kwargs: Any) -> logging.LogRecord:
+ """The previous factory's record, masked when it belongs to one of `_REDACTED_LOGGERS`.
+
+ Nothing here may raise: the factory runs inside every `log.debug(...)` in the process, so
+ a failure would break the caller's code rather than its logging.
+ """
+ record = previous(*args, **kwargs)
+ try:
+ if _redacts(record.name):
+ _redact_record(record)
+ except Exception:
+ # Every part of `_redact_record` already fails closed; this is the last line, for a
+ # record a part of which cannot even be read (a mapping whose `.items()` raises).
+ _blank_record(record)
+ return record
+
+ logging.setLogRecordFactory(factory)
+
+
+_install_record_factory()
logger: logging.Logger = logging.getLogger("gumlet")
httpx_logger: logging.Logger = logging.getLogger("httpx")
diff --git a/src/gumlet/_utils/_utils.py b/src/gumlet/_utils/_utils.py
index 8b051e2..1af7aca 100644
--- a/src/gumlet/_utils/_utils.py
+++ b/src/gumlet/_utils/_utils.py
@@ -203,19 +203,14 @@ def is_iterable(obj: object) -> TypeGuard[Iterable[object]]:
return isinstance(obj, Iterable)
-# copied from https://github.com/Rapptz/RoboDanny
def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str:
- size = len(seq)
- if size == 0:
+ """Joins `seq` into an English list: `"a"`, `"a or b"`, `"a, b or c"` (no comma before `final`)."""
+ if not seq:
return ""
-
- if size == 1:
- return seq[0]
-
- if size == 2:
- return f"{seq[0]} {final} {seq[1]}"
-
- return delim.join(seq[:-1]) + f" {final} {seq[-1]}"
+ *head, last = seq
+ if not head:
+ return last
+ return f"{delim.join(head)} {final} {last}"
def quote(string: str) -> str:
diff --git a/src/gumlet/_version.py b/src/gumlet/_version.py
index a37d2dc..aeb7cbf 100644
--- a/src/gumlet/_version.py
+++ b/src/gumlet/_version.py
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Scalar. See README.md for details.
__title__ = "gumlet"
-__version__ = "1.3.3" # x-release-please-version
+__version__ = "1.4.0" # x-release-please-version
diff --git a/src/gumlet/_webhook_verify.py b/src/gumlet/_webhook_verify.py
new file mode 100644
index 0000000..8b2336d
--- /dev/null
+++ b/src/gumlet/_webhook_verify.py
@@ -0,0 +1,128 @@
+"""Verifies Standard Webhooks signatures with the standard library alone.
+
+A delivery is signed with HMAC-SHA256 over `".."`, keyed with
+the base64-decoded secret, and the `webhook-signature` header carries one or more space-separated
+`v1,` entries. This module checks a delivery the way the `standardwebhooks` 1.x
+package does, with the same secret decoding, header lookup, signed content, tolerance window and error
+messages, and like the library's `verify` it returns the JSON-decoded body once a signature matches. A
+refused delivery raises this package's `WebhookVerificationError`, which also subclasses the library's
+class of that name whenever the library is installed, so handlers written against it still catch it.
+An empty secret raises `ValueError` where the library raised `EmptyWebhookSecretError`; the generated
+webhook methods refuse such a key before calling in here. A delivery without its signed headers raises
+`WebhookMissingHeadersError`, a `WebhookVerificationError` that is also a `ValueError`, so either
+`except` clause catches it.
+"""
+
+from __future__ import annotations
+
+import hmac
+import json
+import base64
+import hashlib
+from math import floor
+from typing import Mapping
+from datetime import datetime, timezone, timedelta
+
+from ._exceptions import WebhookVerificationError, WebhookMissingHeadersError
+
+__all__ = ["verify_webhook_signature"]
+
+_SECRET_PREFIX = "whsec_"
+
+# Deliveries stamped more than five minutes before or after the local clock are refused, so a captured
+# delivery cannot be replayed later and a forward-dated one cannot outlive the window either.
+_TOLERANCE = timedelta(minutes=5)
+
+
+def _utcnow() -> datetime:
+ """The current time, in UTC; a function so tests can pin the clock."""
+ return datetime.now(tz=timezone.utc)
+
+
+def _decode_secret(key: str | bytes) -> bytes:
+ """Turns the configured key into the HMAC key bytes.
+
+ A `str` key is a base64 secret, optionally prefixed with `whsec_` as dashboards display it; `bytes`
+ are already the raw HMAC key and are used as given. Two `=` are appended before decoding so an
+ unpadded secret decodes too: `b64decode` ignores padding beyond what the input needs, so a secret
+ that was already padded decodes to the same bytes. Decoding is not strict, as in the library:
+ ASCII characters outside the base64 alphabet are dropped, so a secret that is not base64 usually
+ decodes to some other key and simply fails to verify. A non-ASCII secret, or one left with a length
+ no padding can fix, raises `ValueError` (the latter as its `binascii.Error` subclass).
+ """
+ if isinstance(key, str):
+ if key.startswith(_SECRET_PREFIX):
+ key = key[len(_SECRET_PREFIX) :]
+ key = base64.b64decode(key + "==")
+
+ # An empty HMAC key is one anyone can sign with, so it can never verify a delivery.
+ if not key:
+ raise ValueError("webhook secret may not be empty")
+ return key
+
+
+def _parse_timestamp(header: str) -> datetime:
+ """Reads `webhook-timestamp` (seconds since the epoch) and refuses one outside the tolerance window."""
+ now = _utcnow()
+ try:
+ timestamp = datetime.fromtimestamp(float(header), tz=timezone.utc)
+ except Exception as exc:
+ raise WebhookVerificationError("Invalid Signature Headers") from exc
+
+ if timestamp < now - _TOLERANCE:
+ raise WebhookVerificationError("Message timestamp too old")
+ if timestamp > now + _TOLERANCE:
+ raise WebhookVerificationError("Message timestamp too new")
+ return timestamp
+
+
+def verify_webhook_signature(payload: str | bytes, headers: Mapping[str, str], key: str | bytes) -> object:
+ """Returns the JSON-decoded `payload` once it carries a valid, fresh signature made with `key`.
+
+ A missing header, a timestamp outside the tolerance or no matching signature raises
+ `WebhookVerificationError`; for a missing header it is the `WebhookMissingHeadersError` subclass,
+ which is also a `ValueError`, saying `Could not find header` for an absent one and
+ `Missing required headers` for an empty one. Input the library also rejected with something else raises the same
+ `ValueError` it did: a signature entry that is not a `,` pair, an undecodable
+ signature, a `bytes` payload that is not UTF-8, or a signed body that is not JSON. An empty secret
+ raises `ValueError` too, where the library raised `EmptyWebhookSecretError`.
+
+ `headers` is matched case-insensitively. A `bytes` payload is decoded as UTF-8 and signed as text,
+ exactly as a `str` payload is, so either form of the same body verifies alike.
+ """
+ secret = _decode_secret(key)
+ body = payload if isinstance(payload, str) else payload.decode()
+
+ lowered = {name.lower(): value for name, value in headers.items()}
+ # An absent header is named, as `get_required_header` names it; one that is present but empty is
+ # refused below with the library's message.
+ for name in ("webhook-id", "webhook-timestamp", "webhook-signature"):
+ if lowered.get(name) is None:
+ raise WebhookMissingHeadersError(f"Could not find {name} header")
+ msg_id = lowered.get("webhook-id")
+ msg_timestamp = lowered.get("webhook-timestamp")
+ msg_signature = lowered.get("webhook-signature")
+ if not (msg_id and msg_timestamp and msg_signature):
+ raise WebhookMissingHeadersError("Missing required headers")
+
+ timestamp = _parse_timestamp(msg_timestamp)
+ # The signed content uses the whole seconds of the parsed timestamp, not the header text, so a
+ # header such as `1614265330.0` is checked against the signature made over `1614265330`.
+ signed_content = f"{msg_id}.{floor(timestamp.timestamp())}.{body}".encode()
+ expected = hmac.new(secret, signed_content, hashlib.sha256).digest()
+
+ # A sender rotating its secret signs with both keys, so any one `v1` entry matching is enough;
+ # entries for other signature versions are skipped. An entry that is not a `,`
+ # pair raises the `ValueError` unpacking raises, as one whose signature leaves an unfixable base64
+ # length does; other stray characters are dropped by the lenient decoder and the entry just fails.
+ # Entries are checked in order, so a malformed one ahead of a valid one raises before it is reached.
+ for entry in msg_signature.split(" "):
+ version, signature = entry.split(",")
+ if version != "v1":
+ continue
+ # `compare_digest` takes the same time wherever the first differing byte is, so the response
+ # time cannot be used to recover a valid signature one byte at a time.
+ if hmac.compare_digest(expected, base64.b64decode(signature)):
+ return json.loads(body)
+
+ raise WebhookVerificationError("No matching signature found")
diff --git a/src/gumlet/resources/global_search.py b/src/gumlet/resources/global_search.py
index f7f6ddc..3a01e97 100644
--- a/src/gumlet/resources/global_search.py
+++ b/src/gumlet/resources/global_search.py
@@ -34,6 +34,7 @@ def search(
self,
*,
search_query: str,
+ workspace_id: str | Omit = omit,
collection_id: str | Omit = omit,
size: int | Omit = omit,
assets_offset: int | Omit = omit,
@@ -52,7 +53,8 @@ def search(
Args:
search_query: Search query term
- collection_id: Workspace ID if you want to limit search to a specific workspace
+ workspace_id: Gumlet video workspace ID. Preferred. Provide `workspace_id`, or deprecated `collection_id` / `source_id` (same ID).
+ collection_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
size: Number of results to return
assets_offset: Offset for assets
folders_offset: Offset for folders
@@ -88,6 +90,7 @@ def search(
query=maybe_transform(
{
"search_query": search_query,
+ "workspace_id": workspace_id,
"collection_id": collection_id,
"size": size,
"assets_offset": assets_offset,
@@ -115,6 +118,7 @@ async def search(
self,
*,
search_query: str,
+ workspace_id: str | Omit = omit,
collection_id: str | Omit = omit,
size: int | Omit = omit,
assets_offset: int | Omit = omit,
@@ -133,7 +137,8 @@ async def search(
Args:
search_query: Search query term
- collection_id: Workspace ID if you want to limit search to a specific workspace
+ workspace_id: Gumlet video workspace ID. Preferred. Provide `workspace_id`, or deprecated `collection_id` / `source_id` (same ID).
+ collection_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
size: Number of results to return
assets_offset: Offset for assets
folders_offset: Offset for folders
@@ -169,6 +174,7 @@ async def search(
query=await async_maybe_transform(
{
"search_query": search_query,
+ "workspace_id": workspace_id,
"collection_id": collection_id,
"size": size,
"assets_offset": assets_offset,
diff --git a/src/gumlet/resources/video_assets.py b/src/gumlet/resources/video_assets.py
index d7e4f38..5fdd24d 100644
--- a/src/gumlet/resources/video_assets.py
+++ b/src/gumlet/resources/video_assets.py
@@ -99,7 +99,7 @@ def create(
Args:
input: URL or web address of a file that Gumlet should download to create a new asset.
- profile_id: Provide `profile_id` of the previously created video profile. This parameter will override all the parameters (except `input` and `collection_id`) from the video profile.
+ profile_id: Provide `profile_id` of the previously created video profile. This parameter will override all the parameters (except `input` and `workspace_id`) from the video profile.
format: Transcode and deliver the asset in the requested format. The options can be one of `ABR` (HLS + DASH) and `MP4`.
tag: Specify a text string or identifier which can identify an asset or bunch of assets later.
title: Specify a text string or identifier which can be used for filtering or searching the asset.
@@ -186,7 +186,7 @@ def create(
def upload(
self,
*,
- collection_id: str,
+ collection_id: str | Omit = omit,
profile_id: str | Omit = omit,
format: Literal["ABR", "MP4"] | Omit = omit,
tag: SequenceNotStr[str] | Omit = omit,
@@ -212,6 +212,7 @@ def upload(
call_to_actions: Iterable[video_asset_upload_params.CallToAction] | Omit = omit,
playlist_id: str | Omit = omit,
folder: str | Omit = omit,
+ workspace_id: str,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -223,8 +224,8 @@ def upload(
This endpoint creates a video asset allowing upload of a video from the local file system and ingest media content into the Gumlet system for processing and delivery. Body parameters are the same as the Create Asset body parameters except for the `input` parameter, which this endpoint does not take. A successful response will be returned with `upload_url` field. You can make `PUT` request to that URL to upload video. To upload video using `upload_url` refer to [this](https://docs.gumlet.com/docs/direct-upload#2-use-the-url-to-upload-a-file).
Args:
- collection_id: Gumlet video workspace id.
- profile_id: Provide `profile_id` of the previously created video profile. This parameter will override all the parameters (except `input` and `collection_id`) from the video profile.
+ collection_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
+ profile_id: Provide `profile_id` of the previously created video profile. This parameter will override all the parameters (except `input` and `workspace_id`) from the video profile.
format: Transcode and deliver the asset in the requested format. The options can be one of `ABR` (HLS + DASH) and `MP4`.
tag: Specify a text string or identifier which can identify an asset or bunch of assets later.
title: Specify a text string or identifier which can be used for filtering or searching the asset.
@@ -249,6 +250,7 @@ def upload(
call_to_actions: A CTA is an explicit prompt within the video content encouraging viewers to take a particular action.
playlist_id: Add this asset to a playlist.
folder: Add this asset to an existing folder by `folder_id`.
+ workspace_id: Gumlet video workspace ID. Preferred over deprecated `collection_id` / `source_id` (same ID).
extra_headers: Send extra headers with the request.
extra_query: Send extra query parameters with the request.
extra_body: Send extra JSON properties with the request.
@@ -281,6 +283,7 @@ def upload(
],
playlist_id="6597acd5ed6f26a9c5ca9633",
folder="697375fbfa2d1037283140e4",
+ workspace_id="646df1c9173a4a2fcac180b4",
)
```
"""
@@ -314,6 +317,7 @@ def upload(
"call_to_actions": call_to_actions,
"playlist_id": playlist_id,
"folder": folder,
+ "workspace_id": workspace_id,
},
video_asset_upload_params.VideoAssetUploadParams,
),
@@ -809,7 +813,8 @@ def delete_many(
self,
*,
asset_list: SequenceNotStr[str],
- source_id: str,
+ source_id: str | Omit = omit,
+ workspace_id: str,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -822,7 +827,8 @@ def delete_many(
Args:
asset_list: LIst of asset ids to delete
- source_id: Workspace ID from which assets needs to be deleted.
+ source_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
+ workspace_id: Gumlet video workspace ID. Preferred over deprecated `collection_id` / `source_id` (same ID).
extra_headers: Send extra headers with the request.
extra_query: Send extra query parameters with the request.
extra_body: Send extra JSON properties with the request.
@@ -836,6 +842,7 @@ def delete_many(
video_asset = client.video_assets.delete_many(
asset_list=["64249a8858fd3a208b987702", "64784bae843b155b829bbf84"],
source_id="60bd2ba353ff754d28179ee6",
+ workspace_id="60bd2ba353ff754d28179ee6",
)
```
"""
@@ -845,6 +852,7 @@ def delete_many(
{
"asset_list": asset_list,
"source_id": source_id,
+ "workspace_id": workspace_id,
},
video_asset_delete_many_params.VideoAssetDeleteManyParams,
),
@@ -858,9 +866,10 @@ def tag_many(
self,
*,
asset_list: SequenceNotStr[str],
- source_id: str,
+ source_id: str | Omit = omit,
add_tags: SequenceNotStr[str],
remove_tags: SequenceNotStr[str],
+ workspace_id: str,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -873,9 +882,10 @@ def tag_many(
Args:
asset_list: List of asset ids to update the tags for.
- source_id: Workspace ID in which the videos needs the operation
+ source_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
add_tags: List of tags to add to given assets.
remove_tags: List of tags to remove from given assets. Pass empty array if nothing is to be removed.
+ workspace_id: Gumlet video workspace ID. Preferred over deprecated `collection_id` / `source_id` (same ID).
extra_headers: Send extra headers with the request.
extra_query: Send extra query parameters with the request.
extra_body: Send extra JSON properties with the request.
@@ -891,6 +901,7 @@ def tag_many(
source_id="60bd2ba353ff754d28179ee6",
add_tags=["tag-1"],
remove_tags=["playlist-1"],
+ workspace_id="60bd2ba353ff754d28179ee6",
)
```
"""
@@ -902,6 +913,7 @@ def tag_many(
"source_id": source_id,
"add_tags": add_tags,
"remove_tags": remove_tags,
+ "workspace_id": workspace_id,
},
video_asset_tag_many_params.VideoAssetTagManyParams,
),
@@ -1040,7 +1052,7 @@ async def create(
Args:
input: URL or web address of a file that Gumlet should download to create a new asset.
- profile_id: Provide `profile_id` of the previously created video profile. This parameter will override all the parameters (except `input` and `collection_id`) from the video profile.
+ profile_id: Provide `profile_id` of the previously created video profile. This parameter will override all the parameters (except `input` and `workspace_id`) from the video profile.
format: Transcode and deliver the asset in the requested format. The options can be one of `ABR` (HLS + DASH) and `MP4`.
tag: Specify a text string or identifier which can identify an asset or bunch of assets later.
title: Specify a text string or identifier which can be used for filtering or searching the asset.
@@ -1127,7 +1139,7 @@ async def create(
async def upload(
self,
*,
- collection_id: str,
+ collection_id: str | Omit = omit,
profile_id: str | Omit = omit,
format: Literal["ABR", "MP4"] | Omit = omit,
tag: SequenceNotStr[str] | Omit = omit,
@@ -1153,6 +1165,7 @@ async def upload(
call_to_actions: Iterable[video_asset_upload_params.CallToAction] | Omit = omit,
playlist_id: str | Omit = omit,
folder: str | Omit = omit,
+ workspace_id: str,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -1164,8 +1177,8 @@ async def upload(
This endpoint creates a video asset allowing upload of a video from the local file system and ingest media content into the Gumlet system for processing and delivery. Body parameters are the same as the Create Asset body parameters except for the `input` parameter, which this endpoint does not take. A successful response will be returned with `upload_url` field. You can make `PUT` request to that URL to upload video. To upload video using `upload_url` refer to [this](https://docs.gumlet.com/docs/direct-upload#2-use-the-url-to-upload-a-file).
Args:
- collection_id: Gumlet video workspace id.
- profile_id: Provide `profile_id` of the previously created video profile. This parameter will override all the parameters (except `input` and `collection_id`) from the video profile.
+ collection_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
+ profile_id: Provide `profile_id` of the previously created video profile. This parameter will override all the parameters (except `input` and `workspace_id`) from the video profile.
format: Transcode and deliver the asset in the requested format. The options can be one of `ABR` (HLS + DASH) and `MP4`.
tag: Specify a text string or identifier which can identify an asset or bunch of assets later.
title: Specify a text string or identifier which can be used for filtering or searching the asset.
@@ -1190,6 +1203,7 @@ async def upload(
call_to_actions: A CTA is an explicit prompt within the video content encouraging viewers to take a particular action.
playlist_id: Add this asset to a playlist.
folder: Add this asset to an existing folder by `folder_id`.
+ workspace_id: Gumlet video workspace ID. Preferred over deprecated `collection_id` / `source_id` (same ID).
extra_headers: Send extra headers with the request.
extra_query: Send extra query parameters with the request.
extra_body: Send extra JSON properties with the request.
@@ -1222,6 +1236,7 @@ async def upload(
],
playlist_id="6597acd5ed6f26a9c5ca9633",
folder="697375fbfa2d1037283140e4",
+ workspace_id="646df1c9173a4a2fcac180b4",
)
```
"""
@@ -1255,6 +1270,7 @@ async def upload(
"call_to_actions": call_to_actions,
"playlist_id": playlist_id,
"folder": folder,
+ "workspace_id": workspace_id,
},
video_asset_upload_params.VideoAssetUploadParams,
),
@@ -1750,7 +1766,8 @@ async def delete_many(
self,
*,
asset_list: SequenceNotStr[str],
- source_id: str,
+ source_id: str | Omit = omit,
+ workspace_id: str,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -1763,7 +1780,8 @@ async def delete_many(
Args:
asset_list: LIst of asset ids to delete
- source_id: Workspace ID from which assets needs to be deleted.
+ source_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
+ workspace_id: Gumlet video workspace ID. Preferred over deprecated `collection_id` / `source_id` (same ID).
extra_headers: Send extra headers with the request.
extra_query: Send extra query parameters with the request.
extra_body: Send extra JSON properties with the request.
@@ -1777,6 +1795,7 @@ async def delete_many(
video_asset = await client.video_assets.delete_many(
asset_list=["64249a8858fd3a208b987702", "64784bae843b155b829bbf84"],
source_id="60bd2ba353ff754d28179ee6",
+ workspace_id="60bd2ba353ff754d28179ee6",
)
```
"""
@@ -1786,6 +1805,7 @@ async def delete_many(
{
"asset_list": asset_list,
"source_id": source_id,
+ "workspace_id": workspace_id,
},
video_asset_delete_many_params.VideoAssetDeleteManyParams,
),
@@ -1799,9 +1819,10 @@ async def tag_many(
self,
*,
asset_list: SequenceNotStr[str],
- source_id: str,
+ source_id: str | Omit = omit,
add_tags: SequenceNotStr[str],
remove_tags: SequenceNotStr[str],
+ workspace_id: str,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -1814,9 +1835,10 @@ async def tag_many(
Args:
asset_list: List of asset ids to update the tags for.
- source_id: Workspace ID in which the videos needs the operation
+ source_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
add_tags: List of tags to add to given assets.
remove_tags: List of tags to remove from given assets. Pass empty array if nothing is to be removed.
+ workspace_id: Gumlet video workspace ID. Preferred over deprecated `collection_id` / `source_id` (same ID).
extra_headers: Send extra headers with the request.
extra_query: Send extra query parameters with the request.
extra_body: Send extra JSON properties with the request.
@@ -1832,6 +1854,7 @@ async def tag_many(
source_id="60bd2ba353ff754d28179ee6",
add_tags=["tag-1"],
remove_tags=["playlist-1"],
+ workspace_id="60bd2ba353ff754d28179ee6",
)
```
"""
@@ -1843,6 +1866,7 @@ async def tag_many(
"source_id": source_id,
"add_tags": add_tags,
"remove_tags": remove_tags,
+ "workspace_id": workspace_id,
},
video_asset_tag_many_params.VideoAssetTagManyParams,
),
diff --git a/src/gumlet/resources/video_playlists.py b/src/gumlet/resources/video_playlists.py
index 5d1e04e..c5a6d66 100644
--- a/src/gumlet/resources/video_playlists.py
+++ b/src/gumlet/resources/video_playlists.py
@@ -51,9 +51,10 @@ def with_streaming_response(self) -> VideoPlaylistsResourceWithStreamingResponse
def create(
self,
*,
- collection_id: str,
+ collection_id: str | Omit = omit,
title: str,
description: str | Omit = omit,
+ workspace_id: str,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -65,9 +66,10 @@ def create(
Create new playlist inside video wprkspace
Args:
- collection_id: Workspace ID in which the playlist should be created
+ collection_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
title: Playlist title
description: Playlist description
+ workspace_id: Gumlet video workspace ID. Preferred over deprecated `collection_id` / `source_id` (same ID).
extra_headers: Send extra headers with the request.
extra_query: Send extra query parameters with the request.
extra_body: Send extra JSON properties with the request.
@@ -82,6 +84,7 @@ def create(
collection_id="{{video-source-id}}",
title="Playlist-Title",
description="This is description for playlist.",
+ workspace_id="{{video-source-id}}",
)
```
"""
@@ -92,6 +95,7 @@ def create(
"collection_id": collection_id,
"title": title,
"description": description,
+ "workspace_id": workspace_id,
},
video_playlist_create_params.VideoPlaylistCreateParams,
),
@@ -104,7 +108,8 @@ def create(
def list_all(
self,
*,
- collection_id: str,
+ workspace_id: str | Omit = omit,
+ collection_id: str | Omit = omit,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -116,7 +121,8 @@ def list_all(
Get all playlists for given workspace
Args:
- collection_id: Workspace ID
+ workspace_id: Gumlet video workspace ID. Preferred. Provide one of `workspace_id` or the deprecated aliases below (same ID).
+ collection_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
extra_headers: Send extra headers with the request.
extra_query: Send extra query parameters with the request.
extra_body: Send extra JSON properties with the request.
@@ -127,9 +133,7 @@ def list_all(
Example:
```python
- video_playlist = client.video_playlists.list_all(
- collection_id="collection_id",
- )
+ video_playlist = client.video_playlists.list_all()
```
"""
return self._get(
@@ -140,7 +144,8 @@ def list_all(
extra_body=extra_body,
timeout=timeout,
query=maybe_transform(
- {"collection_id": collection_id}, video_playlist_list_all_params.VideoPlaylistListAllParams
+ {"workspace_id": workspace_id, "collection_id": collection_id},
+ video_playlist_list_all_params.VideoPlaylistListAllParams,
),
),
cast_to=VideoPlaylistListAllResponse,
@@ -545,9 +550,10 @@ def with_streaming_response(self) -> AsyncVideoPlaylistsResourceWithStreamingRes
async def create(
self,
*,
- collection_id: str,
+ collection_id: str | Omit = omit,
title: str,
description: str | Omit = omit,
+ workspace_id: str,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -559,9 +565,10 @@ async def create(
Create new playlist inside video wprkspace
Args:
- collection_id: Workspace ID in which the playlist should be created
+ collection_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
title: Playlist title
description: Playlist description
+ workspace_id: Gumlet video workspace ID. Preferred over deprecated `collection_id` / `source_id` (same ID).
extra_headers: Send extra headers with the request.
extra_query: Send extra query parameters with the request.
extra_body: Send extra JSON properties with the request.
@@ -576,6 +583,7 @@ async def create(
collection_id="{{video-source-id}}",
title="Playlist-Title",
description="This is description for playlist.",
+ workspace_id="{{video-source-id}}",
)
```
"""
@@ -586,6 +594,7 @@ async def create(
"collection_id": collection_id,
"title": title,
"description": description,
+ "workspace_id": workspace_id,
},
video_playlist_create_params.VideoPlaylistCreateParams,
),
@@ -598,7 +607,8 @@ async def create(
async def list_all(
self,
*,
- collection_id: str,
+ workspace_id: str | Omit = omit,
+ collection_id: str | Omit = omit,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -610,7 +620,8 @@ async def list_all(
Get all playlists for given workspace
Args:
- collection_id: Workspace ID
+ workspace_id: Gumlet video workspace ID. Preferred. Provide one of `workspace_id` or the deprecated aliases below (same ID).
+ collection_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
extra_headers: Send extra headers with the request.
extra_query: Send extra query parameters with the request.
extra_body: Send extra JSON properties with the request.
@@ -621,9 +632,7 @@ async def list_all(
Example:
```python
- video_playlist = await client.video_playlists.list_all(
- collection_id="collection_id",
- )
+ video_playlist = await client.video_playlists.list_all()
```
"""
return await self._get(
@@ -634,7 +643,8 @@ async def list_all(
extra_body=extra_body,
timeout=timeout,
query=await async_maybe_transform(
- {"collection_id": collection_id}, video_playlist_list_all_params.VideoPlaylistListAllParams
+ {"workspace_id": workspace_id, "collection_id": collection_id},
+ video_playlist_list_all_params.VideoPlaylistListAllParams,
),
),
cast_to=VideoPlaylistListAllResponse,
diff --git a/src/gumlet/resources/video_usage_analytics.py b/src/gumlet/resources/video_usage_analytics.py
index f5106ec..0b2ef47 100644
--- a/src/gumlet/resources/video_usage_analytics.py
+++ b/src/gumlet/resources/video_usage_analytics.py
@@ -114,6 +114,7 @@ def top_assets(
*,
start_at: Union[str, date],
end_at: Union[str, date],
+ workspace_id: str | Omit = omit,
collection_id: str | Omit = omit,
page: str | Omit = omit,
page_size: str | Omit = omit,
@@ -130,7 +131,8 @@ def top_assets(
Args:
start_at: Date in yyyy-MM-dd format
end_at: Date in yyyy-MM-dd format
- collection_id: Gumlet workspace ID
+ workspace_id: Gumlet video workspace ID. Preferred. Provide `workspace_id`, or deprecated `collection_id` / `source_id` (same ID).
+ collection_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
page: Page number of the response.
page_size: Assets to list per page.
extra_headers: Send extra headers with the request.
@@ -162,6 +164,7 @@ def top_assets(
{
"start_at": start_at,
"end_at": end_at,
+ "workspace_id": workspace_id,
"collection_id": collection_id,
"page": page,
"page_size": page_size,
@@ -261,6 +264,7 @@ async def top_assets(
*,
start_at: Union[str, date],
end_at: Union[str, date],
+ workspace_id: str | Omit = omit,
collection_id: str | Omit = omit,
page: str | Omit = omit,
page_size: str | Omit = omit,
@@ -277,7 +281,8 @@ async def top_assets(
Args:
start_at: Date in yyyy-MM-dd format
end_at: Date in yyyy-MM-dd format
- collection_id: Gumlet workspace ID
+ workspace_id: Gumlet video workspace ID. Preferred. Provide `workspace_id`, or deprecated `collection_id` / `source_id` (same ID).
+ collection_id: Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility.
page: Page number of the response.
page_size: Assets to list per page.
extra_headers: Send extra headers with the request.
@@ -309,6 +314,7 @@ async def top_assets(
{
"start_at": start_at,
"end_at": end_at,
+ "workspace_id": workspace_id,
"collection_id": collection_id,
"page": page,
"page_size": page_size,
diff --git a/src/gumlet/resources/webhooks.py b/src/gumlet/resources/webhooks.py
index 4a59b2c..862d8d4 100644
--- a/src/gumlet/resources/webhooks.py
+++ b/src/gumlet/resources/webhooks.py
@@ -5,9 +5,11 @@
import json
from typing import Mapping, cast
+from .._types import HeadersLike
+from .._utils import get_required_header
from .._models import construct_type
from .._resource import SyncAPIResource, AsyncAPIResource
-from .._exceptions import GumletError
+from .._webhook_verify import verify_webhook_signature
from ..types.parsed_webhook_event import ParsedWebhookEvent
__all__ = ["WebhooksResource", "AsyncWebhooksResource"]
@@ -15,22 +17,27 @@
class WebhooksResource(SyncAPIResource):
def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | bytes | None = None) -> ParsedWebhookEvent:
- try:
- from standardwebhooks import Webhook
- except ImportError as exc:
- raise GumletError("You need to install `gumlet[webhooks]` to use this method") from exc
-
if key is None:
key = self._client.webhook_secret
if key is None:
raise ValueError(
"Cannot verify a webhook without a key on either the client's webhook_secret or passed in as an argument"
)
+ try:
+ from base64 import b64decode
+
+ empty_key = (
+ not key if isinstance(key, bytes) else not b64decode(key[6:] if key.startswith("whsec_") else key)
+ )
+ except ValueError:
+ empty_key = False
+ if empty_key:
+ raise ValueError("Cannot verify a webhook with a key that decodes to an empty key")
if not isinstance(headers, dict):
headers = dict(headers)
- Webhook(key).verify(payload, headers)
+ verify_webhook_signature(payload, headers, key)
return cast(
ParsedWebhookEvent,
@@ -40,26 +47,115 @@ def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | bytes |
),
)
+ def verify_signature(self, payload: str | bytes, headers: HeadersLike, *, key: str | bytes | None = None) -> None:
+ """Verifies that `payload` was signed with the webhook key, raising when it was not.
-class AsyncWebhooksResource(AsyncAPIResource):
- def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | bytes | None = None) -> ParsedWebhookEvent:
+ `key` defaults to the client's `webhook_secret`. A missing signature header raises too.
+ """
+ if key is None:
+ key = self._client.webhook_secret
+ if key is None:
+ raise ValueError(
+ "Cannot verify a webhook without a key on either the client's webhook_secret or passed in as an argument"
+ )
try:
- from standardwebhooks import Webhook
- except ImportError as exc:
- raise GumletError("You need to install `gumlet[webhooks]` to use this method") from exc
+ from base64 import b64decode
+
+ empty_key = (
+ not key if isinstance(key, bytes) else not b64decode(key[6:] if key.startswith("whsec_") else key)
+ )
+ except ValueError:
+ empty_key = False
+ if empty_key:
+ raise ValueError("Cannot verify a webhook with a key that decodes to an empty key")
+
+ signed_headers = {
+ "webhook-id": get_required_header(headers, "webhook-id"),
+ "webhook-timestamp": get_required_header(headers, "webhook-timestamp"),
+ "webhook-signature": get_required_header(headers, "webhook-signature"),
+ }
+ verify_webhook_signature(payload, signed_headers, key)
+
+ def parse_unsafe(self, payload: str) -> ParsedWebhookEvent:
+ """Parses `payload` into a typed event without verifying its signature.
+
+ Only use this for a payload whose signature was already verified, or in tests.
+ """
+ return cast(
+ ParsedWebhookEvent,
+ construct_type(
+ type_=ParsedWebhookEvent,
+ value=json.loads(payload),
+ ),
+ )
+
+class AsyncWebhooksResource(AsyncAPIResource):
+ def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | bytes | None = None) -> ParsedWebhookEvent:
if key is None:
key = self._client.webhook_secret
if key is None:
raise ValueError(
"Cannot verify a webhook without a key on either the client's webhook_secret or passed in as an argument"
)
+ try:
+ from base64 import b64decode
+
+ empty_key = (
+ not key if isinstance(key, bytes) else not b64decode(key[6:] if key.startswith("whsec_") else key)
+ )
+ except ValueError:
+ empty_key = False
+ if empty_key:
+ raise ValueError("Cannot verify a webhook with a key that decodes to an empty key")
if not isinstance(headers, dict):
headers = dict(headers)
- Webhook(key).verify(payload, headers)
+ verify_webhook_signature(payload, headers, key)
+
+ return cast(
+ ParsedWebhookEvent,
+ construct_type(
+ type_=ParsedWebhookEvent,
+ value=json.loads(payload),
+ ),
+ )
+
+ def verify_signature(self, payload: str | bytes, headers: HeadersLike, *, key: str | bytes | None = None) -> None:
+ """Verifies that `payload` was signed with the webhook key, raising when it was not.
+ `key` defaults to the client's `webhook_secret`. A missing signature header raises too.
+ """
+ if key is None:
+ key = self._client.webhook_secret
+ if key is None:
+ raise ValueError(
+ "Cannot verify a webhook without a key on either the client's webhook_secret or passed in as an argument"
+ )
+ try:
+ from base64 import b64decode
+
+ empty_key = (
+ not key if isinstance(key, bytes) else not b64decode(key[6:] if key.startswith("whsec_") else key)
+ )
+ except ValueError:
+ empty_key = False
+ if empty_key:
+ raise ValueError("Cannot verify a webhook with a key that decodes to an empty key")
+
+ signed_headers = {
+ "webhook-id": get_required_header(headers, "webhook-id"),
+ "webhook-timestamp": get_required_header(headers, "webhook-timestamp"),
+ "webhook-signature": get_required_header(headers, "webhook-signature"),
+ }
+ verify_webhook_signature(payload, signed_headers, key)
+
+ def parse_unsafe(self, payload: str) -> ParsedWebhookEvent:
+ """Parses `payload` into a typed event without verifying its signature.
+
+ Only use this for a payload whose signature was already verified, or in tests.
+ """
return cast(
ParsedWebhookEvent,
construct_type(
diff --git a/src/gumlet/types/folder_create_response.py b/src/gumlet/types/folder_create_response.py
index 7abbd6c..5f8a43e 100644
--- a/src/gumlet/types/folder_create_response.py
+++ b/src/gumlet/types/folder_create_response.py
@@ -16,7 +16,7 @@ class FolderCreateResponse(BaseModel):
"""Folder name"""
video_source_id: Optional[str] = None
- """Workspace ID"""
+ """Deprecated. Use `workspace_id` instead. Still returned for backwards compatibility."""
parent_id: Optional[str] = None
"""Parent folder ID"""
@@ -40,3 +40,6 @@ class FolderCreateResponse(BaseModel):
updated_at: Optional[datetime] = None
"""Folder update time in ISO 8601 format"""
+
+ workspace_id: Optional[str] = None
+ """Workspace ID"""
diff --git a/src/gumlet/types/folder_list_response.py b/src/gumlet/types/folder_list_response.py
index ff83064..24ccb0e 100644
--- a/src/gumlet/types/folder_list_response.py
+++ b/src/gumlet/types/folder_list_response.py
@@ -17,7 +17,7 @@ class FolderListResponseItem(BaseModel):
"""Folder name"""
video_source_id: Optional[str] = None
- """Workspace ID"""
+ """Deprecated. Use `workspace_id` instead. Still returned for backwards compatibility."""
parent_id: Optional[str] = None
"""Parent folder ID"""
@@ -42,5 +42,8 @@ class FolderListResponseItem(BaseModel):
updated_at: Optional[datetime] = None
"""Folder update time in ISO 8601 format"""
+ workspace_id: Optional[str] = None
+ """Workspace ID"""
+
FolderListResponse: TypeAlias = List[FolderListResponseItem]
diff --git a/src/gumlet/types/folder_retrieve_response.py b/src/gumlet/types/folder_retrieve_response.py
index 057d833..642c557 100644
--- a/src/gumlet/types/folder_retrieve_response.py
+++ b/src/gumlet/types/folder_retrieve_response.py
@@ -16,7 +16,7 @@ class FolderRetrieveResponse(BaseModel):
"""Folder name"""
video_source_id: Optional[str] = None
- """Workspace ID"""
+ """Deprecated. Use `workspace_id` instead. Still returned for backwards compatibility."""
parent_id: Optional[str] = None
"""Parent folder ID"""
@@ -40,3 +40,6 @@ class FolderRetrieveResponse(BaseModel):
updated_at: Optional[datetime] = None
"""Folder update time in ISO 8601 format"""
+
+ workspace_id: Optional[str] = None
+ """Workspace ID"""
diff --git a/src/gumlet/types/folder_update_response.py b/src/gumlet/types/folder_update_response.py
index fd311ce..9b05f49 100644
--- a/src/gumlet/types/folder_update_response.py
+++ b/src/gumlet/types/folder_update_response.py
@@ -22,6 +22,7 @@ class FolderUpdateResponse(BaseModel):
name: Optional[str] = None
video_source_id: Optional[str] = None
+ """Deprecated. Use `workspace_id` instead. Still returned for backwards compatibility."""
parent_id: Optional[str] = None
@@ -41,5 +42,8 @@ class FolderUpdateResponse(BaseModel):
updated_at: Optional[str] = None
"""ISO Timestamp"""
+ workspace_id: Optional[str] = None
+ """Workspace ID"""
+
FolderUpdateResponse: TypeAlias = Union[FolderUpdateResponse, FolderUpdateResponse2]
diff --git a/src/gumlet/types/global_search_search_params.py b/src/gumlet/types/global_search_search_params.py
index 27ab205..d70e7e2 100644
--- a/src/gumlet/types/global_search_search_params.py
+++ b/src/gumlet/types/global_search_search_params.py
@@ -11,8 +11,11 @@ class GlobalSearchSearchParams(TypedDict, total=False):
search_query: Required[str]
"""Search query term"""
+ workspace_id: str
+ """Gumlet video workspace ID. Preferred. Provide `workspace_id`, or deprecated `collection_id` / `source_id` (same ID)."""
+
collection_id: str
- """Workspace ID if you want to limit search to a specific workspace"""
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
size: int
"""Number of results to return"""
diff --git a/src/gumlet/types/video_asset_create_params.py b/src/gumlet/types/video_asset_create_params.py
index 691f3ce..6e5251f 100644
--- a/src/gumlet/types/video_asset_create_params.py
+++ b/src/gumlet/types/video_asset_create_params.py
@@ -25,7 +25,7 @@ class VideoAssetCreateParams(TypedDict, total=False):
"""URL or web address of a file that Gumlet should download to create a new asset."""
profile_id: str
- """Provide `profile_id` of the previously created video profile. This parameter will override all the parameters (except `input` and `collection_id`) from the video profile."""
+ """Provide `profile_id` of the previously created video profile. This parameter will override all the parameters (except `input` and `workspace_id`) from the video profile."""
format: Literal["ABR", "MP4"]
"""Transcode and deliver the asset in the requested format. The options can be one of `ABR` (HLS + DASH) and `MP4`."""
diff --git a/src/gumlet/types/video_asset_delete_many_params.py b/src/gumlet/types/video_asset_delete_many_params.py
index 061d950..18befc7 100644
--- a/src/gumlet/types/video_asset_delete_many_params.py
+++ b/src/gumlet/types/video_asset_delete_many_params.py
@@ -12,5 +12,8 @@ class VideoAssetDeleteManyParams(TypedDict, total=False):
asset_list: Required[SequenceNotStr[str]]
"""LIst of asset ids to delete"""
- source_id: Required[str]
- """Workspace ID from which assets needs to be deleted."""
+ source_id: str
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
+
+ workspace_id: Required[str]
+ """Gumlet video workspace ID. Preferred over deprecated `collection_id` / `source_id` (same ID)."""
diff --git a/src/gumlet/types/video_asset_list_deprecated_response.py b/src/gumlet/types/video_asset_list_deprecated_response.py
index c8e8ffc..993b397 100644
--- a/src/gumlet/types/video_asset_list_deprecated_response.py
+++ b/src/gumlet/types/video_asset_list_deprecated_response.py
@@ -92,11 +92,15 @@ class AllAsset(BaseModel):
tag: Optional[str] = None
source_id: Optional[str] = None
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
input: Optional[AllAssetInput] = None
output: Optional[AllAssetOutput] = None
+ workspace_id: Optional[str] = None
+ """Workspace ID"""
+
class VideoAssetListDeprecatedResponse(BaseModel):
all_assets: Optional[List[AllAsset]] = None
diff --git a/src/gumlet/types/video_asset_list_response.py b/src/gumlet/types/video_asset_list_response.py
index 172e1c5..5fa56ee 100644
--- a/src/gumlet/types/video_asset_list_response.py
+++ b/src/gumlet/types/video_asset_list_response.py
@@ -96,11 +96,15 @@ class AllAsset(BaseModel):
tag: Optional[str] = None
source_id: Optional[str] = None
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
input: Optional[AllAssetInput] = None
output: Optional[AllAssetOutput] = None
+ workspace_id: Optional[str] = None
+ """Workspace ID"""
+
class Folder(BaseModel):
id: Optional[str] = None
@@ -108,6 +112,7 @@ class Folder(BaseModel):
name: Optional[str] = None
video_source_id: Optional[str] = None
+ """Deprecated. Use `workspace_id` instead. Still returned for backwards compatibility."""
parent_id: Optional[str] = None
@@ -125,6 +130,9 @@ class Folder(BaseModel):
updated_at: Optional[str] = None
+ workspace_id: Optional[str] = None
+ """Workspace ID"""
+
class VideoAssetListResponse(BaseModel):
folders: Optional[List[Folder]] = None
diff --git a/src/gumlet/types/video_asset_tag_many_params.py b/src/gumlet/types/video_asset_tag_many_params.py
index 0c6ee23..d1a0d72 100644
--- a/src/gumlet/types/video_asset_tag_many_params.py
+++ b/src/gumlet/types/video_asset_tag_many_params.py
@@ -12,11 +12,14 @@ class VideoAssetTagManyParams(TypedDict, total=False):
asset_list: Required[SequenceNotStr[str]]
"""List of asset ids to update the tags for."""
- source_id: Required[str]
- """Workspace ID in which the videos needs the operation"""
+ source_id: str
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
add_tags: Required[SequenceNotStr[str]]
"""List of tags to add to given assets."""
remove_tags: Required[SequenceNotStr[str]]
"""List of tags to remove from given assets. Pass empty array if nothing is to be removed."""
+
+ workspace_id: Required[str]
+ """Gumlet video workspace ID. Preferred over deprecated `collection_id` / `source_id` (same ID)."""
diff --git a/src/gumlet/types/video_asset_upload_params.py b/src/gumlet/types/video_asset_upload_params.py
index 286c1b2..28a841b 100644
--- a/src/gumlet/types/video_asset_upload_params.py
+++ b/src/gumlet/types/video_asset_upload_params.py
@@ -21,11 +21,11 @@
class VideoAssetUploadParams(TypedDict, total=False):
- collection_id: Required[str]
- """Gumlet video workspace id."""
+ collection_id: str
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
profile_id: str
- """Provide `profile_id` of the previously created video profile. This parameter will override all the parameters (except `input` and `collection_id`) from the video profile."""
+ """Provide `profile_id` of the previously created video profile. This parameter will override all the parameters (except `input` and `workspace_id`) from the video profile."""
format: Literal["ABR", "MP4"]
"""Transcode and deliver the asset in the requested format. The options can be one of `ABR` (HLS + DASH) and `MP4`."""
@@ -99,6 +99,9 @@ class VideoAssetUploadParams(TypedDict, total=False):
folder: str
"""Add this asset to an existing folder by `folder_id`."""
+ workspace_id: Required[str]
+ """Gumlet video workspace ID. Preferred over deprecated `collection_id` / `source_id` (same ID)."""
+
class CallToAction(TypedDict, total=False):
text: str
diff --git a/src/gumlet/types/video_playlist_create_params.py b/src/gumlet/types/video_playlist_create_params.py
index 737db82..4cb807e 100644
--- a/src/gumlet/types/video_playlist_create_params.py
+++ b/src/gumlet/types/video_playlist_create_params.py
@@ -8,11 +8,14 @@
class VideoPlaylistCreateParams(TypedDict, total=False):
- collection_id: Required[str]
- """Workspace ID in which the playlist should be created"""
+ collection_id: str
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
title: Required[str]
"""Playlist title"""
description: str
"""Playlist description"""
+
+ workspace_id: Required[str]
+ """Gumlet video workspace ID. Preferred over deprecated `collection_id` / `source_id` (same ID)."""
diff --git a/src/gumlet/types/video_playlist_create_response.py b/src/gumlet/types/video_playlist_create_response.py
index 05f2260..13b6378 100644
--- a/src/gumlet/types/video_playlist_create_response.py
+++ b/src/gumlet/types/video_playlist_create_response.py
@@ -52,7 +52,7 @@ class VideoPlaylistCreateResponse(BaseModel):
"""Playlist ID"""
collection_id: Optional[str] = None
- """Workspace ID"""
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
title: Optional[str] = None
"""Playlist title"""
@@ -62,3 +62,6 @@ class VideoPlaylistCreateResponse(BaseModel):
player_config: Optional[PlayerConfig] = None
"""Player config for assets in the playlist"""
+
+ workspace_id: Optional[str] = None
+ """Workspace ID"""
diff --git a/src/gumlet/types/video_playlist_list_all_params.py b/src/gumlet/types/video_playlist_list_all_params.py
index 880c53f..1caf820 100644
--- a/src/gumlet/types/video_playlist_list_all_params.py
+++ b/src/gumlet/types/video_playlist_list_all_params.py
@@ -2,11 +2,14 @@
from __future__ import annotations
-from typing_extensions import Required, TypedDict
+from typing_extensions import TypedDict
__all__ = ["VideoPlaylistListAllParams"]
class VideoPlaylistListAllParams(TypedDict, total=False):
- collection_id: Required[str]
- """Workspace ID"""
+ workspace_id: str
+ """Gumlet video workspace ID. Preferred. Provide one of `workspace_id` or the deprecated aliases below (same ID)."""
+
+ collection_id: str
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
diff --git a/src/gumlet/types/video_playlist_list_all_response.py b/src/gumlet/types/video_playlist_list_all_response.py
index 29be0b5..f6b0830 100644
--- a/src/gumlet/types/video_playlist_list_all_response.py
+++ b/src/gumlet/types/video_playlist_list_all_response.py
@@ -13,7 +13,7 @@ class VideoPlaylistListAllResponseItem(BaseModel):
"""Playlist ID"""
collection_id: Optional[str] = None
- """Workspace ID"""
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
title: Optional[str] = None
"""Title of the playlist"""
@@ -24,5 +24,8 @@ class VideoPlaylistListAllResponseItem(BaseModel):
player_config: Optional[object] = None
"""Player configuration for the playlist"""
+ workspace_id: Optional[str] = None
+ """Workspace ID"""
+
VideoPlaylistListAllResponse: TypeAlias = List[VideoPlaylistListAllResponseItem]
diff --git a/src/gumlet/types/video_playlist_update_response.py b/src/gumlet/types/video_playlist_update_response.py
index 9af9f5a..bf2cd6b 100644
--- a/src/gumlet/types/video_playlist_update_response.py
+++ b/src/gumlet/types/video_playlist_update_response.py
@@ -52,7 +52,7 @@ class VideoPlaylistUpdateResponse(BaseModel):
"""Playlist ID"""
collection_id: Optional[str] = None
- """Workspace ID"""
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
title: Optional[str] = None
"""Updated playlist title"""
@@ -61,3 +61,6 @@ class VideoPlaylistUpdateResponse(BaseModel):
"""Updated playlist description"""
player_config: Optional[PlayerConfig] = None
+
+ workspace_id: Optional[str] = None
+ """Workspace ID"""
diff --git a/src/gumlet/types/video_usage_analytic_retrieve_params.py b/src/gumlet/types/video_usage_analytic_retrieve_params.py
index e7ea33a..1a483ca 100644
--- a/src/gumlet/types/video_usage_analytic_retrieve_params.py
+++ b/src/gumlet/types/video_usage_analytic_retrieve_params.py
@@ -44,10 +44,13 @@ class VideoUsageAnalyticRetrieveParams(TypedDict, total=False):
class Filters(TypedDict, total=False):
collection_id: str
- """The ID of the `workspace` you want to filter the data for."""
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
source_id: str
- """The ID of the `workspace` you want to filter the data for. Deprecated."""
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
+
+ workspace_id: str
+ """Gumlet video workspace ID. Preferred over deprecated `collection_id` / `source_id` (same ID)."""
class DateRange(TypedDict, total=False):
diff --git a/src/gumlet/types/video_usage_analytic_top_assets_params.py b/src/gumlet/types/video_usage_analytic_top_assets_params.py
index d208203..b907d74 100644
--- a/src/gumlet/types/video_usage_analytic_top_assets_params.py
+++ b/src/gumlet/types/video_usage_analytic_top_assets_params.py
@@ -18,8 +18,11 @@ class VideoUsageAnalyticTopAssetsParams(TypedDict, total=False):
end_at: Required[Annotated[Union[str, date], PropertyInfo(format="iso8601")]]
"""Date in yyyy-MM-dd format"""
+ workspace_id: str
+ """Gumlet video workspace ID. Preferred. Provide `workspace_id`, or deprecated `collection_id` / `source_id` (same ID)."""
+
collection_id: str
- """Gumlet workspace ID"""
+ """Deprecated. Use `workspace_id` instead. Still accepted for backwards compatibility."""
page: str
"""Page number of the response."""
diff --git a/tests/smoke-test.py b/tests/smoke-test.py
index 8466e74..4a9f7d6 100644
--- a/tests/smoke-test.py
+++ b/tests/smoke-test.py
@@ -131,6 +131,7 @@ def _smoke_case_2() -> None:
],
playlist_id="6597acd5ed6f26a9c5ca9633",
folder="697375fbfa2d1037283140e4",
+ workspace_id="646df1c9173a4a2fcac180b4",
)
@@ -196,6 +197,7 @@ def _smoke_case_3() -> None:
],
playlist_id="6597acd5ed6f26a9c5ca9633",
folder="697375fbfa2d1037283140e4",
+ workspace_id="646df1c9173a4a2fcac180b4",
)
@@ -312,6 +314,7 @@ def _smoke_case_15() -> None:
video_asset = client.video_assets.delete_many(
asset_list=["64249a8858fd3a208b987702", "64784bae843b155b829bbf84"],
source_id="60bd2ba353ff754d28179ee6",
+ workspace_id="60bd2ba353ff754d28179ee6",
)
@@ -321,6 +324,7 @@ def _smoke_case_16() -> None:
source_id="60bd2ba353ff754d28179ee6",
add_tags=["tag-1"],
remove_tags=["playlist-1"],
+ workspace_id="60bd2ba353ff754d28179ee6",
)
@@ -386,7 +390,7 @@ def _smoke_case_24() -> None:
video_usage_analytic = client.video_usage_analytics.retrieve(
metrics=["bandwidth_consumption", "asset_duration", "storage_unit", "top_assets", "drm_requests"],
date_range={"start_at": "2026-08-01", "end_at": "2026-08-20"},
- filters={"collection_id": "", "source_id": ""},
+ filters={"collection_id": "", "source_id": "", "workspace_id": "646df1c9173a4a2fcac180b4"},
top_assets_count="5",
top_assets_page="0",
group_by="hourly",
@@ -406,6 +410,7 @@ def _smoke_case_26() -> None:
video_usage_analytic = client.video_usage_analytics.top_assets(
start_at="2026-06-21",
end_at="2026-06-30",
+ workspace_id="workspace_id",
collection_id="collection_id",
page="1",
page_size="1000",
@@ -585,16 +590,22 @@ def _smoke_case_40() -> None:
collection_id="{{video-source-id}}",
title="Playlist-Title",
description="This is description for playlist.",
+ workspace_id="{{video-source-id}}",
)
def _smoke_case_41() -> None:
+ video_playlist = client.video_playlists.list_all()
+
+
+def _smoke_case_42() -> None:
video_playlist = client.video_playlists.list_all(
+ workspace_id="workspace_id",
collection_id="collection_id",
)
-def _smoke_case_42() -> None:
+def _smoke_case_43() -> None:
video_playlist = client.video_playlists.create_asset(
playlist_id="playlistId",
asset_list=[
@@ -607,14 +618,14 @@ def _smoke_case_42() -> None:
)
-def _smoke_case_43() -> None:
+def _smoke_case_44() -> None:
video_playlist = client.video_playlists.delete_asset(
playlist_id="playlistId",
delete_list=["6508790783e4d606118467a3"],
)
-def _smoke_case_44() -> None:
+def _smoke_case_45() -> None:
video_playlist = client.video_playlists.update(
playlist_id="playlistId",
title="Playlist-Title-Updated",
@@ -644,13 +655,13 @@ def _smoke_case_44() -> None:
)
-def _smoke_case_45() -> None:
+def _smoke_case_46() -> None:
client.video_playlists.delete(
playlist_id="playlistId",
)
-def _smoke_case_46() -> None:
+def _smoke_case_47() -> None:
video_playlist = client.video_playlists.list_assets(
playlist_id="playlistId",
sort_order=1,
@@ -659,7 +670,7 @@ def _smoke_case_46() -> None:
)
-def _smoke_case_47() -> None:
+def _smoke_case_48() -> None:
video_playlist = client.video_playlists.list_assets(
playlist_id="playlistId",
sort_by="sort_by",
@@ -669,7 +680,7 @@ def _smoke_case_47() -> None:
)
-def _smoke_case_48() -> None:
+def _smoke_case_49() -> None:
video_playlist = client.video_playlists.reorder_asset(
playlist_id="playlistId",
asset_id="6e82bf783e88be000ab45ed2",
@@ -679,7 +690,7 @@ def _smoke_case_48() -> None:
)
-def _smoke_case_49() -> None:
+def _smoke_case_50() -> None:
webhook_endpoint = client.webhook_endpoints.create(
url="",
secret_token="",
@@ -688,17 +699,17 @@ def _smoke_case_49() -> None:
)
-def _smoke_case_50() -> None:
+def _smoke_case_51() -> None:
webhook_endpoint = client.webhook_endpoints.list()
-def _smoke_case_51() -> None:
+def _smoke_case_52() -> None:
webhook_endpoint = client.webhook_endpoints.update(
webhook_id="webhookId",
)
-def _smoke_case_52() -> None:
+def _smoke_case_53() -> None:
webhook_endpoint = client.webhook_endpoints.update(
webhook_id="webhookId",
url="",
@@ -708,19 +719,19 @@ def _smoke_case_52() -> None:
)
-def _smoke_case_53() -> None:
+def _smoke_case_54() -> None:
webhook_endpoint = client.webhook_endpoints.delete(
webhook_id="webhookId",
)
-def _smoke_case_54() -> None:
+def _smoke_case_55() -> None:
webhook_endpoint = client.webhook_endpoints.history(
webhook_id="webhookId",
)
-def _smoke_case_55() -> None:
+def _smoke_case_56() -> None:
image_source = client.image_sources.create(
namespace="google-demo",
type="webfolder",
@@ -728,7 +739,7 @@ def _smoke_case_55() -> None:
)
-def _smoke_case_56() -> None:
+def _smoke_case_57() -> None:
image_source = client.image_sources.create(
namespace="google-demo",
type="webfolder",
@@ -753,20 +764,20 @@ def _smoke_case_56() -> None:
)
-def _smoke_case_57() -> None:
+def _smoke_case_58() -> None:
image_source = client.image_sources.list(
offset=0,
size=20,
)
-def _smoke_case_58() -> None:
+def _smoke_case_59() -> None:
image_source = client.image_sources.retrieve(
image_source_id="imageSourceId",
)
-def _smoke_case_59() -> None:
+def _smoke_case_60() -> None:
image_source = client.image_sources.update(
image_source_id="imageSourceId",
type="aws",
@@ -779,7 +790,7 @@ def _smoke_case_59() -> None:
)
-def _smoke_case_60() -> None:
+def _smoke_case_61() -> None:
image_source = client.image_sources.update(
image_source_id="imageSourceId",
type="aws",
@@ -820,27 +831,27 @@ def _smoke_case_60() -> None:
)
-def _smoke_case_61() -> None:
+def _smoke_case_62() -> None:
image_source = client.image_sources.delete(
image_source_id="imageSourceId",
)
-def _smoke_case_62() -> None:
+def _smoke_case_63() -> None:
image_source = client.image_sources.purge_cache(
subdomain="subdomain",
paths=["image.jpeg", "image2.png"],
)
-def _smoke_case_63() -> None:
+def _smoke_case_64() -> None:
image_source = client.image_sources.purge(
source_id="sourceId",
paths=["image.jpeg", "image2.png"],
)
-def _smoke_case_64() -> None:
+def _smoke_case_65() -> None:
image_usage_analytic = client.image_usage_analytics.retrieve(
metrics=["bandwidth_consumption"],
date_range={"start_at": "2024-01-01", "end_at": "2024-01-01"},
@@ -848,7 +859,7 @@ def _smoke_case_64() -> None:
)
-def _smoke_case_65() -> None:
+def _smoke_case_66() -> None:
image_usage_analytic = client.image_usage_analytics.retrieve(
metrics=["bandwidth_consumption"],
date_range={"start_at": "2024-01-01", "end_at": "2024-01-01"},
@@ -857,14 +868,14 @@ def _smoke_case_65() -> None:
)
-def _smoke_case_66() -> None:
+def _smoke_case_67() -> None:
live_stream_asset = client.live_stream_assets.create(
live_source_id="",
resolution="",
)
-def _smoke_case_67() -> None:
+def _smoke_case_68() -> None:
live_stream_asset = client.live_stream_assets.create(
live_source_id="",
resolution="",
@@ -875,13 +886,13 @@ def _smoke_case_67() -> None:
)
-def _smoke_case_68() -> None:
+def _smoke_case_69() -> None:
live_stream_asset = client.live_stream_assets.update(
live_asset_id="",
)
-def _smoke_case_69() -> None:
+def _smoke_case_70() -> None:
live_stream_asset = client.live_stream_assets.update(
live_asset_id="",
title="",
@@ -889,31 +900,31 @@ def _smoke_case_69() -> None:
)
-def _smoke_case_70() -> None:
+def _smoke_case_71() -> None:
live_stream_asset = client.live_stream_assets.retrieve_status(
live_asset_id="liveAssetId",
)
-def _smoke_case_71() -> None:
+def _smoke_case_72() -> None:
live_stream_asset = client.live_stream_assets.delete(
live_asset_id="liveAssetId",
)
-def _smoke_case_72() -> None:
+def _smoke_case_73() -> None:
live_stream_asset = client.live_stream_assets.complete(
live_asset_id="liveAssetId",
)
-def _smoke_case_73() -> None:
+def _smoke_case_74() -> None:
live_stream_asset = client.live_stream_assets.filter(
live_source_id="liveSourceId",
)
-def _smoke_case_74() -> None:
+def _smoke_case_75() -> None:
live_stream_asset = client.live_stream_assets.filter(
live_source_id="liveSourceId",
status="status",
@@ -922,39 +933,39 @@ def _smoke_case_74() -> None:
)
-def _smoke_case_75() -> None:
+def _smoke_case_76() -> None:
client.live_stream_assets.start(
live_asset_id="liveAssetId",
)
-def _smoke_case_76() -> None:
+def _smoke_case_77() -> None:
live_stream_asset = client.live_stream_assets.upload(
live_asset_id="68c406b147f9ad0c0d584ce2",
statuses="preparing",
)
-def _smoke_case_77() -> None:
+def _smoke_case_78() -> None:
live_stream_asset = client.live_stream_assets.status_history(
live_asset_id="liveAssetId",
)
-def _smoke_case_78() -> None:
+def _smoke_case_79() -> None:
client.recycle_bin.recover(
asset_id="",
)
-def _smoke_case_79() -> None:
+def _smoke_case_80() -> None:
recycle_bin = client.recycle_bin.list(
size=20,
workspace_id="workspace_id",
)
-def _smoke_case_80() -> None:
+def _smoke_case_81() -> None:
recycle_bin = client.recycle_bin.list(
offset=1,
size=20,
@@ -962,14 +973,14 @@ def _smoke_case_80() -> None:
)
-def _smoke_case_81() -> None:
+def _smoke_case_82() -> None:
video_workspace = client.video_workspaces.list(
offset="0",
size="10",
)
-def _smoke_case_82() -> None:
+def _smoke_case_83() -> None:
video_workspace = client.video_workspaces.create(
name="My Workspace",
type="direct-upload",
@@ -977,7 +988,7 @@ def _smoke_case_82() -> None:
)
-def _smoke_case_83() -> None:
+def _smoke_case_84() -> None:
video_workspace = client.video_workspaces.create(
name="My Workspace",
type="direct-upload",
@@ -1011,7 +1022,7 @@ def _smoke_case_83() -> None:
)
-def _smoke_case_84() -> None:
+def _smoke_case_85() -> None:
video_workspace = client.video_workspaces.update(
workspace_id="workspaceId",
name="awsrename",
@@ -1026,7 +1037,7 @@ def _smoke_case_84() -> None:
)
-def _smoke_case_85() -> None:
+def _smoke_case_86() -> None:
video_workspace = client.video_workspaces.update(
workspace_id="workspaceId",
name="awsrename",
@@ -1097,19 +1108,19 @@ def _smoke_case_85() -> None:
)
-def _smoke_case_86() -> None:
+def _smoke_case_87() -> None:
video_workspace = client.video_workspaces.retrieve(
workspace_id="workspaceId",
)
-def _smoke_case_87() -> None:
+def _smoke_case_88() -> None:
video_workspace = client.video_workspaces.delete(
workspace_id="workspaceId",
)
-def _smoke_case_88() -> None:
+def _smoke_case_89() -> None:
folder = client.folders.create(
workspace_id="workspaceId",
name="Course Assets",
@@ -1117,27 +1128,27 @@ def _smoke_case_88() -> None:
)
-def _smoke_case_89() -> None:
+def _smoke_case_90() -> None:
folder = client.folders.list(
workspace_id="workspaceId",
)
-def _smoke_case_90() -> None:
+def _smoke_case_91() -> None:
folder = client.folders.list(
workspace_id="workspaceId",
parent_id="parent_id",
)
-def _smoke_case_91() -> None:
+def _smoke_case_92() -> None:
folder = client.folders.retrieve(
workspace_id="workspaceId",
folder_id="folderId",
)
-def _smoke_case_92() -> None:
+def _smoke_case_93() -> None:
folder = client.folders.update(
workspace_id="workspaceId",
folder_id="folderId",
@@ -1145,7 +1156,7 @@ def _smoke_case_92() -> None:
)
-def _smoke_case_93() -> None:
+def _smoke_case_94() -> None:
folder = client.folders.update(
workspace_id="workspaceId",
folder_id="folderId",
@@ -1155,21 +1166,21 @@ def _smoke_case_93() -> None:
)
-def _smoke_case_94() -> None:
+def _smoke_case_95() -> None:
folder = client.folders.delete(
workspace_id="workspaceId",
folder_id="folderId",
)
-def _smoke_case_95() -> None:
+def _smoke_case_96() -> None:
folder = client.folders.remove_assets(
workspace_id="workspaceId",
asset_ids=["67e4f2b4403562dbea654301", "67e4f2bb403562dbea654302"],
)
-def _smoke_case_96() -> None:
+def _smoke_case_97() -> None:
channel_viewer = client.channel_viewers.invite(
video_workspace_id="videoWorkspaceId",
users=[
@@ -1180,21 +1191,21 @@ def _smoke_case_96() -> None:
)
-def _smoke_case_97() -> None:
+def _smoke_case_98() -> None:
channel_viewer = client.channel_viewers.remove(
video_workspace_id="videoWorkspaceId",
emails=["test@gumlet.com", "test+2@gumlet.com"],
)
-def _smoke_case_98() -> None:
+def _smoke_case_99() -> None:
channel_viewer = client.channel_viewers.invite_csv(
video_workspace_id="videoWorkspaceId",
viewers_csv=b"viewers.csv",
)
-def _smoke_case_99() -> None:
+def _smoke_case_100() -> None:
channel_viewer = client.channel_viewers.list_subscribers(
workspace_id="workspaceId",
page_number=1,
@@ -1202,7 +1213,7 @@ def _smoke_case_99() -> None:
)
-def _smoke_case_100() -> None:
+def _smoke_case_101() -> None:
video_analytic = client.video_analytics.viewer_data(
metrics=["views"],
date_range={"start_at": "2024-01-01", "end_at": "2024-01-01"},
@@ -1210,7 +1221,7 @@ def _smoke_case_100() -> None:
)
-def _smoke_case_101() -> None:
+def _smoke_case_102() -> None:
video_analytic = client.video_analytics.viewer_data(
metrics=["views"],
workspace_id="",
@@ -1222,7 +1233,7 @@ def _smoke_case_101() -> None:
)
-def _smoke_case_102() -> None:
+def _smoke_case_103() -> None:
video_analytic = client.video_analytics.breakdown_data(
date_range={"start_at": "2026-07-20", "end_at": "2026-08-20"},
filters=[],
@@ -1234,7 +1245,7 @@ def _smoke_case_102() -> None:
)
-def _smoke_case_103() -> None:
+def _smoke_case_104() -> None:
video_analytic = client.video_analytics.aggregated_data(
aggregate=[{"metric": "views", "function": "sum"}],
workspace_id="",
@@ -1242,7 +1253,7 @@ def _smoke_case_103() -> None:
)
-def _smoke_case_104() -> None:
+def _smoke_case_105() -> None:
video_analytic = client.video_analytics.aggregated_data(
aggregate=[{"metric": "views", "function": "sum"}],
workspace_id="",
@@ -1251,15 +1262,15 @@ def _smoke_case_104() -> None:
)
-def _smoke_case_105() -> None:
+def _smoke_case_106() -> None:
organization_data = client.organization_data.fetch_org()
-def _smoke_case_106() -> None:
+def _smoke_case_107() -> None:
user_data = client.user_data.fetch()
-def _smoke_case_107() -> None:
+def _smoke_case_108() -> None:
audit_log = client.audit_logs.fetch(
date_range={"start_at": "2026-08-25", "end_at": "2026-08-29"},
page_number=1,
@@ -1267,7 +1278,7 @@ def _smoke_case_107() -> None:
)
-def _smoke_case_108() -> None:
+def _smoke_case_109() -> None:
audit_log = client.audit_logs.fetch(
date_range={"start_at": "2026-08-25", "end_at": "2026-08-29"},
page_number=1,
@@ -1277,15 +1288,15 @@ def _smoke_case_108() -> None:
)
-def _smoke_case_109() -> None:
+def _smoke_case_110() -> None:
billing = client.billing.list_invoices()
-def _smoke_case_110() -> None:
+def _smoke_case_111() -> None:
billing = client.billing.fetch_details()
-def _smoke_case_111() -> None:
+def _smoke_case_112() -> None:
billing = client.billing.update_details(
address_line="",
city="",
@@ -1297,21 +1308,21 @@ def _smoke_case_111() -> None:
)
-def _smoke_case_112() -> None:
+def _smoke_case_113() -> None:
billing = client.billing.fetch_upcoming_invoice()
-def _smoke_case_113() -> None:
+def _smoke_case_114() -> None:
live_stream_workspace = client.live_stream_workspaces.list()
-def _smoke_case_114() -> None:
+def _smoke_case_115() -> None:
live_stream_workspace = client.live_stream_workspaces.create(
name="",
)
-def _smoke_case_115() -> None:
+def _smoke_case_116() -> None:
live_stream_workspace = client.live_stream_workspaces.update(
live_workspace_id="liveWorkspaceId",
name="live-stream-collections",
@@ -1319,13 +1330,13 @@ def _smoke_case_115() -> None:
)
-def _smoke_case_116() -> None:
+def _smoke_case_117() -> None:
live_stream_workspace = client.live_stream_workspaces.delete(
live_workspace_id="liveWorkspaceId",
)
-def _smoke_case_117() -> None:
+def _smoke_case_118() -> None:
live_stream_analytic = client.live_stream_analytics.usage(
date_range={"start_at": "2024-01-01", "end_at": "2024-01-01"},
group_by="daily",
@@ -1333,7 +1344,7 @@ def _smoke_case_117() -> None:
)
-def _smoke_case_118() -> None:
+def _smoke_case_119() -> None:
global_search = client.global_search.search(
search_query="search_query",
size=20,
@@ -1344,9 +1355,10 @@ def _smoke_case_118() -> None:
)
-def _smoke_case_119() -> None:
+def _smoke_case_120() -> None:
global_search = client.global_search.search(
search_query="search_query",
+ workspace_id="workspace_id",
collection_id="collection_id",
size=20,
assets_offset=0,
@@ -1631,509 +1643,517 @@ def _smoke_case_119() -> None:
"operation": "listAll",
"method": "GET",
"path": "/video/playlist",
+ "label": "required params",
"run": _smoke_case_41,
},
+ {
+ "operation": "listAll",
+ "method": "GET",
+ "path": "/video/playlist",
+ "label": "all params",
+ "run": _smoke_case_42,
+ },
{
"operation": "createAsset",
"method": "POST",
"path": "/video/playlist/{playlist_id}/asset",
- "run": _smoke_case_42,
+ "run": _smoke_case_43,
},
{
"operation": "deleteAsset",
"method": "DELETE",
"path": "/video/playlist/{playlist_id}/asset",
- "run": _smoke_case_43,
+ "run": _smoke_case_44,
},
{
"operation": "update",
"method": "POST",
"path": "/video/playlist/{playlist_id}",
- "run": _smoke_case_44,
+ "run": _smoke_case_45,
},
{
"operation": "delete",
"method": "DELETE",
"path": "/video/playlist/{playlist_id}",
- "run": _smoke_case_45,
+ "run": _smoke_case_46,
},
{
"operation": "listAssets",
"method": "GET",
"path": "/video/playlist/{playlist_id}/assets",
"label": "required params",
- "run": _smoke_case_46,
+ "run": _smoke_case_47,
},
{
"operation": "listAssets",
"method": "GET",
"path": "/video/playlist/{playlist_id}/assets",
"label": "all params",
- "run": _smoke_case_47,
+ "run": _smoke_case_48,
},
{
"operation": "reorderAsset",
"method": "POST",
"path": "/video/playlists/{playlist_id}/reorder",
- "run": _smoke_case_48,
+ "run": _smoke_case_49,
},
{
"operation": "create",
"method": "POST",
"path": "/org/webhooks",
- "run": _smoke_case_49,
+ "run": _smoke_case_50,
},
{
"operation": "list",
"method": "GET",
"path": "/org/webhooks",
- "run": _smoke_case_50,
+ "run": _smoke_case_51,
},
{
"operation": "update",
"method": "POST",
"path": "/org/webhooks/{webhook_id}",
"label": "required params",
- "run": _smoke_case_51,
+ "run": _smoke_case_52,
},
{
"operation": "update",
"method": "POST",
"path": "/org/webhooks/{webhook_id}",
"label": "all params",
- "run": _smoke_case_52,
+ "run": _smoke_case_53,
},
{
"operation": "delete",
"method": "DELETE",
"path": "/org/webhooks/{webhook_id}",
- "run": _smoke_case_53,
+ "run": _smoke_case_54,
},
{
"operation": "history",
"method": "GET",
"path": "/org/webhook/{webhook_id}/history",
- "run": _smoke_case_54,
+ "run": _smoke_case_55,
},
{
"operation": "create",
"method": "POST",
"path": "/image/sources",
"label": "required params",
- "run": _smoke_case_55,
+ "run": _smoke_case_56,
},
{
"operation": "create",
"method": "POST",
"path": "/image/sources",
"label": "all params",
- "run": _smoke_case_56,
+ "run": _smoke_case_57,
},
{
"operation": "list",
"method": "GET",
"path": "/image/sources",
- "run": _smoke_case_57,
+ "run": _smoke_case_58,
},
{
"operation": "retrieve",
"method": "GET",
"path": "/image/sources/{image_source_id}",
- "run": _smoke_case_58,
+ "run": _smoke_case_59,
},
{
"operation": "update",
"method": "POST",
"path": "/image/sources/{image_source_id}",
"label": "required params",
- "run": _smoke_case_59,
+ "run": _smoke_case_60,
},
{
"operation": "update",
"method": "POST",
"path": "/image/sources/{image_source_id}",
"label": "all params",
- "run": _smoke_case_60,
+ "run": _smoke_case_61,
},
{
"operation": "delete",
"method": "DELETE",
"path": "/image/sources/{image_source_id}",
- "run": _smoke_case_61,
+ "run": _smoke_case_62,
},
{
"operation": "purgeCache",
"method": "POST",
"path": "/purge/{subdomain}",
- "run": _smoke_case_62,
+ "run": _smoke_case_63,
},
{
"operation": "purge",
"method": "POST",
"path": "/image/purge/{source_id}",
- "run": _smoke_case_63,
+ "run": _smoke_case_64,
},
{
"operation": "retrieve",
"method": "POST",
"path": "/image/analytics",
"label": "required params",
- "run": _smoke_case_64,
+ "run": _smoke_case_65,
},
{
"operation": "retrieve",
"method": "POST",
"path": "/image/analytics",
"label": "all params",
- "run": _smoke_case_65,
+ "run": _smoke_case_66,
},
{
"operation": "create",
"method": "POST",
"path": "/video/live/assets",
"label": "required params",
- "run": _smoke_case_66,
+ "run": _smoke_case_67,
},
{
"operation": "create",
"method": "POST",
"path": "/video/live/assets",
"label": "all params",
- "run": _smoke_case_67,
+ "run": _smoke_case_68,
},
{
"operation": "update",
"method": "POST",
"path": "/video/live/assets/update",
"label": "required params",
- "run": _smoke_case_68,
+ "run": _smoke_case_69,
},
{
"operation": "update",
"method": "POST",
"path": "/video/live/assets/update",
"label": "all params",
- "run": _smoke_case_69,
+ "run": _smoke_case_70,
},
{
"operation": "retrieveStatus",
"method": "GET",
"path": "/video/live/assets/{live_asset_id}",
- "run": _smoke_case_70,
+ "run": _smoke_case_71,
},
{
"operation": "delete",
"method": "DELETE",
"path": "/video/live/assets/{live_asset_id}",
- "run": _smoke_case_71,
+ "run": _smoke_case_72,
},
{
"operation": "complete",
"method": "POST",
"path": "/video/live/assets/{live_asset_id}/complete",
- "run": _smoke_case_72,
+ "run": _smoke_case_73,
},
{
"operation": "filter",
"method": "GET",
"path": "/video/live/assets/list/{live_source_id}",
"label": "required params",
- "run": _smoke_case_73,
+ "run": _smoke_case_74,
},
{
"operation": "filter",
"method": "GET",
"path": "/video/live/assets/list/{live_source_id}",
"label": "all params",
- "run": _smoke_case_74,
+ "run": _smoke_case_75,
},
{
"operation": "start",
"method": "POST",
"path": "/video/live/assets/{live_asset_id}/start",
- "run": _smoke_case_75,
+ "run": _smoke_case_76,
},
{
"operation": "upload",
"method": "POST",
"path": "/video/live/assets/thumbnail/upload",
- "run": _smoke_case_76,
+ "run": _smoke_case_77,
},
{
"operation": "statusHistory",
"method": "GET",
"path": "/video/live/assets/{live_asset_id}/history",
- "run": _smoke_case_77,
+ "run": _smoke_case_78,
},
{
"operation": "recover",
"method": "POST",
"path": "/video/asset/recover",
- "run": _smoke_case_78,
+ "run": _smoke_case_79,
},
{
"operation": "list",
"method": "GET",
"path": "/video/asset/recoverable/list",
"label": "required params",
- "run": _smoke_case_79,
+ "run": _smoke_case_80,
},
{
"operation": "list",
"method": "GET",
"path": "/video/asset/recoverable/list",
"label": "all params",
- "run": _smoke_case_80,
+ "run": _smoke_case_81,
},
{
"operation": "list",
"method": "GET",
"path": "/video/workspaces",
- "run": _smoke_case_81,
+ "run": _smoke_case_82,
},
{
"operation": "create",
"method": "POST",
"path": "/video/workspaces",
"label": "required params",
- "run": _smoke_case_82,
+ "run": _smoke_case_83,
},
{
"operation": "create",
"method": "POST",
"path": "/video/workspaces",
"label": "all params",
- "run": _smoke_case_83,
+ "run": _smoke_case_84,
},
{
"operation": "update",
"method": "POST",
"path": "/video/workspaces/{workspace_id}",
"label": "required params",
- "run": _smoke_case_84,
+ "run": _smoke_case_85,
},
{
"operation": "update",
"method": "POST",
"path": "/video/workspaces/{workspace_id}",
"label": "all params",
- "run": _smoke_case_85,
+ "run": _smoke_case_86,
},
{
"operation": "retrieve",
"method": "GET",
"path": "/video/workspaces/{workspace_id}",
- "run": _smoke_case_86,
+ "run": _smoke_case_87,
},
{
"operation": "delete",
"method": "DELETE",
"path": "/video/workspaces/{workspace_id}",
- "run": _smoke_case_87,
+ "run": _smoke_case_88,
},
{
"operation": "create",
"method": "POST",
"path": "/video/workspaces/{workspace_id}/folders",
- "run": _smoke_case_88,
+ "run": _smoke_case_89,
},
{
"operation": "list",
"method": "GET",
"path": "/video/workspaces/{workspace_id}/folders",
"label": "required params",
- "run": _smoke_case_89,
+ "run": _smoke_case_90,
},
{
"operation": "list",
"method": "GET",
"path": "/video/workspaces/{workspace_id}/folders",
"label": "all params",
- "run": _smoke_case_90,
+ "run": _smoke_case_91,
},
{
"operation": "retrieve",
"method": "GET",
"path": "/video/workspaces/{workspace_id}/folders/{folder_id}",
- "run": _smoke_case_91,
+ "run": _smoke_case_92,
},
{
"operation": "update",
"method": "POST",
"path": "/video/workspaces/{workspace_id}/folders/{folder_id}",
"label": "required params",
- "run": _smoke_case_92,
+ "run": _smoke_case_93,
},
{
"operation": "update",
"method": "POST",
"path": "/video/workspaces/{workspace_id}/folders/{folder_id}",
"label": "all params",
- "run": _smoke_case_93,
+ "run": _smoke_case_94,
},
{
"operation": "delete",
"method": "DELETE",
"path": "/video/workspaces/{workspace_id}/folders/{folder_id}",
- "run": _smoke_case_94,
+ "run": _smoke_case_95,
},
{
"operation": "removeAssets",
"method": "POST",
"path": "/video/workspaces/{workspace_id}/remove-assets-from-folder",
- "run": _smoke_case_95,
+ "run": _smoke_case_96,
},
{
"operation": "invite",
"method": "POST",
"path": "/channel/{video_workspace_id}/viewers/invite",
- "run": _smoke_case_96,
+ "run": _smoke_case_97,
},
{
"operation": "remove",
"method": "POST",
"path": "/channel/{video_workspace_id}/viewers/remove",
- "run": _smoke_case_97,
+ "run": _smoke_case_98,
},
{
"operation": "inviteCsv",
"method": "POST",
"path": "/channel/{video_workspace_id}/viewers/invite/csv",
- "run": _smoke_case_98,
+ "run": _smoke_case_99,
},
{
"operation": "listSubscribers",
"method": "GET",
"path": "/channel/{workspace_id}/viewers",
- "run": _smoke_case_99,
+ "run": _smoke_case_100,
},
{
"operation": "viewerData",
"method": "POST",
"path": "/insights/viewer-analytics",
"label": "required params",
- "run": _smoke_case_100,
+ "run": _smoke_case_101,
},
{
"operation": "viewerData",
"method": "POST",
"path": "/insights/viewer-analytics",
"label": "all params",
- "run": _smoke_case_101,
+ "run": _smoke_case_102,
},
{
"operation": "breakdownData",
"method": "POST",
"path": "/insights/breakdown-data",
- "run": _smoke_case_102,
+ "run": _smoke_case_103,
},
{
"operation": "aggregatedData",
"method": "POST",
"path": "/insights/aggregated-data",
"label": "required params",
- "run": _smoke_case_103,
+ "run": _smoke_case_104,
},
{
"operation": "aggregatedData",
"method": "POST",
"path": "/insights/aggregated-data",
"label": "all params",
- "run": _smoke_case_104,
+ "run": _smoke_case_105,
},
{
"operation": "fetchOrg",
"method": "GET",
"path": "/org/data",
- "run": _smoke_case_105,
+ "run": _smoke_case_106,
},
{
"operation": "fetch",
"method": "GET",
"path": "/user/data",
- "run": _smoke_case_106,
+ "run": _smoke_case_107,
},
{
"operation": "fetch",
"method": "POST",
"path": "/user/audit-log",
"label": "required params",
- "run": _smoke_case_107,
+ "run": _smoke_case_108,
},
{
"operation": "fetch",
"method": "POST",
"path": "/user/audit-log",
"label": "all params",
- "run": _smoke_case_108,
+ "run": _smoke_case_109,
},
{
"operation": "listInvoices",
"method": "GET",
"path": "/mixed/billing/invoice/history",
- "run": _smoke_case_109,
+ "run": _smoke_case_110,
},
{
"operation": "fetchDetails",
"method": "GET",
"path": "/mixed/billing/details",
- "run": _smoke_case_110,
+ "run": _smoke_case_111,
},
{
"operation": "updateDetails",
"method": "POST",
"path": "/mixed/billing/details",
- "run": _smoke_case_111,
+ "run": _smoke_case_112,
},
{
"operation": "fetchUpcomingInvoice",
"method": "GET",
"path": "/mixed/billing/invoice/upcoming",
- "run": _smoke_case_112,
+ "run": _smoke_case_113,
},
{
"operation": "list",
"method": "GET",
"path": "/video/sources/live",
- "run": _smoke_case_113,
+ "run": _smoke_case_114,
},
{
"operation": "create",
"method": "POST",
"path": "/video/sources/live",
- "run": _smoke_case_114,
+ "run": _smoke_case_115,
},
{
"operation": "update",
"method": "POST",
"path": "/video/sources/live/{live_workspace_id}",
- "run": _smoke_case_115,
+ "run": _smoke_case_116,
},
{
"operation": "delete",
"method": "DELETE",
"path": "/video/sources/live/{live_workspace_id}",
- "run": _smoke_case_116,
+ "run": _smoke_case_117,
},
{
"operation": "usage",
"method": "POST",
"path": "/video/live/analytics",
- "run": _smoke_case_117,
+ "run": _smoke_case_118,
},
{
"operation": "search",
"method": "GET",
"path": "/entities/global-search",
"label": "required params",
- "run": _smoke_case_118,
+ "run": _smoke_case_119,
},
{
"operation": "search",
"method": "GET",
"path": "/entities/global-search",
"label": "all params",
- "run": _smoke_case_119,
+ "run": _smoke_case_120,
},
]