From a32962e54563d7073726b4463d70017fc2a40331 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:31:39 +0000 Subject: [PATCH 01/15] fix(pydantic): do not pass `by_alias` unless set --- src/tabstack/_compat.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/tabstack/_compat.py b/src/tabstack/_compat.py index 786ff42..e6690a4 100644 --- a/src/tabstack/_compat.py +++ b/src/tabstack/_compat.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload from datetime import date, datetime -from typing_extensions import Self, Literal +from typing_extensions import Self, Literal, TypedDict import pydantic from pydantic.fields import FieldInfo @@ -131,6 +131,10 @@ def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: return model.model_dump_json(indent=indent) +class _ModelDumpKwargs(TypedDict, total=False): + by_alias: bool + + def model_dump( model: pydantic.BaseModel, *, @@ -142,6 +146,9 @@ def model_dump( by_alias: bool | None = None, ) -> dict[str, Any]: if (not PYDANTIC_V1) or hasattr(model, "model_dump"): + kwargs: _ModelDumpKwargs = {} + if by_alias is not None: + kwargs["by_alias"] = by_alias return model.model_dump( mode=mode, exclude=exclude, @@ -149,7 +156,7 @@ def model_dump( exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 warnings=True if PYDANTIC_V1 else warnings, - by_alias=by_alias, + **kwargs, ) return cast( "dict[str, Any]", From 194650472a951f76bd180e1dd08507fcf2670577 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:01:51 +0000 Subject: [PATCH 02/15] fix(deps): bump minimum typing-extensions version --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4a373c9..3822de4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ dependencies = [ "httpx>=0.23.0, <1", "pydantic>=1.9.0, <3", - "typing-extensions>=4.10, <5", + "typing-extensions>=4.14, <5", "anyio>=3.5.0, <5", "distro>=1.7.0, <2", "sniffio", diff --git a/uv.lock b/uv.lock index 64a2d52..6535369 100644 --- a/uv.lock +++ b/uv.lock @@ -1385,7 +1385,7 @@ requires-dist = [ { name = "httpx-aiohttp", marker = "extra == 'aiohttp'", specifier = ">=0.1.9" }, { name = "pydantic", specifier = ">=1.9.0,<3" }, { name = "sniffio" }, - { name = "typing-extensions", specifier = ">=4.10,<5" }, + { name = "typing-extensions", specifier = ">=4.14,<5" }, ] provides-extras = ["aiohttp"] From 8458777f42fdbdc611d213513b1e7f026886ba42 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:45:48 +0000 Subject: [PATCH 03/15] chore(internal): tweak CI branches --- .github/workflows/ci.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e8b7f4..fc69dca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,14 @@ name: CI on: push: - branches-ignore: - - 'generated' - - 'codegen/**' - - 'integrated/**' - - 'stl-preview-head/**' - - 'stl-preview-base/**' + branches: + - '**' + - '!integrated/**' + - '!stl-preview-head/**' + - '!stl-preview-base/**' + - '!generated' + - '!codegen/**' + - 'codegen/stl/**' pull_request: branches-ignore: - 'stl-preview-head/**' From f4632c74a42a3ea56998e6f9d9afacace4bd4a5c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:36:53 +0000 Subject: [PATCH 04/15] fix: sanitize endpoint path params --- src/tabstack/_utils/__init__.py | 1 + src/tabstack/_utils/_path.py | 127 ++++++++++++++++++++++++++++++++ tests/test_utils/test_path.py | 89 ++++++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 src/tabstack/_utils/_path.py create mode 100644 tests/test_utils/test_path.py diff --git a/src/tabstack/_utils/__init__.py b/src/tabstack/_utils/__init__.py index dc64e29..10cb66d 100644 --- a/src/tabstack/_utils/__init__.py +++ b/src/tabstack/_utils/__init__.py @@ -1,3 +1,4 @@ +from ._path import path_template as path_template from ._sync import asyncify as asyncify from ._proxy import LazyProxy as LazyProxy from ._utils import ( diff --git a/src/tabstack/_utils/_path.py b/src/tabstack/_utils/_path.py new file mode 100644 index 0000000..4d6e1e4 --- /dev/null +++ b/src/tabstack/_utils/_path.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import re +from typing import ( + Any, + Mapping, + Callable, +) +from urllib.parse import quote + +# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E). +_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$") + +_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}") + + +def _quote_path_segment_part(value: str) -> str: + """Percent-encode `value` for use in a URI path segment. + + Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.3 + """ + # quote() already treats unreserved characters (letters, digits, and -._~) + # as safe, so we only need to add sub-delims, ':', and '@'. + # Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted. + return quote(value, safe="!$&'()*+,;=:@") + + +def _quote_query_part(value: str) -> str: + """Percent-encode `value` for use in a URI query string. + + Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.4 + """ + return quote(value, safe="!$'()*+,;:@/?") + + +def _quote_fragment_part(value: str) -> str: + """Percent-encode `value` for use in a URI fragment. + + Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.5 + """ + return quote(value, safe="!$&'()*+,;=:@/?") + + +def _interpolate( + template: str, + values: Mapping[str, Any], + quoter: Callable[[str], str], +) -> str: + """Replace {name} placeholders in `template`, quoting each value with `quoter`. + + Placeholder names are looked up in `values`. + + Raises: + KeyError: If a placeholder is not found in `values`. + """ + # re.split with a capturing group returns alternating + # [text, name, text, name, ..., text] elements. + parts = _PLACEHOLDER_RE.split(template) + + for i in range(1, len(parts), 2): + name = parts[i] + if name not in values: + raise KeyError(f"a value for placeholder {{{name}}} was not provided") + val = values[name] + if val is None: + parts[i] = "null" + elif isinstance(val, bool): + parts[i] = "true" if val else "false" + else: + parts[i] = quoter(str(values[name])) + + return "".join(parts) + + +def path_template(template: str, /, **kwargs: Any) -> str: + """Interpolate {name} placeholders in `template` from keyword arguments. + + Args: + template: The template string containing {name} placeholders. + **kwargs: Keyword arguments to interpolate into the template. + + Returns: + The template with placeholders interpolated and percent-encoded. + + Safe characters for percent-encoding are dependent on the URI component. + Placeholders in path and fragment portions are percent-encoded where the `segment` + and `fragment` sets from RFC 3986 respectively are considered safe. + Placeholders in the query portion are percent-encoded where the `query` set from + RFC 3986 §3.3 is considered safe except for = and & characters. + + Raises: + KeyError: If a placeholder is not found in `kwargs`. + ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments). + """ + # Split the template into path, query, and fragment portions. + fragment_template: str | None = None + query_template: str | None = None + + rest = template + if "#" in rest: + rest, fragment_template = rest.split("#", 1) + if "?" in rest: + rest, query_template = rest.split("?", 1) + path_template = rest + + # Interpolate each portion with the appropriate quoting rules. + path_result = _interpolate(path_template, kwargs, _quote_path_segment_part) + + # Reject dot-segments (. and ..) in the final assembled path. The check + # runs after interpolation so that adjacent placeholders or a mix of static + # text and placeholders that together form a dot-segment are caught. + # Also reject percent-encoded dot-segments to protect against incorrectly + # implemented normalization in servers/proxies. + for segment in path_result.split("/"): + if _DOT_SEGMENT_RE.match(segment): + raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed") + + result = path_result + if query_template is not None: + result += "?" + _interpolate(query_template, kwargs, _quote_query_part) + if fragment_template is not None: + result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part) + + return result diff --git a/tests/test_utils/test_path.py b/tests/test_utils/test_path.py new file mode 100644 index 0000000..56754f6 --- /dev/null +++ b/tests/test_utils/test_path.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from tabstack._utils._path import path_template + + +@pytest.mark.parametrize( + "template, kwargs, expected", + [ + ("/v1/{id}", dict(id="abc"), "/v1/abc"), + ("/v1/{a}/{b}", dict(a="x", b="y"), "/v1/x/y"), + ("/v1/{a}{b}/path/{c}?val={d}#{e}", dict(a="x", b="y", c="z", d="u", e="v"), "/v1/xy/path/z?val=u#v"), + ("/{w}/{w}", dict(w="echo"), "/echo/echo"), + ("/v1/static", {}, "/v1/static"), + ("", {}, ""), + ("/v1/?q={n}&count=10", dict(n=42), "/v1/?q=42&count=10"), + ("/v1/{v}", dict(v=None), "/v1/null"), + ("/v1/{v}", dict(v=True), "/v1/true"), + ("/v1/{v}", dict(v=False), "/v1/false"), + ("/v1/{v}", dict(v=".hidden"), "/v1/.hidden"), # dot prefix ok + ("/v1/{v}", dict(v="file.txt"), "/v1/file.txt"), # dot in middle ok + ("/v1/{v}", dict(v="..."), "/v1/..."), # triple dot ok + ("/v1/{a}{b}", dict(a=".", b="txt"), "/v1/.txt"), # dot var combining with adjacent to be ok + ("/items?q={v}#{f}", dict(v=".", f=".."), "/items?q=.#.."), # dots in query/fragment are fine + ( + "/v1/{a}?query={b}", + dict(a="../../other/endpoint", b="a&bad=true"), + "/v1/..%2F..%2Fother%2Fendpoint?query=a%26bad%3Dtrue", + ), + ("/v1/{val}", dict(val="a/b/c"), "/v1/a%2Fb%2Fc"), + ("/v1/{val}", dict(val="a/b/c?query=value"), "/v1/a%2Fb%2Fc%3Fquery=value"), + ("/v1/{val}", dict(val="a/b/c?query=value&bad=true"), "/v1/a%2Fb%2Fc%3Fquery=value&bad=true"), + ("/v1/{val}", dict(val="%20"), "/v1/%2520"), # escapes escape sequences in input + # Query: slash and ? are safe, # is not + ("/items?q={v}", dict(v="a/b"), "/items?q=a/b"), + ("/items?q={v}", dict(v="a?b"), "/items?q=a?b"), + ("/items?q={v}", dict(v="a#b"), "/items?q=a%23b"), + ("/items?q={v}", dict(v="a b"), "/items?q=a%20b"), + # Fragment: slash and ? are safe + ("/docs#{v}", dict(v="a/b"), "/docs#a/b"), + ("/docs#{v}", dict(v="a?b"), "/docs#a?b"), + # Path: slash, ? and # are all encoded + ("/v1/{v}", dict(v="a/b"), "/v1/a%2Fb"), + ("/v1/{v}", dict(v="a?b"), "/v1/a%3Fb"), + ("/v1/{v}", dict(v="a#b"), "/v1/a%23b"), + # same var encoded differently by component + ( + "/v1/{v}?q={v}#{v}", + dict(v="a/b?c#d"), + "/v1/a%2Fb%3Fc%23d?q=a/b?c%23d#a/b?c%23d", + ), + ("/v1/{val}", dict(val="x?admin=true"), "/v1/x%3Fadmin=true"), # query injection + ("/v1/{val}", dict(val="x#admin"), "/v1/x%23admin"), # fragment injection + ], +) +def test_interpolation(template: str, kwargs: dict[str, Any], expected: str) -> None: + assert path_template(template, **kwargs) == expected + + +def test_missing_kwarg_raises_key_error() -> None: + with pytest.raises(KeyError, match="org_id"): + path_template("/v1/{org_id}") + + +@pytest.mark.parametrize( + "template, kwargs", + [ + ("{a}/path", dict(a=".")), + ("{a}/path", dict(a="..")), + ("/v1/{a}", dict(a=".")), + ("/v1/{a}", dict(a="..")), + ("/v1/{a}/path", dict(a=".")), + ("/v1/{a}/path", dict(a="..")), + ("/v1/{a}{b}", dict(a=".", b=".")), # adjacent vars → ".." + ("/v1/{a}.", dict(a=".")), # var + static → ".." + ("/v1/{a}{b}", dict(a="", b=".")), # empty + dot → "." + ("/v1/%2e/{x}", dict(x="ok")), # encoded dot in static text + ("/v1/%2e./{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/.%2E/{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/{v}?q=1", dict(v="..")), + ("/v1/{v}#frag", dict(v="..")), + ], +) +def test_dot_segment_rejected(template: str, kwargs: dict[str, Any]) -> None: + with pytest.raises(ValueError, match="dot-segment"): + path_template(template, **kwargs) From e39102cd8c44fbbaec52a5bed74a11deab25b97f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:38:13 +0000 Subject: [PATCH 05/15] chore(internal): update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 95ceb18..3824f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .prism.log +.stdy.log _dev __pycache__ From 97108400820fbe715dcb922c997b87b612bf588e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:14:21 +0000 Subject: [PATCH 06/15] chore(ci): skip lint on metadata-only changes Note that we still want to run tests, as these depend on the metadata. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc69dca..f4158ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: timeout-minutes: 10 name: lint runs-on: ${{ github.repository == 'stainless-sdks/tabstack-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - uses: actions/checkout@v6 @@ -35,7 +35,7 @@ jobs: run: ./scripts/lint build: - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') timeout-minutes: 10 name: build permissions: From 97a9bb7241ad0c7a98483d81f74bbb5ee40e1b93 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:23:36 +0000 Subject: [PATCH 07/15] feat(internal): implement indices array format for query and form serialization --- src/tabstack/_qs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tabstack/_qs.py b/src/tabstack/_qs.py index ada6fd3..de8c99b 100644 --- a/src/tabstack/_qs.py +++ b/src/tabstack/_qs.py @@ -101,7 +101,10 @@ def _stringify_item( items.extend(self._stringify_item(key, item, opts)) return items elif array_format == "indices": - raise NotImplementedError("The array indices format is not supported yet") + items = [] + for i, item in enumerate(value): + items.extend(self._stringify_item(f"{key}[{i}]", item, opts)) + return items elif array_format == "brackets": items = [] key = key + "[]" From ed80ce06975480e8466b18b75397ee3c761f6398 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:01:55 +0000 Subject: [PATCH 08/15] feat(api): api update --- .stats.yml | 4 ++-- src/tabstack/resources/generate.py | 4 ++-- src/tabstack/types/generate_json_params.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.stats.yml b/.stats.yml index 62b5e49..d628251 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-8f80078ef30bc395d44843f9ce076188ba43a297e158d5f3672c45dc814ffcf0.yml -openapi_spec_hash: c615a817dcb27c55bd15b9b9346669c2 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-4f4bec25bf55411f39f7c98b8bfd9abc76f886581f058ee4dd533ba52165b642.yml +openapi_spec_hash: 7683e4ecea241c97066124b3ba833843 config_hash: 6b388b673b2a48895ab10da245fb6771 diff --git a/src/tabstack/resources/generate.py b/src/tabstack/resources/generate.py index 36f4952..b9a2996 100644 --- a/src/tabstack/resources/generate.py +++ b/src/tabstack/resources/generate.py @@ -64,7 +64,7 @@ def json( instructions. Use this to generate new content, summaries, or restructured data. Args: - instructions: Instructions describing how to transform the data + instructions: Instructions describing how to transform the data. Maximum 20,000 characters. json_schema: JSON schema defining the structure of the transformed output @@ -147,7 +147,7 @@ async def json( instructions. Use this to generate new content, summaries, or restructured data. Args: - instructions: Instructions describing how to transform the data + instructions: Instructions describing how to transform the data. Maximum 20,000 characters. json_schema: JSON schema defining the structure of the transformed output diff --git a/src/tabstack/types/generate_json_params.py b/src/tabstack/types/generate_json_params.py index bbe9b76..486e3ed 100644 --- a/src/tabstack/types/generate_json_params.py +++ b/src/tabstack/types/generate_json_params.py @@ -9,7 +9,7 @@ class GenerateJsonParams(TypedDict, total=False): instructions: Required[str] - """Instructions describing how to transform the data""" + """Instructions describing how to transform the data. Maximum 20,000 characters.""" json_schema: Required[object] """JSON schema defining the structure of the transformed output""" From 2cda6aeb86a50115ba347549452c57700e810679 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:08:50 +0000 Subject: [PATCH 09/15] fix(client): preserve hardcoded query params when merging with user params --- src/tabstack/_base_client.py | 4 +++ tests/test_client.py | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/tabstack/_base_client.py b/src/tabstack/_base_client.py index 8df444d..6d03d50 100644 --- a/src/tabstack/_base_client.py +++ b/src/tabstack/_base_client.py @@ -540,6 +540,10 @@ def _build_request( files = cast(HttpxRequestFiles, ForceMultipartDict()) prepared_url = self._prepare_url(options.url) + # preserve hard-coded query params from the url + if params and prepared_url.query: + params = {**dict(prepared_url.params.items()), **params} + prepared_url = prepared_url.copy_with(raw_path=prepared_url.raw_path.split(b"?", 1)[0]) if "_" in prepared_url.host: # work around https://github.com/encode/httpx/discussions/2880 kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} diff --git a/tests/test_client.py b/tests/test_client.py index f6e8193..4e4f04c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -429,6 +429,30 @@ def test_default_query_option(self) -> None: client.close() + def test_hardcoded_query_params_in_url(self, client: Tabstack) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: Tabstack) -> None: request = client._build_request( FinalRequestOptions( @@ -1332,6 +1356,30 @@ async def test_default_query_option(self) -> None: await client.close() + async def test_hardcoded_query_params_in_url(self, async_client: AsyncTabstack) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: Tabstack) -> None: request = client._build_request( FinalRequestOptions( From cda5c19baedee455072a8071cb2741714a9ded19 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:21:00 +0000 Subject: [PATCH 10/15] feat(api): api update --- .stats.yml | 4 ++-- src/tabstack/resources/agent.py | 8 ++++++++ src/tabstack/types/agent_automate_params.py | 3 +++ tests/api_resources/test_agent.py | 2 ++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index d628251..e506e2d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-4f4bec25bf55411f39f7c98b8bfd9abc76f886581f058ee4dd533ba52165b642.yml -openapi_spec_hash: 7683e4ecea241c97066124b3ba833843 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-ab135cad4c10edc98bbe45d18a1ca23d2d4aa4b2ba2e4554e6991e322ce46231.yml +openapi_spec_hash: 3e8185dfbbd4e83d5f05ded9ce9c5b06 config_hash: 6b388b673b2a48895ab10da245fb6771 diff --git a/src/tabstack/resources/agent.py b/src/tabstack/resources/agent.py index 0cf85d7..73a5770 100644 --- a/src/tabstack/resources/agent.py +++ b/src/tabstack/resources/agent.py @@ -52,6 +52,7 @@ def automate( data: object | Omit = omit, geo_target: agent_automate_params.GeoTarget | Omit = omit, guardrails: str | Omit = omit, + interactive: bool | Omit = omit, max_iterations: int | Omit = omit, max_validation_attempts: int | Omit = omit, url: str | Omit = omit, @@ -93,6 +94,8 @@ def automate( guardrails: Safety constraints for execution + interactive: Enable interactive mode to allow human-in-the-loop input during task execution + max_iterations: Maximum task iterations max_validation_attempts: Maximum validation attempts @@ -116,6 +119,7 @@ def automate( "data": data, "geo_target": geo_target, "guardrails": guardrails, + "interactive": interactive, "max_iterations": max_iterations, "max_validation_attempts": max_validation_attempts, "url": url, @@ -231,6 +235,7 @@ async def automate( data: object | Omit = omit, geo_target: agent_automate_params.GeoTarget | Omit = omit, guardrails: str | Omit = omit, + interactive: bool | Omit = omit, max_iterations: int | Omit = omit, max_validation_attempts: int | Omit = omit, url: str | Omit = omit, @@ -272,6 +277,8 @@ async def automate( guardrails: Safety constraints for execution + interactive: Enable interactive mode to allow human-in-the-loop input during task execution + max_iterations: Maximum task iterations max_validation_attempts: Maximum validation attempts @@ -295,6 +302,7 @@ async def automate( "data": data, "geo_target": geo_target, "guardrails": guardrails, + "interactive": interactive, "max_iterations": max_iterations, "max_validation_attempts": max_validation_attempts, "url": url, diff --git a/src/tabstack/types/agent_automate_params.py b/src/tabstack/types/agent_automate_params.py index 350bb30..3d63b9c 100644 --- a/src/tabstack/types/agent_automate_params.py +++ b/src/tabstack/types/agent_automate_params.py @@ -22,6 +22,9 @@ class AgentAutomateParams(TypedDict, total=False): guardrails: str """Safety constraints for execution""" + interactive: bool + """Enable interactive mode to allow human-in-the-loop input during task execution""" + max_iterations: Annotated[int, PropertyInfo(alias="maxIterations")] """Maximum task iterations""" diff --git a/tests/api_resources/test_agent.py b/tests/api_resources/test_agent.py index cabea82..a17f0a2 100644 --- a/tests/api_resources/test_agent.py +++ b/tests/api_resources/test_agent.py @@ -31,6 +31,7 @@ def test_method_automate_with_all_params(self, client: Tabstack) -> None: data={}, geo_target={"country": "US"}, guardrails="browse and extract only, don't interact with repositories", + interactive=False, max_iterations=50, max_validation_attempts=3, url="https://github.com/trending", @@ -128,6 +129,7 @@ async def test_method_automate_with_all_params(self, async_client: AsyncTabstack data={}, geo_target={"country": "US"}, guardrails="browse and extract only, don't interact with repositories", + interactive=False, max_iterations=50, max_validation_attempts=3, url="https://github.com/trending", From f5f587ab07a757e4ada42e6bc3c802f29591c536 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:49:58 +0000 Subject: [PATCH 11/15] chore: configure new SDK language --- .stats.yml | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.stats.yml b/.stats.yml index e506e2d..d2b5a79 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-ab135cad4c10edc98bbe45d18a1ca23d2d4aa4b2ba2e4554e6991e322ce46231.yml openapi_spec_hash: 3e8185dfbbd4e83d5f05ded9ce9c5b06 -config_hash: 6b388b673b2a48895ab10da245fb6771 +config_hash: 448fb94cb4e335d289d02a77f66badc0 diff --git a/README.md b/README.md index a3d18fc..3933628 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ It is generated with [Stainless](https://www.stainless.com/). Use the Tabstack MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application. -[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40tabstack%2Fmcp&config=eyJuYW1lIjoiQHRhYnN0YWNrL21jcCIsInRyYW5zcG9ydCI6Imh0dHAiLCJ1cmwiOiJodHRwczovL3RhYnN0YWNrLnN0bG1jcC5jb20iLCJoZWFkZXJzIjp7IngtdGFic3RhY2stYXBpLWtleSI6Ik15IEFQSSBLZXkifX0) -[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40tabstack%2Fmcp%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Ftabstack.stlmcp.com%22%2C%22headers%22%3A%7B%22x-tabstack-api-key%22%3A%22My%20API%20Key%22%7D%7D) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40tabstack%2Fsdk-mcp&config=eyJuYW1lIjoiQHRhYnN0YWNrL3Nkay1tY3AiLCJ0cmFuc3BvcnQiOiJodHRwIiwidXJsIjoiaHR0cHM6Ly90YWJzdGFjay5zdGxtY3AuY29tIiwiaGVhZGVycyI6eyJ4LXRhYnN0YWNrLWFwaS1rZXkiOiJNeSBBUEkgS2V5In19) +[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40tabstack%2Fsdk-mcp%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Ftabstack.stlmcp.com%22%2C%22headers%22%3A%7B%22x-tabstack-api-key%22%3A%22My%20API%20Key%22%7D%7D) > Note: You may need to set environment variables in your MCP client. From f3b1f16204c0d724193815c5e34100831f0a2653 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:51:27 +0000 Subject: [PATCH 12/15] feat(api): add input endpoint --- .stats.yml | 4 +- api.md | 3 +- src/tabstack/resources/agent.py | 132 +++++++++++++++++- src/tabstack/types/__init__.py | 2 + .../types/agent_automate_input_params.py | 22 +++ .../types/agent_automate_input_response.py | 11 ++ tests/api_resources/test_agent.py | 118 ++++++++++++++++ 7 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 src/tabstack/types/agent_automate_input_params.py create mode 100644 src/tabstack/types/agent_automate_input_response.py diff --git a/.stats.yml b/.stats.yml index d2b5a79..560dde2 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 5 +configured_endpoints: 6 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-ab135cad4c10edc98bbe45d18a1ca23d2d4aa4b2ba2e4554e6991e322ce46231.yml openapi_spec_hash: 3e8185dfbbd4e83d5f05ded9ce9c5b06 -config_hash: 448fb94cb4e335d289d02a77f66badc0 +config_hash: 64a326d972276ffd4a067a2f21f49681 diff --git a/api.md b/api.md index 48a3a76..51b78b1 100644 --- a/api.md +++ b/api.md @@ -3,12 +3,13 @@ Types: ```python -from tabstack.types import AutomateEvent, ResearchEvent +from tabstack.types import AutomateEvent, ResearchEvent, AgentAutomateInputResponse ``` Methods: - client.agent.automate(\*\*params) -> AutomateEvent +- client.agent.automate_input(request_id, \*\*params) -> AgentAutomateInputResponse - client.agent.research(\*\*params) -> ResearchEvent # Extract diff --git a/src/tabstack/resources/agent.py b/src/tabstack/resources/agent.py index 73a5770..b8ba20f 100644 --- a/src/tabstack/resources/agent.py +++ b/src/tabstack/resources/agent.py @@ -2,13 +2,14 @@ from __future__ import annotations +from typing import Iterable from typing_extensions import Literal import httpx -from ..types import agent_automate_params, agent_research_params +from ..types import agent_automate_params, agent_research_params, agent_automate_input_params from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from .._utils import maybe_transform, async_maybe_transform +from .._utils import path_template, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -21,6 +22,7 @@ from .._base_client import make_request_options from ..types.automate_event import AutomateEvent from ..types.research_event import ResearchEvent +from ..types.agent_automate_input_response import AgentAutomateInputResponse __all__ = ["AgentResource", "AsyncAgentResource"] @@ -134,6 +136,63 @@ def automate( stream_cls=Stream[AutomateEvent], ) + def automate_input( + self, + request_id: str, + *, + cancelled: bool | Omit = omit, + fields: Iterable[agent_automate_input_params.Field] | 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, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentAutomateInputResponse: + """ + Submit a response to an interactive form data request from an in-progress + automation task. When the AI agent encounters a form requiring user data, it + emits an `interactive:form_data:request` or `interactive:form_data:error` SSE + event containing a `requestId`. Use this endpoint to provide the requested data + or cancel the request. + + **Lifecycle:** + + - Input requests expire after 2 minutes by default + - Expired or already-answered requests return `410 Gone` + - Successful submissions return `202 Accepted` (fire-and-forget from caller's + perspective) + + Args: + cancelled: Set to true to cancel/decline the request + + fields: Field values as array of {ref, value} pairs (required when not cancelled) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not request_id: + raise ValueError(f"Expected a non-empty value for `request_id` but received {request_id!r}") + return self._post( + path_template("/automate/{request_id}/input", request_id=request_id), + body=maybe_transform( + { + "cancelled": cancelled, + "fields": fields, + }, + agent_automate_input_params.AgentAutomateInputParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentAutomateInputResponse, + ) + def research( self, *, @@ -317,6 +376,63 @@ async def automate( stream_cls=AsyncStream[AutomateEvent], ) + async def automate_input( + self, + request_id: str, + *, + cancelled: bool | Omit = omit, + fields: Iterable[agent_automate_input_params.Field] | 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, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentAutomateInputResponse: + """ + Submit a response to an interactive form data request from an in-progress + automation task. When the AI agent encounters a form requiring user data, it + emits an `interactive:form_data:request` or `interactive:form_data:error` SSE + event containing a `requestId`. Use this endpoint to provide the requested data + or cancel the request. + + **Lifecycle:** + + - Input requests expire after 2 minutes by default + - Expired or already-answered requests return `410 Gone` + - Successful submissions return `202 Accepted` (fire-and-forget from caller's + perspective) + + Args: + cancelled: Set to true to cancel/decline the request + + fields: Field values as array of {ref, value} pairs (required when not cancelled) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not request_id: + raise ValueError(f"Expected a non-empty value for `request_id` but received {request_id!r}") + return await self._post( + path_template("/automate/{request_id}/input", request_id=request_id), + body=await async_maybe_transform( + { + "cancelled": cancelled, + "fields": fields, + }, + agent_automate_input_params.AgentAutomateInputParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentAutomateInputResponse, + ) + async def research( self, *, @@ -398,6 +514,9 @@ def __init__(self, agent: AgentResource) -> None: self.automate = to_raw_response_wrapper( agent.automate, ) + self.automate_input = to_raw_response_wrapper( + agent.automate_input, + ) self.research = to_raw_response_wrapper( agent.research, ) @@ -410,6 +529,9 @@ def __init__(self, agent: AsyncAgentResource) -> None: self.automate = async_to_raw_response_wrapper( agent.automate, ) + self.automate_input = async_to_raw_response_wrapper( + agent.automate_input, + ) self.research = async_to_raw_response_wrapper( agent.research, ) @@ -422,6 +544,9 @@ def __init__(self, agent: AgentResource) -> None: self.automate = to_streamed_response_wrapper( agent.automate, ) + self.automate_input = to_streamed_response_wrapper( + agent.automate_input, + ) self.research = to_streamed_response_wrapper( agent.research, ) @@ -434,6 +559,9 @@ def __init__(self, agent: AsyncAgentResource) -> None: self.automate = async_to_streamed_response_wrapper( agent.automate, ) + self.automate_input = async_to_streamed_response_wrapper( + agent.automate_input, + ) self.research = async_to_streamed_response_wrapper( agent.research, ) diff --git a/src/tabstack/types/__init__.py b/src/tabstack/types/__init__.py index a0f24b4..feec32c 100644 --- a/src/tabstack/types/__init__.py +++ b/src/tabstack/types/__init__.py @@ -12,3 +12,5 @@ from .generate_json_response import GenerateJsonResponse as GenerateJsonResponse from .extract_markdown_params import ExtractMarkdownParams as ExtractMarkdownParams from .extract_markdown_response import ExtractMarkdownResponse as ExtractMarkdownResponse +from .agent_automate_input_params import AgentAutomateInputParams as AgentAutomateInputParams +from .agent_automate_input_response import AgentAutomateInputResponse as AgentAutomateInputResponse diff --git a/src/tabstack/types/agent_automate_input_params.py b/src/tabstack/types/agent_automate_input_params.py new file mode 100644 index 0000000..07adcc1 --- /dev/null +++ b/src/tabstack/types/agent_automate_input_params.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable +from typing_extensions import TypedDict + +__all__ = ["AgentAutomateInputParams", "Field"] + + +class AgentAutomateInputParams(TypedDict, total=False): + cancelled: bool + """Set to true to cancel/decline the request""" + + fields: Iterable[Field] + """Field values as array of {ref, value} pairs (required when not cancelled)""" + + +class Field(TypedDict, total=False): + ref: str + + value: str diff --git a/src/tabstack/types/agent_automate_input_response.py b/src/tabstack/types/agent_automate_input_response.py new file mode 100644 index 0000000..63e05da --- /dev/null +++ b/src/tabstack/types/agent_automate_input_response.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .._models import BaseModel + +__all__ = ["AgentAutomateInputResponse"] + + +class AgentAutomateInputResponse(BaseModel): + status: Optional[str] = None diff --git a/tests/api_resources/test_agent.py b/tests/api_resources/test_agent.py index a17f0a2..e96224c 100644 --- a/tests/api_resources/test_agent.py +++ b/tests/api_resources/test_agent.py @@ -8,6 +8,10 @@ import pytest from tabstack import Tabstack, AsyncTabstack +from tests.utils import assert_matches_type +from tabstack.types import ( + AgentAutomateInputResponse, +) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -63,6 +67,63 @@ def test_streaming_response_automate(self, client: Tabstack) -> None: assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_automate_input(self, client: Tabstack) -> None: + agent = client.agent.automate_input( + request_id="requestID", + ) + assert_matches_type(AgentAutomateInputResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_automate_input_with_all_params(self, client: Tabstack) -> None: + agent = client.agent.automate_input( + request_id="requestID", + cancelled=True, + fields=[ + { + "ref": "E42", + "value": "user@example.com", + } + ], + ) + assert_matches_type(AgentAutomateInputResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_automate_input(self, client: Tabstack) -> None: + response = client.agent.with_raw_response.automate_input( + request_id="requestID", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(AgentAutomateInputResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_automate_input(self, client: Tabstack) -> None: + with client.agent.with_streaming_response.automate_input( + request_id="requestID", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(AgentAutomateInputResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_automate_input(self, client: Tabstack) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `request_id` but received ''"): + client.agent.with_raw_response.automate_input( + request_id="", + ) + @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize def test_method_research(self, client: Tabstack) -> None: @@ -161,6 +222,63 @@ async def test_streaming_response_automate(self, async_client: AsyncTabstack) -> assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_automate_input(self, async_client: AsyncTabstack) -> None: + agent = await async_client.agent.automate_input( + request_id="requestID", + ) + assert_matches_type(AgentAutomateInputResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_automate_input_with_all_params(self, async_client: AsyncTabstack) -> None: + agent = await async_client.agent.automate_input( + request_id="requestID", + cancelled=True, + fields=[ + { + "ref": "E42", + "value": "user@example.com", + } + ], + ) + assert_matches_type(AgentAutomateInputResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_automate_input(self, async_client: AsyncTabstack) -> None: + response = await async_client.agent.with_raw_response.automate_input( + request_id="requestID", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(AgentAutomateInputResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_automate_input(self, async_client: AsyncTabstack) -> None: + async with async_client.agent.with_streaming_response.automate_input( + request_id="requestID", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(AgentAutomateInputResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_automate_input(self, async_client: AsyncTabstack) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `request_id` but received ''"): + await async_client.agent.with_raw_response.automate_input( + request_id="", + ) + @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize async def test_method_research(self, async_client: AsyncTabstack) -> None: From 6b6ba7b5b6a6aba119a6caf6aec3af5ff5c6350b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:03:59 +0000 Subject: [PATCH 13/15] feat(api): better handling of SSE events --- .stats.yml | 2 +- src/tabstack/_base_client.py | 4 ++++ src/tabstack/_models.py | 2 ++ src/tabstack/_streaming.py | 16 ++++++++++++++-- src/tabstack/_types.py | 1 + src/tabstack/resources/agent.py | 24 ++++++++++++++++++++---- 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/.stats.yml b/.stats.yml index 560dde2..63f86b6 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 6 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-ab135cad4c10edc98bbe45d18a1ca23d2d4aa4b2ba2e4554e6991e322ce46231.yml openapi_spec_hash: 3e8185dfbbd4e83d5f05ded9ce9c5b06 -config_hash: 64a326d972276ffd4a067a2f21f49681 +config_hash: 57c64e5e8fe99c1bd7af536d82af4ad9 diff --git a/src/tabstack/_base_client.py b/src/tabstack/_base_client.py index 6d03d50..8a3f478 100644 --- a/src/tabstack/_base_client.py +++ b/src/tabstack/_base_client.py @@ -1956,6 +1956,7 @@ def make_request_options( idempotency_key: str | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, post_parser: PostParser | NotGiven = not_given, + synthesize_event_and_data: bool | None = None, ) -> RequestOptions: """Create a dict of type RequestOptions without keys of NotGiven values.""" options: RequestOptions = {} @@ -1981,6 +1982,9 @@ def make_request_options( # internal options["post_parser"] = post_parser # type: ignore + if synthesize_event_and_data is not None: + options["synthesize_event_and_data"] = synthesize_event_and_data + return options diff --git a/src/tabstack/_models.py b/src/tabstack/_models.py index 29070e0..f75ae33 100644 --- a/src/tabstack/_models.py +++ b/src/tabstack/_models.py @@ -804,6 +804,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): json_data: Body extra_json: AnyMapping follow_redirects: bool + synthesize_event_and_data: bool @final @@ -818,6 +819,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 + synthesize_event_and_data: Optional[bool] = 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/tabstack/_streaming.py b/src/tabstack/_streaming.py index 3377de8..2b58d3a 100644 --- a/src/tabstack/_streaming.py +++ b/src/tabstack/_streaming.py @@ -59,7 +59,13 @@ def __stream__(self) -> Iterator[_T]: try: for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) + yield process_data( + data={"data": sse.json(), "event": sse.event} + if self._options is not None and self._options.synthesize_event_and_data + else sse.json(), + cast_to=cast_to, + response=response, + ) finally: # Ensure the response is closed even if the consumer doesn't read all data response.close() @@ -125,7 +131,13 @@ async def __stream__(self) -> AsyncIterator[_T]: try: async for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) + yield process_data( + data={"data": sse.json(), "event": sse.event} + if self._options is not None and self._options.synthesize_event_and_data + else sse.json(), + cast_to=cast_to, + response=response, + ) finally: # Ensure the response is closed even if the consumer doesn't read all data await response.aclose() diff --git a/src/tabstack/_types.py b/src/tabstack/_types.py index bf959ef..818a79d 100644 --- a/src/tabstack/_types.py +++ b/src/tabstack/_types.py @@ -121,6 +121,7 @@ class RequestOptions(TypedDict, total=False): extra_json: AnyMapping idempotency_key: str follow_redirects: bool + synthesize_event_and_data: bool # Sentinel class used until PEP 0661 is accepted diff --git a/src/tabstack/resources/agent.py b/src/tabstack/resources/agent.py index b8ba20f..1ae07c4 100644 --- a/src/tabstack/resources/agent.py +++ b/src/tabstack/resources/agent.py @@ -129,7 +129,11 @@ def automate( agent_automate_params.AgentAutomateParams, ), options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + synthesize_event_and_data=True, ), cast_to=AutomateEvent, stream=True, @@ -259,7 +263,11 @@ def research( agent_research_params.AgentResearchParams, ), options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + synthesize_event_and_data=True, ), cast_to=ResearchEvent, stream=True, @@ -369,7 +377,11 @@ async def automate( agent_automate_params.AgentAutomateParams, ), options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + synthesize_event_and_data=True, ), cast_to=AutomateEvent, stream=True, @@ -499,7 +511,11 @@ async def research( agent_research_params.AgentResearchParams, ), options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + synthesize_event_and_data=True, ), cast_to=ResearchEvent, stream=True, From 10f77bcad1e0ea05151f291bf55b17a04512465a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:54:12 +0000 Subject: [PATCH 14/15] fix: ensure file data are only sent as 1 parameter --- src/tabstack/_utils/_utils.py | 5 +++-- tests/test_extract_files.py | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/tabstack/_utils/_utils.py b/src/tabstack/_utils/_utils.py index eec7f4a..63b8cd6 100644 --- a/src/tabstack/_utils/_utils.py +++ b/src/tabstack/_utils/_utils.py @@ -86,8 +86,9 @@ def _extract_items( index += 1 if is_dict(obj): try: - # We are at the last entry in the path so we must remove the field - if (len(path)) == index: + # Remove the field if there are no more dict keys in the path, + # only "" traversal markers or end. + if all(p == "" for p in path[index:]): item = obj.pop(key) else: item = obj[key] diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index 6706d51..a11b04c 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -35,6 +35,15 @@ def test_multiple_files() -> None: assert query == {"documents": [{}, {}]} +def test_top_level_file_array() -> None: + query = {"files": [b"file one", b"file two"], "title": "hello"} + assert extract_files(query, paths=[["files", ""]]) == [ + ("files[]", b"file one"), + ("files[]", b"file two"), + ] + assert query == {"title": "hello"} + + @pytest.mark.parametrize( "query,paths,expected", [ From a26248d7a174b09e0987558fa84cd8f2dd708492 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:54:31 +0000 Subject: [PATCH 15/15] release: 2.4.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/tabstack/_version.py | 2 +- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 75ec52f..b44b287 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.3.0" + ".": "2.4.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 55679a4..aed943f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 2.4.0 (2026-04-10) + +Full Changelog: [v2.3.0...v2.4.0](https://github.com/Mozilla-Ocho/tabstack-python/compare/v2.3.0...v2.4.0) + +### Features + +* **api:** add input endpoint ([f3b1f16](https://github.com/Mozilla-Ocho/tabstack-python/commit/f3b1f16204c0d724193815c5e34100831f0a2653)) +* **api:** api update ([cda5c19](https://github.com/Mozilla-Ocho/tabstack-python/commit/cda5c19baedee455072a8071cb2741714a9ded19)) +* **api:** api update ([ed80ce0](https://github.com/Mozilla-Ocho/tabstack-python/commit/ed80ce06975480e8466b18b75397ee3c761f6398)) +* **api:** better handling of SSE events ([6b6ba7b](https://github.com/Mozilla-Ocho/tabstack-python/commit/6b6ba7b5b6a6aba119a6caf6aec3af5ff5c6350b)) +* **internal:** implement indices array format for query and form serialization ([97a9bb7](https://github.com/Mozilla-Ocho/tabstack-python/commit/97a9bb7241ad0c7a98483d81f74bbb5ee40e1b93)) + + +### Bug Fixes + +* **client:** preserve hardcoded query params when merging with user params ([2cda6ae](https://github.com/Mozilla-Ocho/tabstack-python/commit/2cda6aeb86a50115ba347549452c57700e810679)) +* **deps:** bump minimum typing-extensions version ([1946504](https://github.com/Mozilla-Ocho/tabstack-python/commit/194650472a951f76bd180e1dd08507fcf2670577)) +* ensure file data are only sent as 1 parameter ([10f77bc](https://github.com/Mozilla-Ocho/tabstack-python/commit/10f77bcad1e0ea05151f291bf55b17a04512465a)) +* **pydantic:** do not pass `by_alias` unless set ([a32962e](https://github.com/Mozilla-Ocho/tabstack-python/commit/a32962e54563d7073726b4463d70017fc2a40331)) +* sanitize endpoint path params ([f4632c7](https://github.com/Mozilla-Ocho/tabstack-python/commit/f4632c74a42a3ea56998e6f9d9afacace4bd4a5c)) + + +### Chores + +* **ci:** skip lint on metadata-only changes ([9710840](https://github.com/Mozilla-Ocho/tabstack-python/commit/97108400820fbe715dcb922c997b87b612bf588e)) +* configure new SDK language ([f5f587a](https://github.com/Mozilla-Ocho/tabstack-python/commit/f5f587ab07a757e4ada42e6bc3c802f29591c536)) +* **internal:** tweak CI branches ([8458777](https://github.com/Mozilla-Ocho/tabstack-python/commit/8458777f42fdbdc611d213513b1e7f026886ba42)) +* **internal:** update gitignore ([e39102c](https://github.com/Mozilla-Ocho/tabstack-python/commit/e39102cd8c44fbbaec52a5bed74a11deab25b97f)) + ## 2.3.0 (2026-03-12) Full Changelog: [v2.2.0...v2.3.0](https://github.com/Mozilla-Ocho/tabstack-python/compare/v2.2.0...v2.3.0) diff --git a/pyproject.toml b/pyproject.toml index 3822de4..c1383dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tabstack" -version = "2.3.0" +version = "2.4.0" description = "The official Python library for the tabstack API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/tabstack/_version.py b/src/tabstack/_version.py index 42120a2..1154ef9 100644 --- a/src/tabstack/_version.py +++ b/src/tabstack/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "tabstack" -__version__ = "2.3.0" # x-release-please-version +__version__ = "2.4.0" # x-release-please-version