diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e8b7f4..f4158ca 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/**' @@ -17,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 @@ -33,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: diff --git a/.gitignore b/.gitignore index 95ceb18..3824f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .prism.log +.stdy.log _dev __pycache__ 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/.stats.yml b/.stats.yml index 62b5e49..63f86b6 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 -config_hash: 6b388b673b2a48895ab10da245fb6771 +configured_endpoints: 6 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-ab135cad4c10edc98bbe45d18a1ca23d2d4aa4b2ba2e4554e6991e322ce46231.yml +openapi_spec_hash: 3e8185dfbbd4e83d5f05ded9ce9c5b06 +config_hash: 57c64e5e8fe99c1bd7af536d82af4ad9 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/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. 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/pyproject.toml b/pyproject.toml index 4a373c9..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" @@ -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/src/tabstack/_base_client.py b/src/tabstack/_base_client.py index 8df444d..8a3f478 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("_", "-")} @@ -1952,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 = {} @@ -1977,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/_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]", 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/_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 + "[]" 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/_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/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/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 diff --git a/src/tabstack/resources/agent.py b/src/tabstack/resources/agent.py index 0cf85d7..1ae07c4 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"] @@ -52,6 +54,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 +96,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 +121,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, @@ -123,13 +129,74 @@ 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, 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, *, @@ -196,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, @@ -231,6 +302,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 +344,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 +369,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, @@ -302,13 +377,74 @@ 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, 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, *, @@ -375,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, @@ -390,6 +530,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, ) @@ -402,6 +545,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, ) @@ -414,6 +560,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, ) @@ -426,6 +575,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/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/__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/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/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""" diff --git a/tests/api_resources/test_agent.py b/tests/api_resources/test_agent.py index cabea82..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") @@ -31,6 +35,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", @@ -62,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: @@ -128,6 +190,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", @@ -159,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: 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( 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", [ 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) 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"]