From fcf96d3c4f3bdbec2cd1b88745cdbbc48e864be2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:41:54 +0000 Subject: [PATCH 01/47] chore(internal): add request options to SSE classes --- src/beeper_desktop_api/_response.py | 3 +++ src/beeper_desktop_api/_streaming.py | 11 ++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/beeper_desktop_api/_response.py b/src/beeper_desktop_api/_response.py index 5d155b7..a7f1bf9 100644 --- a/src/beeper_desktop_api/_response.py +++ b/src/beeper_desktop_api/_response.py @@ -152,6 +152,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: ), response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) @@ -162,6 +163,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: cast_to=extract_stream_chunk_type(self._stream_cls), response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) @@ -175,6 +177,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: cast_to=cast_to, response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) diff --git a/src/beeper_desktop_api/_streaming.py b/src/beeper_desktop_api/_streaming.py index 55409b8..be797cc 100644 --- a/src/beeper_desktop_api/_streaming.py +++ b/src/beeper_desktop_api/_streaming.py @@ -4,7 +4,7 @@ import json import inspect from types import TracebackType -from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, AsyncIterator, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, Optional, AsyncIterator, cast from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable import httpx @@ -13,6 +13,7 @@ if TYPE_CHECKING: from ._client import BeeperDesktop, AsyncBeeperDesktop + from ._models import FinalRequestOptions _T = TypeVar("_T") @@ -22,7 +23,7 @@ class Stream(Generic[_T]): """Provides the core interface to iterate over a synchronous stream response.""" response: httpx.Response - + _options: Optional[FinalRequestOptions] = None _decoder: SSEBytesDecoder def __init__( @@ -31,10 +32,12 @@ def __init__( cast_to: type[_T], response: httpx.Response, client: BeeperDesktop, + options: Optional[FinalRequestOptions] = None, ) -> None: self.response = response self._cast_to = cast_to self._client = client + self._options = options self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() @@ -85,7 +88,7 @@ class AsyncStream(Generic[_T]): """Provides the core interface to iterate over an asynchronous stream response.""" response: httpx.Response - + _options: Optional[FinalRequestOptions] = None _decoder: SSEDecoder | SSEBytesDecoder def __init__( @@ -94,10 +97,12 @@ def __init__( cast_to: type[_T], response: httpx.Response, client: AsyncBeeperDesktop, + options: Optional[FinalRequestOptions] = None, ) -> None: self.response = response self._cast_to = cast_to self._client = client + self._options = options self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() From 2420dd3d3de95350f142acaf7fb923fd292af59e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:53:34 +0000 Subject: [PATCH 02/47] chore(internal): make `test_proxy_environment_variables` more resilient --- tests/test_client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 5cbca10..aa68ad7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -984,6 +984,8 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has this set + monkeypatch.delenv("HTTP_PROXY", raising=False) client = DefaultHttpxClient() @@ -1916,6 +1918,8 @@ async def test_get_platform(self) -> None: async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has this set + monkeypatch.delenv("HTTP_PROXY", raising=False) client = DefaultAsyncHttpxClient() From a54d51a23c31d38e124dc263f748f6ada2f2409c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:38:28 +0000 Subject: [PATCH 03/47] 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 56c368e..eea9217 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-4acef56b00be513f305543096fdd407e6947f0a5ad268ab2e627ff30b37a75db.yml openapi_spec_hash: e876d796b6c25f18577f6be3944bf7d9 -config_hash: 659111d4e28efa599b5f800619ed79c2 +config_hash: 66617ffb2c7b6ef016e9704e766e7f65 diff --git a/README.md b/README.md index b42ad75..cb71054 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ and offers both synchronous and asynchronous clients powered by [httpx](https:// Use the Beeper Desktop 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=%40beeper%2Fdesktop-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1tY3AiXSwiZW52Ijp7IkJFRVBFUl9BQ0NFU1NfVE9LRU4iOiJNeSBBY2Nlc3MgVG9rZW4ifX0) -[![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%40beeper%2Fdesktop-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-api-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1hcGktbWNwIl0sImVudiI6eyJCRUVQRVJfQUNDRVNTX1RPS0VOIjoiTXkgQWNjZXNzIFRva2VuIn19) +[![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%40beeper%2Fdesktop-api-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-api-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) > Note: You may need to set environment variables in your MCP client. From 770a8e2a6fc4d96dae58b3b787d55072faf63e34 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:39:39 +0000 Subject: [PATCH 04/47] feat(api): api update --- .stats.yml | 6 +++--- README.md | 18 ++++-------------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/.stats.yml b/.stats.yml index eea9217..6e96390 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-4acef56b00be513f305543096fdd407e6947f0a5ad268ab2e627ff30b37a75db.yml -openapi_spec_hash: e876d796b6c25f18577f6be3944bf7d9 -config_hash: 66617ffb2c7b6ef016e9704e766e7f65 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml +openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a +config_hash: 659111d4e28efa599b5f800619ed79c2 diff --git a/README.md b/README.md index cb71054..15ac23a 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ and offers both synchronous and asynchronous clients powered by [httpx](https:// Use the Beeper Desktop 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=%40beeper%2Fdesktop-api-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1hcGktbWNwIl0sImVudiI6eyJCRUVQRVJfQUNDRVNTX1RPS0VOIjoiTXkgQWNjZXNzIFRva2VuIn19) -[![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%40beeper%2Fdesktop-api-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-api-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1tY3AiXSwiZW52Ijp7IkJFRVBFUl9BQ0NFU1NfVE9LRU4iOiJNeSBBY2Nlc3MgVG9rZW4ifX0) +[![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%40beeper%2Fdesktop-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) > Note: You may need to set environment variables in your MCP client. @@ -35,12 +35,9 @@ pip install git+ssh://git@github.com/beeper/desktop-api-python.git The full API of this library can be found in [api.md](api.md). ```python -import os from beeper_desktop_api import BeeperDesktop -client = BeeperDesktop( - access_token=os.environ.get("BEEPER_ACCESS_TOKEN"), # This is the default and can be omitted -) +client = BeeperDesktop() page = client.chats.search( include_muted=True, @@ -60,13 +57,10 @@ so that your Access Token is not stored in source control. Simply import `AsyncBeeperDesktop` instead of `BeeperDesktop` and use `await` with each API call: ```python -import os import asyncio from beeper_desktop_api import AsyncBeeperDesktop -client = AsyncBeeperDesktop( - access_token=os.environ.get("BEEPER_ACCESS_TOKEN"), # This is the default and can be omitted -) +client = AsyncBeeperDesktop() async def main() -> None: @@ -97,7 +91,6 @@ pip install 'beeper_desktop_api[aiohttp] @ git+ssh://git@github.com/beeper/deskt Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python -import os import asyncio from beeper_desktop_api import DefaultAioHttpClient from beeper_desktop_api import AsyncBeeperDesktop @@ -105,9 +98,6 @@ from beeper_desktop_api import AsyncBeeperDesktop async def main() -> None: async with AsyncBeeperDesktop( - access_token=os.environ.get( - "BEEPER_ACCESS_TOKEN" - ), # This is the default and can be omitted http_client=DefaultAioHttpClient(), ) as client: page = await client.chats.search( From 8b9d76c76fe4e3ae99d85429f20d9b782bea2520 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:41:55 +0000 Subject: [PATCH 05/47] 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 6e96390..ea5e4be 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: 659111d4e28efa599b5f800619ed79c2 +config_hash: 66617ffb2c7b6ef016e9704e766e7f65 diff --git a/README.md b/README.md index 15ac23a..40c528f 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ and offers both synchronous and asynchronous clients powered by [httpx](https:// Use the Beeper Desktop 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=%40beeper%2Fdesktop-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1tY3AiXSwiZW52Ijp7IkJFRVBFUl9BQ0NFU1NfVE9LRU4iOiJNeSBBY2Nlc3MgVG9rZW4ifX0) -[![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%40beeper%2Fdesktop-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-api-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1hcGktbWNwIl0sImVudiI6eyJCRUVQRVJfQUNDRVNTX1RPS0VOIjoiTXkgQWNjZXNzIFRva2VuIn19) +[![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%40beeper%2Fdesktop-api-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-api-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) > Note: You may need to set environment variables in your MCP client. From 266e1afc38d192d9412a4d295727fdc16d74235d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:58:36 +0000 Subject: [PATCH 06/47] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index ea5e4be..7c03a30 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: 66617ffb2c7b6ef016e9704e766e7f65 +config_hash: 2f5c2448fc8eec47bb412de39beb09dc From 5df69bf22554340ee0fd0c694fb755c80907ee22 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:59:54 +0000 Subject: [PATCH 07/47] chore: update SDK settings --- .stats.yml | 2 +- README.md | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.stats.yml b/.stats.yml index 7c03a30..004aab8 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: 2f5c2448fc8eec47bb412de39beb09dc +config_hash: aa49273410d42fb96c5515dbce1f182f diff --git a/README.md b/README.md index 40c528f..b82d06c 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,10 @@ The REST API documentation can be found on [developers.beeper.com](https://devel ## Installation ```sh -# install from the production repo -pip install git+ssh://git@github.com/beeper/desktop-api-python.git +# install from PyPI +pip install beeper_desktop_api ``` -> [!NOTE] -> Once this package is [published to PyPI](https://www.stainless.com/docs/guides/publish), this will become: `pip install beeper_desktop_api` - ## Usage The full API of this library can be found in [api.md](api.md). @@ -84,8 +81,8 @@ By default, the async client uses `httpx` for HTTP requests. However, for improv You can enable this by installing `aiohttp`: ```sh -# install from the production repo -pip install 'beeper_desktop_api[aiohttp] @ git+ssh://git@github.com/beeper/desktop-api-python.git' +# install from PyPI +pip install beeper_desktop_api[aiohttp] ``` Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: From 1ad2ddfe678d2a495d69e45d7a1a8f0856af4211 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 04:25:14 +0000 Subject: [PATCH 08/47] chore(internal): make `test_proxy_environment_variables` more resilient to env --- tests/test_client.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index aa68ad7..a687487 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -984,8 +984,14 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") - # Delete in case our environment has this set + # Delete in case our environment has any proxy env vars set monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) client = DefaultHttpxClient() @@ -1918,8 +1924,14 @@ async def test_get_platform(self) -> None: async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") - # Delete in case our environment has this set + # Delete in case our environment has any proxy env vars set monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) client = DefaultAsyncHttpxClient() From a6b8aac8430c698cd1a73bab2cd257c9cf553df6 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 05:47:58 +0000 Subject: [PATCH 09/47] chore(internal): codegen related update --- src/beeper_desktop_api/_client.py | 6 ++++++ src/beeper_desktop_api/resources/info.py | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index 2dc0bf9..2f1306e 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -154,6 +154,7 @@ def assets(self) -> AssetsResource: @cached_property def info(self) -> InfoResource: + """Control the Beeper Desktop application""" from .resources.info import InfoResource return InfoResource(self) @@ -448,6 +449,7 @@ def assets(self) -> AsyncAssetsResource: @cached_property def info(self) -> AsyncInfoResource: + """Control the Beeper Desktop application""" from .resources.info import AsyncInfoResource return AsyncInfoResource(self) @@ -700,6 +702,7 @@ def assets(self) -> assets.AssetsResourceWithRawResponse: @cached_property def info(self) -> info.InfoResourceWithRawResponse: + """Control the Beeper Desktop application""" from .resources.info import InfoResourceWithRawResponse return InfoResourceWithRawResponse(self._client.info) @@ -748,6 +751,7 @@ def assets(self) -> assets.AsyncAssetsResourceWithRawResponse: @cached_property def info(self) -> info.AsyncInfoResourceWithRawResponse: + """Control the Beeper Desktop application""" from .resources.info import AsyncInfoResourceWithRawResponse return AsyncInfoResourceWithRawResponse(self._client.info) @@ -796,6 +800,7 @@ def assets(self) -> assets.AssetsResourceWithStreamingResponse: @cached_property def info(self) -> info.InfoResourceWithStreamingResponse: + """Control the Beeper Desktop application""" from .resources.info import InfoResourceWithStreamingResponse return InfoResourceWithStreamingResponse(self._client.info) @@ -844,6 +849,7 @@ def assets(self) -> assets.AsyncAssetsResourceWithStreamingResponse: @cached_property def info(self) -> info.AsyncInfoResourceWithStreamingResponse: + """Control the Beeper Desktop application""" from .resources.info import AsyncInfoResourceWithStreamingResponse return AsyncInfoResourceWithStreamingResponse(self._client.info) diff --git a/src/beeper_desktop_api/resources/info.py b/src/beeper_desktop_api/resources/info.py index 43a98bf..9b6bc94 100644 --- a/src/beeper_desktop_api/resources/info.py +++ b/src/beeper_desktop_api/resources/info.py @@ -20,6 +20,8 @@ class InfoResource(SyncAPIResource): + """Control the Beeper Desktop application""" + @cached_property def with_raw_response(self) -> InfoResourceWithRawResponse: """ @@ -63,6 +65,8 @@ def retrieve( class AsyncInfoResource(AsyncAPIResource): + """Control the Beeper Desktop application""" + @cached_property def with_raw_response(self) -> AsyncInfoResourceWithRawResponse: """ From 352dc26df496dac34b88c8d410a3d5761fad7cde Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:02:00 +0000 Subject: [PATCH 10/47] chore(test): do not count install time for mock server timeout --- scripts/mock | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/mock b/scripts/mock index 0b28f6e..bcf3b39 100755 --- a/scripts/mock +++ b/scripts/mock @@ -21,11 +21,22 @@ echo "==> Starting mock server with URL ${URL}" # Run prism mock on the given spec if [ "$1" == "--daemon" ]; then + # Pre-install the package so the download doesn't eat into the startup timeout + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & - # Wait for server to come online + # Wait for server to come online (max 30s) echo -n "Waiting for server" + attempts=0 while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 300 ]; then + echo + echo "Timed out waiting for Prism server to start" + cat .prism.log + exit 1 + fi echo -n "." sleep 0.1 done From 3f5692eb199bd02db1359e8131c8774eee7fabcf Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:08:09 +0000 Subject: [PATCH 11/47] chore(ci): skip uploading artifacts on stainless-internal branches --- .github/workflows/ci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e7de04..99e51ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,14 +61,18 @@ jobs: run: rye build - name: Get GitHub OIDC Token - if: github.repository == 'stainless-sdks/beeper-desktop-api-python' + if: |- + github.repository == 'stainless-sdks/beeper-desktop-api-python' && + !startsWith(github.ref, 'refs/heads/stl/') id: github-oidc uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); - name: Upload tarball - if: github.repository == 'stainless-sdks/beeper-desktop-api-python' + if: |- + github.repository == 'stainless-sdks/beeper-desktop-api-python' && + !startsWith(github.ref, 'refs/heads/stl/') env: URL: https://pkg.stainless.com/s AUTH: ${{ steps.github-oidc.outputs.github_token }} From f9883db325ccb453c60affa4218f2b257c4d41d8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:08:50 +0000 Subject: [PATCH 12/47] chore: update placeholder string --- tests/api_resources/test_assets.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py index 64927ac..16d9ffa 100644 --- a/tests/api_resources/test_assets.py +++ b/tests/api_resources/test_assets.py @@ -86,14 +86,14 @@ def test_streaming_response_serve(self, client: BeeperDesktop) -> None: @parametrize def test_method_upload(self, client: BeeperDesktop) -> None: asset = client.assets.upload( - file=b"raw file contents", + file=b"Example data", ) assert_matches_type(AssetUploadResponse, asset, path=["response"]) @parametrize def test_method_upload_with_all_params(self, client: BeeperDesktop) -> None: asset = client.assets.upload( - file=b"raw file contents", + file=b"Example data", file_name="fileName", mime_type="mimeType", ) @@ -102,7 +102,7 @@ def test_method_upload_with_all_params(self, client: BeeperDesktop) -> None: @parametrize def test_raw_response_upload(self, client: BeeperDesktop) -> None: response = client.assets.with_raw_response.upload( - file=b"raw file contents", + file=b"Example data", ) assert response.is_closed is True @@ -113,7 +113,7 @@ def test_raw_response_upload(self, client: BeeperDesktop) -> None: @parametrize def test_streaming_response_upload(self, client: BeeperDesktop) -> None: with client.assets.with_streaming_response.upload( - file=b"raw file contents", + file=b"Example data", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -234,14 +234,14 @@ async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop) @parametrize async def test_method_upload(self, async_client: AsyncBeeperDesktop) -> None: asset = await async_client.assets.upload( - file=b"raw file contents", + file=b"Example data", ) assert_matches_type(AssetUploadResponse, asset, path=["response"]) @parametrize async def test_method_upload_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: asset = await async_client.assets.upload( - file=b"raw file contents", + file=b"Example data", file_name="fileName", mime_type="mimeType", ) @@ -250,7 +250,7 @@ async def test_method_upload_with_all_params(self, async_client: AsyncBeeperDesk @parametrize async def test_raw_response_upload(self, async_client: AsyncBeeperDesktop) -> None: response = await async_client.assets.with_raw_response.upload( - file=b"raw file contents", + file=b"Example data", ) assert response.is_closed is True @@ -261,7 +261,7 @@ async def test_raw_response_upload(self, async_client: AsyncBeeperDesktop) -> No @parametrize async def test_streaming_response_upload(self, async_client: AsyncBeeperDesktop) -> None: async with async_client.assets.with_streaming_response.upload( - file=b"raw file contents", + file=b"Example data", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" From 0d6781765c31c396a4cf0b0e89b61dd816645941 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:45:30 +0000 Subject: [PATCH 13/47] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 004aab8..06ba3c3 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: aa49273410d42fb96c5515dbce1f182f +config_hash: bfb432c69dc0a8d273043a3cdd87ffe1 From c84dca576d56b83e314ab798749607e70aea7223 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:22:00 +0000 Subject: [PATCH 14/47] feat(api): manual updates --- .stats.yml | 2 +- README.md | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.stats.yml b/.stats.yml index 06ba3c3..5dbc3d6 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: bfb432c69dc0a8d273043a3cdd87ffe1 +config_hash: 6fc4359a793fc3fc9ac01712b5ef8c0d diff --git a/README.md b/README.md index b82d06c..7972750 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,14 @@ The Beeper Desktop Python library provides convenient access to the Beeper Deskt application. The library includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). +It is generated with [Stainless](https://www.stainless.com/). + ## MCP Server Use the Beeper Desktop 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=%40beeper%2Fdesktop-api-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1hcGktbWNwIl0sImVudiI6eyJCRUVQRVJfQUNDRVNTX1RPS0VOIjoiTXkgQWNjZXNzIFRva2VuIn19) -[![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%40beeper%2Fdesktop-api-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-api-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1tY3AiXSwiZW52Ijp7IkJFRVBFUl9BQ0NFU1NfVE9LRU4iOiJNeSBBY2Nlc3MgVG9rZW4ifX0) +[![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%40beeper%2Fdesktop-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) > Note: You may need to set environment variables in your MCP client. @@ -23,10 +25,13 @@ The REST API documentation can be found on [developers.beeper.com](https://devel ## Installation ```sh -# install from PyPI -pip install beeper_desktop_api +# install from the production repo +pip install git+ssh://git@github.com/beeper/desktop-api-python.git ``` +> [!NOTE] +> Once this package is [published to PyPI](https://www.stainless.com/docs/guides/publish), this will become: `pip install beeper_desktop_api` + ## Usage The full API of this library can be found in [api.md](api.md). @@ -81,8 +86,8 @@ By default, the async client uses `httpx` for HTTP requests. However, for improv You can enable this by installing `aiohttp`: ```sh -# install from PyPI -pip install beeper_desktop_api[aiohttp] +# install from the production repo +pip install 'beeper_desktop_api[aiohttp] @ git+ssh://git@github.com/beeper/desktop-api-python.git' ``` Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: From bbfc0badc6259db8d6d448ef828cf9ac484d1667 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:36:12 +0000 Subject: [PATCH 15/47] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 5dbc3d6..2b39be6 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: 6fc4359a793fc3fc9ac01712b5ef8c0d +config_hash: ca148af6be59ec54295b2c5f852a38d1 From 8b9fe85df1911bc10a65b5c965e5465c4041e065 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:15:09 +0000 Subject: [PATCH 16/47] fix(pydantic): do not pass `by_alias` unless set --- src/beeper_desktop_api/_compat.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/beeper_desktop_api/_compat.py b/src/beeper_desktop_api/_compat.py index 786ff42..e6690a4 100644 --- a/src/beeper_desktop_api/_compat.py +++ b/src/beeper_desktop_api/_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 922d90aeb6d75306490a359d26ebbf71a6e340b8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:33:27 +0000 Subject: [PATCH 17/47] fix(deps): bump minimum typing-extensions version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 089b317..3f8161a 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", From 311a998617de99c1defaa4c54f1b8a308d1bfaf3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:38:25 +0000 Subject: [PATCH 18/47] 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 99e51ac..afb122d 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 900c955edf1d5f8cf7aa9c7d8a7859e5b61ae379 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:08:03 +0000 Subject: [PATCH 19/47] fix: sanitize endpoint path params --- src/beeper_desktop_api/_utils/__init__.py | 1 + src/beeper_desktop_api/_utils/_path.py | 127 ++++++++++++++++++ .../resources/accounts/contacts.py | 10 +- .../resources/chats/chats.py | 10 +- .../resources/chats/messages/reactions.py | 18 ++- .../resources/chats/reminders.py | 10 +- src/beeper_desktop_api/resources/messages.py | 14 +- tests/test_utils/test_path.py | 89 ++++++++++++ 8 files changed, 252 insertions(+), 27 deletions(-) create mode 100644 src/beeper_desktop_api/_utils/_path.py create mode 100644 tests/test_utils/test_path.py diff --git a/src/beeper_desktop_api/_utils/__init__.py b/src/beeper_desktop_api/_utils/__init__.py index dc64e29..10cb66d 100644 --- a/src/beeper_desktop_api/_utils/__init__.py +++ b/src/beeper_desktop_api/_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/beeper_desktop_api/_utils/_path.py b/src/beeper_desktop_api/_utils/_path.py new file mode 100644 index 0000000..4d6e1e4 --- /dev/null +++ b/src/beeper_desktop_api/_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/beeper_desktop_api/resources/accounts/contacts.py b/src/beeper_desktop_api/resources/accounts/contacts.py index 02749f1..ba704bb 100644 --- a/src/beeper_desktop_api/resources/accounts/contacts.py +++ b/src/beeper_desktop_api/resources/accounts/contacts.py @@ -7,7 +7,7 @@ import httpx 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 ( @@ -88,7 +88,7 @@ def list( if not account_id: raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") return self._get_api_list( - f"/v1/accounts/{account_id}/contacts/list", + path_template("/v1/accounts/{account_id}/contacts/list", account_id=account_id), page=SyncCursorSearch[User], options=make_request_options( extra_headers=extra_headers, @@ -140,7 +140,7 @@ def search( if not account_id: raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") return self._get( - f"/v1/accounts/{account_id}/contacts", + path_template("/v1/accounts/{account_id}/contacts", account_id=account_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -215,7 +215,7 @@ def list( if not account_id: raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") return self._get_api_list( - f"/v1/accounts/{account_id}/contacts/list", + path_template("/v1/accounts/{account_id}/contacts/list", account_id=account_id), page=AsyncCursorSearch[User], options=make_request_options( extra_headers=extra_headers, @@ -267,7 +267,7 @@ async def search( if not account_id: raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") return await self._get( - f"/v1/accounts/{account_id}/contacts", + path_template("/v1/accounts/{account_id}/contacts", account_id=account_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index 6a3cdb0..b72d252 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -10,7 +10,7 @@ from ...types import chat_list_params, chat_create_params, chat_search_params, chat_archive_params, chat_retrieve_params from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, 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 .reminders import ( RemindersResource, @@ -180,7 +180,7 @@ def retrieve( if not chat_id: raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return self._get( - f"/v1/chats/{chat_id}", + path_template("/v1/chats/{chat_id}", chat_id=chat_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -281,7 +281,7 @@ def archive( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return self._post( - f"/v1/chats/{chat_id}/archive", + path_template("/v1/chats/{chat_id}/archive", chat_id=chat_id), body=maybe_transform({"archived": archived}, chat_archive_params.ChatArchiveParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -523,7 +523,7 @@ async def retrieve( if not chat_id: raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return await self._get( - f"/v1/chats/{chat_id}", + path_template("/v1/chats/{chat_id}", chat_id=chat_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -624,7 +624,7 @@ async def archive( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return await self._post( - f"/v1/chats/{chat_id}/archive", + path_template("/v1/chats/{chat_id}/archive", chat_id=chat_id), body=await async_maybe_transform({"archived": archived}, chat_archive_params.ChatArchiveParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout diff --git a/src/beeper_desktop_api/resources/chats/messages/reactions.py b/src/beeper_desktop_api/resources/chats/messages/reactions.py index d9e610d..13a855d 100644 --- a/src/beeper_desktop_api/resources/chats/messages/reactions.py +++ b/src/beeper_desktop_api/resources/chats/messages/reactions.py @@ -5,7 +5,7 @@ import httpx 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 ( @@ -78,7 +78,9 @@ def delete( if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") return self._delete( - f"/v1/chats/{chat_id}/messages/{message_id}/reactions", + path_template( + "/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -126,7 +128,9 @@ def add( if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") return self._post( - f"/v1/chats/{chat_id}/messages/{message_id}/reactions", + path_template( + "/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id + ), body=maybe_transform( { "reaction_key": reaction_key, @@ -197,7 +201,9 @@ async def delete( if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") return await self._delete( - f"/v1/chats/{chat_id}/messages/{message_id}/reactions", + path_template( + "/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -247,7 +253,9 @@ async def add( if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") return await self._post( - f"/v1/chats/{chat_id}/messages/{message_id}/reactions", + path_template( + "/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id + ), body=await async_maybe_transform( { "reaction_key": reaction_key, diff --git a/src/beeper_desktop_api/resources/chats/reminders.py b/src/beeper_desktop_api/resources/chats/reminders.py index 2096903..32a169b 100644 --- a/src/beeper_desktop_api/resources/chats/reminders.py +++ b/src/beeper_desktop_api/resources/chats/reminders.py @@ -5,7 +5,7 @@ import httpx from ..._types import Body, Query, Headers, NoneType, NotGiven, 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 ( @@ -74,7 +74,7 @@ def create( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return self._post( - f"/v1/chats/{chat_id}/reminders", + path_template("/v1/chats/{chat_id}/reminders", chat_id=chat_id), body=maybe_transform({"reminder": reminder}, reminder_create_params.ReminderCreateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -111,7 +111,7 @@ def delete( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return self._delete( - f"/v1/chats/{chat_id}/reminders", + path_template("/v1/chats/{chat_id}/reminders", chat_id=chat_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -173,7 +173,7 @@ async def create( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return await self._post( - f"/v1/chats/{chat_id}/reminders", + path_template("/v1/chats/{chat_id}/reminders", chat_id=chat_id), body=await async_maybe_transform({"reminder": reminder}, reminder_create_params.ReminderCreateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -210,7 +210,7 @@ async def delete( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return await self._delete( - f"/v1/chats/{chat_id}/reminders", + path_template("/v1/chats/{chat_id}/reminders", chat_id=chat_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index b97c7a0..af2178e 100644 --- a/src/beeper_desktop_api/resources/messages.py +++ b/src/beeper_desktop_api/resources/messages.py @@ -10,7 +10,7 @@ from ..types import message_list_params, message_send_params, message_search_params, message_update_params from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, 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 ( @@ -86,7 +86,7 @@ def update( if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") return self._put( - f"/v1/chats/{chat_id}/messages/{message_id}", + path_template("/v1/chats/{chat_id}/messages/{message_id}", chat_id=chat_id, message_id=message_id), body=maybe_transform({"text": text}, message_update_params.MessageUpdateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -130,7 +130,7 @@ def list( if not chat_id: raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return self._get_api_list( - f"/v1/chats/{chat_id}/messages", + path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id), page=SyncCursorSortKey[Message], options=make_request_options( extra_headers=extra_headers, @@ -288,7 +288,7 @@ def send( if not chat_id: raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return self._post( - f"/v1/chats/{chat_id}/messages", + path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id), body=maybe_transform( { "attachment": attachment, @@ -362,7 +362,7 @@ async def update( if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") return await self._put( - f"/v1/chats/{chat_id}/messages/{message_id}", + path_template("/v1/chats/{chat_id}/messages/{message_id}", chat_id=chat_id, message_id=message_id), body=await async_maybe_transform({"text": text}, message_update_params.MessageUpdateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -406,7 +406,7 @@ def list( if not chat_id: raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return self._get_api_list( - f"/v1/chats/{chat_id}/messages", + path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id), page=AsyncCursorSortKey[Message], options=make_request_options( extra_headers=extra_headers, @@ -564,7 +564,7 @@ async def send( if not chat_id: raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return await self._post( - f"/v1/chats/{chat_id}/messages", + path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id), body=await async_maybe_transform( { "attachment": attachment, diff --git a/tests/test_utils/test_path.py b/tests/test_utils/test_path.py new file mode 100644 index 0000000..d429db8 --- /dev/null +++ b/tests/test_utils/test_path.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from beeper_desktop_api._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 ef99778f642f49a26aad1d65c59df9f9cfa766e9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:09:41 +0000 Subject: [PATCH 20/47] refactor(tests): switch from prism to steady --- CONTRIBUTING.md | 2 +- scripts/mock | 26 +++++++++++++------------- scripts/test | 16 ++++++++-------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08c3ec2..f303ab9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,7 @@ $ pip install ./path-to-wheel-file.whl ## Running tests -Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. +Most tests require you to [set up a mock server](https://github.com/dgellow/steady) against the OpenAPI spec to run the tests. ```sh $ ./scripts/mock diff --git a/scripts/mock b/scripts/mock index bcf3b39..00b490b 100755 --- a/scripts/mock +++ b/scripts/mock @@ -19,34 +19,34 @@ fi echo "==> Starting mock server with URL ${URL}" -# Run prism mock on the given spec +# Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version + npm exec --package=@stdy/cli@0.19.3 -- steady --version - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & + npm exec --package=@stdy/cli@0.19.3 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-query-object-format=brackets "$URL" &> .stdy.log & - # Wait for server to come online (max 30s) + # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" attempts=0 - while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + while ! curl --silent --fail "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1; do + if ! kill -0 $! 2>/dev/null; then + echo + cat .stdy.log + exit 1 + fi attempts=$((attempts + 1)) if [ "$attempts" -ge 300 ]; then echo - echo "Timed out waiting for Prism server to start" - cat .prism.log + echo "Timed out waiting for Steady server to start" + cat .stdy.log exit 1 fi echo -n "." sleep 0.1 done - if grep -q "✖ fatal" ".prism.log"; then - cat .prism.log - exit 1 - fi - echo else - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" + npm exec --package=@stdy/cli@0.19.3 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-query-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index dbeda2d..d0fe9be 100755 --- a/scripts/test +++ b/scripts/test @@ -9,8 +9,8 @@ GREEN='\033[0;32m' YELLOW='\033[0;33m' NC='\033[0m' # No Color -function prism_is_running() { - curl --silent "http://localhost:4010" >/dev/null 2>&1 +function steady_is_running() { + curl --silent "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1 } kill_server_on_port() { @@ -25,7 +25,7 @@ function is_overriding_api_base_url() { [ -n "$TEST_API_BASE_URL" ] } -if ! is_overriding_api_base_url && ! prism_is_running ; then +if ! is_overriding_api_base_url && ! steady_is_running ; then # When we exit this script, make sure to kill the background mock server process trap 'kill_server_on_port 4010' EXIT @@ -36,19 +36,19 @@ fi if is_overriding_api_base_url ; then echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" echo -elif ! prism_is_running ; then - echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" +elif ! steady_is_running ; then + echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Steady server" echo -e "running against your OpenAPI spec." echo echo -e "To run the server, pass in the path or url of your OpenAPI" - echo -e "spec to the prism command:" + echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.3 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-query-object-format=brackets${NC}" echo exit 1 else - echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" + echo -e "${GREEN}✔ Mock steady server is running with your OpenAPI spec${NC}" echo fi From 68f14afb85f168eb61a91398165b1d8222fbeea4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:11:00 +0000 Subject: [PATCH 21/47] chore(tests): bump steady to v0.19.4 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 00b490b..f310477 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.3 -- steady --version + npm exec --package=@stdy/cli@0.19.4 -- steady --version - npm exec --package=@stdy/cli@0.19.3 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.19.4 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.3 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.19.4 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index d0fe9be..0c2bfad 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.3 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.4 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" echo exit 1 From 9229d32b59f8494f49677dbf508d2fedd21cd8b4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:18:23 +0000 Subject: [PATCH 22/47] chore(tests): bump steady to v0.19.5 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index f310477..54fc791 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.4 -- steady --version + npm exec --package=@stdy/cli@0.19.5 -- steady --version - npm exec --package=@stdy/cli@0.19.4 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.19.5 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.4 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.19.5 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 0c2bfad..4153738 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.4 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.5 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" echo exit 1 From 3dfb3799f6ebbde6400db092864361e3b6a6e07f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 02:09:33 +0000 Subject: [PATCH 23/47] 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 166b069fbd3034b27d16baf50ef96c61a46996d9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 02:16:24 +0000 Subject: [PATCH 24/47] chore(tests): bump steady to v0.19.6 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 54fc791..0f82c95 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.5 -- steady --version + npm exec --package=@stdy/cli@0.19.6 -- steady --version - npm exec --package=@stdy/cli@0.19.5 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.5 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 4153738..4f9eef9 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.5 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.6 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" echo exit 1 From 1fe013eacfb814508b88eefa3b2ee3bf51618edc Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:05:07 +0000 Subject: [PATCH 25/47] 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 afb122d..1ca0ca2 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/beeper-desktop-api-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 @@ -38,7 +38,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 31a8e58a09bd798b9930c195da2be239d50a2e77 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:05:53 +0000 Subject: [PATCH 26/47] chore(tests): bump steady to v0.19.7 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 0f82c95..3732f8e 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.6 -- steady --version + npm exec --package=@stdy/cli@0.19.7 -- steady --version - npm exec --package=@stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 4f9eef9..e642cea 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.6 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" echo exit 1 From b954521c09743ea77dc1fe3f49e62cadb9cb6b1f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:25:03 +0000 Subject: [PATCH 27/47] chore: update SDK settings --- .stats.yml | 2 +- README.md | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.stats.yml b/.stats.yml index 2b39be6..60bb453 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: ca148af6be59ec54295b2c5f852a38d1 +config_hash: e342a96262eaf44c54e8bbb93cc8d7a7 diff --git a/README.md b/README.md index 7972750..b0c6f12 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,10 @@ The REST API documentation can be found on [developers.beeper.com](https://devel ## Installation ```sh -# install from the production repo -pip install git+ssh://git@github.com/beeper/desktop-api-python.git +# install from PyPI +pip install beeper_desktop_api ``` -> [!NOTE] -> Once this package is [published to PyPI](https://www.stainless.com/docs/guides/publish), this will become: `pip install beeper_desktop_api` - ## Usage The full API of this library can be found in [api.md](api.md). @@ -86,8 +83,8 @@ By default, the async client uses `httpx` for HTTP requests. However, for improv You can enable this by installing `aiohttp`: ```sh -# install from the production repo -pip install 'beeper_desktop_api[aiohttp] @ git+ssh://git@github.com/beeper/desktop-api-python.git' +# install from PyPI +pip install beeper_desktop_api[aiohttp] ``` Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: From fc973f48f6dce9712ce7da0c588ed9fee2e6348e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:25:37 +0000 Subject: [PATCH 28/47] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 60bb453..16d5bba 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: e342a96262eaf44c54e8bbb93cc8d7a7 +config_hash: f99f904573839260bdb6d428bad17613 From daf5390c2b456877ee06b154d9567af708bb0057 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:26:20 +0000 Subject: [PATCH 29/47] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 16d5bba..2c47924 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: f99f904573839260bdb6d428bad17613 +config_hash: 7d85c0b454fc78a59db6474c5c4d73c6 From de85c3aef481f44350bab667ed81e155573ace81 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:09:32 +0000 Subject: [PATCH 30/47] feat(internal): implement indices array format for query and form serialization --- scripts/mock | 4 ++-- scripts/test | 2 +- src/beeper_desktop_api/_qs.py | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 3732f8e..58e4628 100755 --- a/scripts/mock +++ b/scripts/mock @@ -24,7 +24,7 @@ if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout npm exec --package=@stdy/cli@0.19.7 -- steady --version - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index e642cea..3970464 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 diff --git a/src/beeper_desktop_api/_qs.py b/src/beeper_desktop_api/_qs.py index ada6fd3..de8c99b 100644 --- a/src/beeper_desktop_api/_qs.py +++ b/src/beeper_desktop_api/_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 d2cf119042313a4b82f4a395a43c175874ceca1e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 02:17:51 +0000 Subject: [PATCH 31/47] chore(tests): bump steady to v0.20.1 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 58e4628..5ea72a2 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.7 -- steady --version + npm exec --package=@stdy/cli@0.20.1 -- steady --version - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 3970464..3fdac80 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From 0def55c17787849b27d1e8c21cb6cd129069e220 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 02:22:29 +0000 Subject: [PATCH 32/47] chore(tests): bump steady to v0.20.2 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 5ea72a2..7c58865 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.20.1 -- steady --version + npm exec --package=@stdy/cli@0.20.2 -- steady --version - npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 3fdac80..87cdeac 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.2 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From 9e86464960e28472bc3a4f137c5d2c025f2acc16 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 02:10:53 +0000 Subject: [PATCH 33/47] fix(client): preserve hardcoded query params when merging with user params --- src/beeper_desktop_api/_base_client.py | 4 +++ tests/test_client.py | 48 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/beeper_desktop_api/_base_client.py b/src/beeper_desktop_api/_base_client.py index 25424b1..4e62b4b 100644 --- a/src/beeper_desktop_api/_base_client.py +++ b/src/beeper_desktop_api/_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 a687487..0e4b49b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -443,6 +443,30 @@ def test_default_query_option(self) -> None: client.close() + def test_hardcoded_query_params_in_url(self, client: BeeperDesktop) -> 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: BeeperDesktop) -> None: request = client._build_request( FinalRequestOptions( @@ -1366,6 +1390,30 @@ async def test_default_query_option(self) -> None: await client.close() + async def test_hardcoded_query_params_in_url(self, async_client: AsyncBeeperDesktop) -> 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: BeeperDesktop) -> None: request = client._build_request( FinalRequestOptions( From 69f6d11ecb0959d1a5eb90c41c76542a1ea5826f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 02:30:02 +0000 Subject: [PATCH 34/47] fix: ensure file data are only sent as 1 parameter --- src/beeper_desktop_api/_utils/_utils.py | 5 +++-- tests/test_extract_files.py | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/beeper_desktop_api/_utils/_utils.py b/src/beeper_desktop_api/_utils/_utils.py index eec7f4a..63b8cd6 100644 --- a/src/beeper_desktop_api/_utils/_utils.py +++ b/src/beeper_desktop_api/_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 497fb79..7c7c5dd 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 af70fc9fab45036721b4be634bb4444964c70d1e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:31:50 +0000 Subject: [PATCH 35/47] feat(api): add network, bridge fields to accounts --- .stats.yml | 6 +- README.md | 34 +++++--- .../resources/chats/chats.py | 86 +------------------ src/beeper_desktop_api/types/account.py | 23 ++++- .../types/chat_create_params.py | 83 ++++++++++-------- tests/api_resources/test_chats.py | 74 +++++++--------- 6 files changed, 127 insertions(+), 179 deletions(-) diff --git a/.stats.yml b/.stats.yml index 2c47924..229f6b5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml -openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: 7d85c0b454fc78a59db6474c5c4d73c6 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml +openapi_spec_hash: d6c0a1776048dab04f6c5625c9893c9c +config_hash: 39ed0717b5f415499aaace2468346e1a diff --git a/README.md b/README.md index b0c6f12..c0c9be9 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,6 @@ The Beeper Desktop Python library provides convenient access to the Beeper Deskt application. The library includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). -It is generated with [Stainless](https://www.stainless.com/). - ## MCP Server Use the Beeper Desktop 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. @@ -25,18 +23,24 @@ The REST API documentation can be found on [developers.beeper.com](https://devel ## Installation ```sh -# install from PyPI -pip install beeper_desktop_api +# install from the production repo +pip install git+ssh://git@github.com/beeper/desktop-api-python.git ``` +> [!NOTE] +> Once this package is [published to PyPI](https://www.stainless.com/docs/guides/publish), this will become: `pip install beeper_desktop_api` + ## Usage The full API of this library can be found in [api.md](api.md). ```python +import os from beeper_desktop_api import BeeperDesktop -client = BeeperDesktop() +client = BeeperDesktop( + access_token=os.environ.get("BEEPER_ACCESS_TOKEN"), # This is the default and can be omitted +) page = client.chats.search( include_muted=True, @@ -56,10 +60,13 @@ so that your Access Token is not stored in source control. Simply import `AsyncBeeperDesktop` instead of `BeeperDesktop` and use `await` with each API call: ```python +import os import asyncio from beeper_desktop_api import AsyncBeeperDesktop -client = AsyncBeeperDesktop() +client = AsyncBeeperDesktop( + access_token=os.environ.get("BEEPER_ACCESS_TOKEN"), # This is the default and can be omitted +) async def main() -> None: @@ -83,13 +90,14 @@ By default, the async client uses `httpx` for HTTP requests. However, for improv You can enable this by installing `aiohttp`: ```sh -# install from PyPI -pip install beeper_desktop_api[aiohttp] +# install from the production repo +pip install 'beeper_desktop_api[aiohttp] @ git+ssh://git@github.com/beeper/desktop-api-python.git' ``` Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python +import os import asyncio from beeper_desktop_api import DefaultAioHttpClient from beeper_desktop_api import AsyncBeeperDesktop @@ -97,6 +105,9 @@ from beeper_desktop_api import AsyncBeeperDesktop async def main() -> None: async with AsyncBeeperDesktop( + access_token=os.environ.get( + "BEEPER_ACCESS_TOKEN" + ), # This is the default and can be omitted http_client=DefaultAioHttpClient(), ) as client: page = await client.chats.search( @@ -207,11 +218,10 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() -chat = client.chats.create( - account_id="accountID", - user={}, +client.chats.reminders.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, ) -print(chat.user) ``` ## File uploads diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index b72d252..2a6a92c 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -79,14 +79,7 @@ def with_streaming_response(self) -> ChatsResourceWithStreamingResponse: def create( self, *, - account_id: str, - allow_invite: bool | Omit = omit, - message_text: str | Omit = omit, - mode: Literal["create", "start"] | Omit = omit, - participant_ids: SequenceNotStr[str] | Omit = omit, - title: str | Omit = omit, - type: Literal["single", "group"] | Omit = omit, - user: chat_create_params.User | Omit = omit, + params: chat_create_params.Params | 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, @@ -99,26 +92,6 @@ def create( user data (mode='start'). Args: - account_id: Account to create or start the chat on. - - allow_invite: Whether invite-based DM creation is allowed when required by the platform. Used - for mode='start'. - - message_text: Optional first message content if the platform requires it to create the chat. - - mode: Operation mode. Defaults to 'create' when omitted. - - participant_ids: Required when mode='create'. User IDs to include in the new chat. - - title: Optional title for group chats when mode='create'; ignored for single chats on - most platforms. - - type: Required when mode='create'. 'single' requires exactly one participantID; - 'group' supports multiple participants and optional title. - - user: Required when mode='start'. Merged user-like contact payload used to resolve the - best identifier. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -129,19 +102,7 @@ def create( """ return self._post( "/v1/chats", - body=maybe_transform( - { - "account_id": account_id, - "allow_invite": allow_invite, - "message_text": message_text, - "mode": mode, - "participant_ids": participant_ids, - "title": title, - "type": type, - "user": user, - }, - chat_create_params.ChatCreateParams, - ), + body=maybe_transform(params, chat_create_params.ChatCreateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -422,14 +383,7 @@ def with_streaming_response(self) -> AsyncChatsResourceWithStreamingResponse: async def create( self, *, - account_id: str, - allow_invite: bool | Omit = omit, - message_text: str | Omit = omit, - mode: Literal["create", "start"] | Omit = omit, - participant_ids: SequenceNotStr[str] | Omit = omit, - title: str | Omit = omit, - type: Literal["single", "group"] | Omit = omit, - user: chat_create_params.User | Omit = omit, + params: chat_create_params.Params | 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, @@ -442,26 +396,6 @@ async def create( user data (mode='start'). Args: - account_id: Account to create or start the chat on. - - allow_invite: Whether invite-based DM creation is allowed when required by the platform. Used - for mode='start'. - - message_text: Optional first message content if the platform requires it to create the chat. - - mode: Operation mode. Defaults to 'create' when omitted. - - participant_ids: Required when mode='create'. User IDs to include in the new chat. - - title: Optional title for group chats when mode='create'; ignored for single chats on - most platforms. - - type: Required when mode='create'. 'single' requires exactly one participantID; - 'group' supports multiple participants and optional title. - - user: Required when mode='start'. Merged user-like contact payload used to resolve the - best identifier. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -472,19 +406,7 @@ async def create( """ return await self._post( "/v1/chats", - body=await async_maybe_transform( - { - "account_id": account_id, - "allow_invite": allow_invite, - "message_text": message_text, - "mode": mode, - "participant_ids": participant_ids, - "title": title, - "type": type, - "user": user, - }, - chat_create_params.ChatCreateParams, - ), + body=await async_maybe_transform(params, chat_create_params.ChatCreateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/beeper_desktop_api/types/account.py b/src/beeper_desktop_api/types/account.py index ff00c78..c024419 100644 --- a/src/beeper_desktop_api/types/account.py +++ b/src/beeper_desktop_api/types/account.py @@ -1,11 +1,26 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from typing_extensions import Literal + from pydantic import Field as FieldInfo from .._models import BaseModel from .shared.user import User -__all__ = ["Account"] +__all__ = ["Account", "Bridge"] + + +class Bridge(BaseModel): + """Bridge metadata for the account. Available from Beeper Desktop v.4.2.719+.""" + + id: str + """Bridge instance identifier.""" + + provider: Literal["cloud", "self-hosted", "local", "platform-sdk"] + """Bridge provider for the account.""" + + type: str + """Bridge type.""" class Account(BaseModel): @@ -14,5 +29,11 @@ class Account(BaseModel): account_id: str = FieldInfo(alias="accountID") """Chat account added to Beeper. Use this to route account-scoped actions.""" + bridge: Bridge + """Bridge metadata for the account. Available from Beeper Desktop v.4.2.719+.""" + + network: str + """Human-friendly network name for the account.""" + user: User """User the account belongs to.""" diff --git a/src/beeper_desktop_api/types/chat_create_params.py b/src/beeper_desktop_api/types/chat_create_params.py index 93229c1..f755b70 100644 --- a/src/beeper_desktop_api/types/chat_create_params.py +++ b/src/beeper_desktop_api/types/chat_create_params.py @@ -2,18 +2,48 @@ from __future__ import annotations -from typing_extensions import Literal, Required, Annotated, TypedDict +from typing import Union +from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict from .._types import SequenceNotStr from .._utils import PropertyInfo -__all__ = ["ChatCreateParams", "User"] +__all__ = ["ChatCreateParams", "Params", "ParamsUnionMember0", "ParamsUnionMember0User", "ParamsUnionMember1"] class ChatCreateParams(TypedDict, total=False): + params: Params + + +class ParamsUnionMember0User(TypedDict, total=False): + """Merged user-like contact payload used to resolve the best identifier.""" + + id: str + """Known user ID when available.""" + + email: str + """Email candidate.""" + + full_name: Annotated[str, PropertyInfo(alias="fullName")] + """Display name hint used for ranking only.""" + + phone_number: Annotated[str, PropertyInfo(alias="phoneNumber")] + """Phone number candidate (E.164 preferred).""" + + username: str + """Username/handle candidate.""" + + +class ParamsUnionMember0(TypedDict, total=False): account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] """Account to create or start the chat on.""" + mode: Required[Literal["start"]] + """Operation mode. Use 'start' to resolve a user/contact and start a direct chat.""" + + user: Required[ParamsUnionMember0User] + """Merged user-like contact payload used to resolve the best identifier.""" + allow_invite: Annotated[bool, PropertyInfo(alias="allowInvite")] """Whether invite-based DM creation is allowed when required by the platform. @@ -23,49 +53,28 @@ class ChatCreateParams(TypedDict, total=False): message_text: Annotated[str, PropertyInfo(alias="messageText")] """Optional first message content if the platform requires it to create the chat.""" - mode: Literal["create", "start"] - """Operation mode. Defaults to 'create' when omitted.""" - - participant_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")] - """Required when mode='create'. User IDs to include in the new chat.""" - title: str - """ - Optional title for group chats when mode='create'; ignored for single chats on - most platforms. - """ +class ParamsUnionMember1(TypedDict, total=False): + account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] + """Account to create or start the chat on.""" - type: Literal["single", "group"] - """Required when mode='create'. + participant_ids: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")]] + """User IDs to include in the new chat.""" + type: Required[Literal["single", "group"]] + """ 'single' requires exactly one participantID; 'group' supports multiple participants and optional title. """ - user: User - """Required when mode='start'. - - Merged user-like contact payload used to resolve the best identifier. - """ - - -class User(TypedDict, total=False): - """Required when mode='start'. - - Merged user-like contact payload used to resolve the best identifier. - """ - - id: str - """Known user ID when available.""" + message_text: Annotated[str, PropertyInfo(alias="messageText")] + """Optional first message content if the platform requires it to create the chat.""" - email: str - """Email candidate.""" + mode: Literal["create"] + """Operation mode. Defaults to 'create' when omitted.""" - full_name: Annotated[str, PropertyInfo(alias="fullName")] - """Display name hint used for ranking only.""" + title: str + """Optional title for group chats; ignored for single chats on most platforms.""" - phone_number: Annotated[str, PropertyInfo(alias="phoneNumber")] - """Phone number candidate (E.164 preferred).""" - username: str - """Username/handle candidate.""" +Params: TypeAlias = Union[ParamsUnionMember0, ParamsUnionMember1] diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index b899add..10de8b2 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -25,36 +25,31 @@ class TestChats: @parametrize def test_method_create(self, client: BeeperDesktop) -> None: - chat = client.chats.create( - account_id="accountID", - ) + chat = client.chats.create() assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: chat = client.chats.create( - account_id="accountID", - allow_invite=True, - message_text="messageText", - mode="create", - participant_ids=["string"], - title="title", - type="single", - user={ - "id": "id", - "email": "email", - "full_name": "fullName", - "phone_number": "phoneNumber", - "username": "username", + params={ + "account_id": "accountID", + "mode": "start", + "user": { + "id": "id", + "email": "email", + "full_name": "fullName", + "phone_number": "phoneNumber", + "username": "username", + }, + "allow_invite": True, + "message_text": "messageText", }, ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize def test_raw_response_create(self, client: BeeperDesktop) -> None: - response = client.chats.with_raw_response.create( - account_id="accountID", - ) + response = client.chats.with_raw_response.create() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -63,9 +58,7 @@ def test_raw_response_create(self, client: BeeperDesktop) -> None: @parametrize def test_streaming_response_create(self, client: BeeperDesktop) -> None: - with client.chats.with_streaming_response.create( - account_id="accountID", - ) as response: + with client.chats.with_streaming_response.create() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -257,36 +250,31 @@ class TestAsyncChats: @parametrize async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.create( - account_id="accountID", - ) + chat = await async_client.chats.create() assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.create( - account_id="accountID", - allow_invite=True, - message_text="messageText", - mode="create", - participant_ids=["string"], - title="title", - type="single", - user={ - "id": "id", - "email": "email", - "full_name": "fullName", - "phone_number": "phoneNumber", - "username": "username", + params={ + "account_id": "accountID", + "mode": "start", + "user": { + "id": "id", + "email": "email", + "full_name": "fullName", + "phone_number": "phoneNumber", + "username": "username", + }, + "allow_invite": True, + "message_text": "messageText", }, ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.chats.with_raw_response.create( - account_id="accountID", - ) + response = await async_client.chats.with_raw_response.create() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -295,9 +283,7 @@ async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> No @parametrize async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.chats.with_streaming_response.create( - account_id="accountID", - ) as response: + async with async_client.chats.with_streaming_response.create() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" From 7addb88adf0574857ce646e8a9f15e8eb035a48a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:32:36 +0000 Subject: [PATCH 36/47] perf(client): optimize file structure copying in multipart requests --- src/beeper_desktop_api/_files.py | 56 +++++++++++- src/beeper_desktop_api/_utils/__init__.py | 1 - src/beeper_desktop_api/_utils/_utils.py | 15 ---- src/beeper_desktop_api/resources/assets.py | 13 +-- tests/test_deepcopy.py | 58 ------------- tests/test_files.py | 99 +++++++++++++++++++++- 6 files changed, 159 insertions(+), 83 deletions(-) delete mode 100644 tests/test_deepcopy.py diff --git a/src/beeper_desktop_api/_files.py b/src/beeper_desktop_api/_files.py index e0ef7aa..8a371d3 100644 --- a/src/beeper_desktop_api/_files.py +++ b/src/beeper_desktop_api/_files.py @@ -3,8 +3,8 @@ import io import os import pathlib -from typing import overload -from typing_extensions import TypeGuard +from typing import Sequence, cast, overload +from typing_extensions import TypeVar, TypeGuard import anyio @@ -17,7 +17,9 @@ HttpxFileContent, HttpxRequestFiles, ) -from ._utils import is_tuple_t, is_mapping_t, is_sequence_t +from ._utils import is_list, is_mapping, is_tuple_t, is_mapping_t, is_sequence_t + +_T = TypeVar("_T") def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: @@ -121,3 +123,51 @@ async def async_read_file_content(file: FileContent) -> HttpxFileContent: return await anyio.Path(file).read_bytes() return file + + +def deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]]) -> _T: + """Copy only the containers along the given paths. + + Used to guard against mutation by extract_files without copying the entire structure. + Only dicts and lists that lie on a path are copied; everything else + is returned by reference. + + For example, given paths=[["foo", "files", "file"]] and the structure: + { + "foo": { + "bar": {"baz": {}}, + "files": {"file": } + } + } + The root dict, "foo", and "files" are copied (they lie on the path). + "bar" and "baz" are returned by reference (off the path). + """ + return _deepcopy_with_paths(item, paths, 0) + + +def _deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]], index: int) -> _T: + if not paths: + return item + if is_mapping(item): + key_to_paths: dict[str, list[Sequence[str]]] = {} + for path in paths: + if index < len(path): + key_to_paths.setdefault(path[index], []).append(path) + + # if no path continues through this mapping, it won't be mutated and copying it is redundant + if not key_to_paths: + return item + + result = dict(item) + for key, subpaths in key_to_paths.items(): + if key in result: + result[key] = _deepcopy_with_paths(result[key], subpaths, index + 1) + return cast(_T, result) + if is_list(item): + array_paths = [path for path in paths if index < len(path) and path[index] == ""] + + # if no path expects a list here, nothing will be mutated inside it - return by reference + if not array_paths: + return cast(_T, item) + return cast(_T, [_deepcopy_with_paths(entry, array_paths, index + 1) for entry in item]) + return item diff --git a/src/beeper_desktop_api/_utils/__init__.py b/src/beeper_desktop_api/_utils/__init__.py index 10cb66d..1c090e5 100644 --- a/src/beeper_desktop_api/_utils/__init__.py +++ b/src/beeper_desktop_api/_utils/__init__.py @@ -24,7 +24,6 @@ coerce_integer as coerce_integer, file_from_path as file_from_path, strip_not_given as strip_not_given, - deepcopy_minimal as deepcopy_minimal, get_async_library as get_async_library, maybe_coerce_float as maybe_coerce_float, get_required_header as get_required_header, diff --git a/src/beeper_desktop_api/_utils/_utils.py b/src/beeper_desktop_api/_utils/_utils.py index 63b8cd6..771859f 100644 --- a/src/beeper_desktop_api/_utils/_utils.py +++ b/src/beeper_desktop_api/_utils/_utils.py @@ -177,21 +177,6 @@ def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: return isinstance(obj, Iterable) -def deepcopy_minimal(item: _T) -> _T: - """Minimal reimplementation of copy.deepcopy() that will only copy certain object types: - - - mappings, e.g. `dict` - - list - - This is done for performance reasons. - """ - if is_mapping(item): - return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) - if is_list(item): - return cast(_T, [deepcopy_minimal(entry) for entry in item]) - return item - - # copied from https://github.com/Rapptz/RoboDanny def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: size = len(seq) diff --git a/src/beeper_desktop_api/resources/assets.py b/src/beeper_desktop_api/resources/assets.py index db5dce4..652dcd5 100644 --- a/src/beeper_desktop_api/resources/assets.py +++ b/src/beeper_desktop_api/resources/assets.py @@ -7,8 +7,9 @@ import httpx from ..types import asset_serve_params, asset_upload_params, asset_download_params, asset_upload_base64_params +from .._files import deepcopy_with_paths from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given -from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform +from .._utils import extract_files, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -155,12 +156,13 @@ def upload( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "file": file, "file_name": file_name, "mime_type": mime_type, - } + }, + [["file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be @@ -358,12 +360,13 @@ async def upload( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "file": file, "file_name": file_name, "mime_type": mime_type, - } + }, + [["file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py deleted file mode 100644 index 6288bda..0000000 --- a/tests/test_deepcopy.py +++ /dev/null @@ -1,58 +0,0 @@ -from beeper_desktop_api._utils import deepcopy_minimal - - -def assert_different_identities(obj1: object, obj2: object) -> None: - assert obj1 == obj2 - assert id(obj1) != id(obj2) - - -def test_simple_dict() -> None: - obj1 = {"foo": "bar"} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_dict() -> None: - obj1 = {"foo": {"bar": True}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - - -def test_complex_nested_dict() -> None: - obj1 = {"foo": {"bar": [{"hello": "world"}]}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) - assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) - - -def test_simple_list() -> None: - obj1 = ["a", "b", "c"] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_list() -> None: - obj1 = ["a", [1, 2, 3]] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1[1], obj2[1]) - - -class MyObject: ... - - -def test_ignores_other_types() -> None: - # custom classes - my_obj = MyObject() - obj1 = {"foo": my_obj} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert obj1["foo"] is my_obj - - # tuples - obj3 = ("a", "b") - obj4 = deepcopy_minimal(obj3) - assert obj3 is obj4 diff --git a/tests/test_files.py b/tests/test_files.py index 76be5ce..310e532 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -4,7 +4,8 @@ import pytest from dirty_equals import IsDict, IsList, IsBytes, IsTuple -from beeper_desktop_api._files import to_httpx_files, async_to_httpx_files +from beeper_desktop_api._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files +from beeper_desktop_api._utils import extract_files readme_path = Path(__file__).parent.parent.joinpath("README.md") @@ -49,3 +50,99 @@ def test_string_not_allowed() -> None: "file": "foo", # type: ignore } ) + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert obj1 is not obj2 + + +class TestDeepcopyWithPaths: + def test_copies_top_level_dict(self) -> None: + original = {"file": b"data", "other": "value"} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + + def test_file_value_is_same_reference(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + assert result["file"] is file_bytes + + def test_list_popped_wholesale(self) -> None: + files = [b"f1", b"f2"] + original = {"files": files, "title": "t"} + result = deepcopy_with_paths(original, [["files", ""]]) + assert_different_identities(result, original) + result_files = result["files"] + assert isinstance(result_files, list) + assert_different_identities(result_files, files) + + def test_nested_array_path_copies_list_and_elements(self) -> None: + elem1 = {"file": b"f1", "extra": 1} + elem2 = {"file": b"f2", "extra": 2} + original = {"items": [elem1, elem2]} + result = deepcopy_with_paths(original, [["items", "", "file"]]) + assert_different_identities(result, original) + result_items = result["items"] + assert isinstance(result_items, list) + assert_different_identities(result_items, original["items"]) + assert_different_identities(result_items[0], elem1) + assert_different_identities(result_items[1], elem2) + + def test_empty_paths_returns_same_object(self) -> None: + original = {"foo": "bar"} + result = deepcopy_with_paths(original, []) + assert result is original + + def test_multiple_paths(self) -> None: + f1 = b"file1" + f2 = b"file2" + original = {"a": f1, "b": f2, "c": "unchanged"} + result = deepcopy_with_paths(original, [["a"], ["b"]]) + assert_different_identities(result, original) + assert result["a"] is f1 + assert result["b"] is f2 + assert result["c"] is original["c"] + + def test_extract_files_does_not_mutate_original_top_level(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes, "other": "value"} + + copied = deepcopy_with_paths(original, [["file"]]) + extracted = extract_files(copied, paths=[["file"]]) + + assert extracted == [("file", file_bytes)] + assert original == {"file": file_bytes, "other": "value"} + assert copied == {"other": "value"} + + def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: + file1 = b"f1" + file2 = b"f2" + original = { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + + copied = deepcopy_with_paths(original, [["items", "", "file"]]) + extracted = extract_files(copied, paths=[["items", "", "file"]]) + + assert extracted == [("items[][file]", file1), ("items[][file]", file2)] + assert original == { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + assert copied == { + "items": [ + {"extra": 1}, + {"extra": 2}, + ], + "title": "example", + } From 29127f7697a10191b913f86e8664321963b55003 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:34:27 +0000 Subject: [PATCH 37/47] chore(tests): bump steady to v0.22.1 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 7c58865..9c7c439 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.20.2 -- steady --version + npm exec --package=@stdy/cli@0.22.1 -- steady --version - npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 87cdeac..0159035 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.2 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From ed8c2c499c99ac2f1a4e83054ae0000cb9e12c47 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:11:43 +0000 Subject: [PATCH 38/47] chore(internal): more robust bootstrap script --- scripts/bootstrap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bootstrap b/scripts/bootstrap index b430fee..fe8451e 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { echo -n "==> Install Homebrew dependencies? (y/N): " read -r response From d086e7f0ff86653ace4d1f21c5daf1d6605b3369 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 02:06:13 +0000 Subject: [PATCH 39/47] fix: use correct field name format for multipart file arrays --- src/beeper_desktop_api/_qs.py | 8 ++--- src/beeper_desktop_api/_types.py | 3 ++ src/beeper_desktop_api/_utils/_utils.py | 42 ++++++++++++++++++++----- tests/test_extract_files.py | 28 ++++++++++++++--- tests/test_files.py | 2 +- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/beeper_desktop_api/_qs.py b/src/beeper_desktop_api/_qs.py index de8c99b..4127c19 100644 --- a/src/beeper_desktop_api/_qs.py +++ b/src/beeper_desktop_api/_qs.py @@ -2,17 +2,13 @@ from typing import Any, List, Tuple, Union, Mapping, TypeVar from urllib.parse import parse_qs, urlencode -from typing_extensions import Literal, get_args +from typing_extensions import get_args -from ._types import NotGiven, not_given +from ._types import NotGiven, ArrayFormat, NestedFormat, not_given from ._utils import flatten _T = TypeVar("_T") - -ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] -NestedFormat = Literal["dots", "brackets"] - PrimitiveData = Union[str, int, float, bool, None] # this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"] # https://github.com/microsoft/pyright/issues/3555 diff --git a/src/beeper_desktop_api/_types.py b/src/beeper_desktop_api/_types.py index 2880d78..3c1fd72 100644 --- a/src/beeper_desktop_api/_types.py +++ b/src/beeper_desktop_api/_types.py @@ -47,6 +47,9 @@ ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) _T = TypeVar("_T") +ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] +NestedFormat = Literal["dots", "brackets"] + # Approximates httpx internal ProxiesTypes and RequestFiles types # while adding support for `PathLike` instances diff --git a/src/beeper_desktop_api/_utils/_utils.py b/src/beeper_desktop_api/_utils/_utils.py index 771859f..199cd23 100644 --- a/src/beeper_desktop_api/_utils/_utils.py +++ b/src/beeper_desktop_api/_utils/_utils.py @@ -17,11 +17,11 @@ ) from pathlib import Path from datetime import date, datetime -from typing_extensions import TypeGuard +from typing_extensions import TypeGuard, get_args import sniffio -from .._types import Omit, NotGiven, FileTypes, HeadersLike +from .._types import Omit, NotGiven, FileTypes, ArrayFormat, HeadersLike _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) @@ -40,25 +40,45 @@ def extract_files( query: Mapping[str, object], *, paths: Sequence[Sequence[str]], + array_format: ArrayFormat = "brackets", ) -> list[tuple[str, FileTypes]]: """Recursively extract files from the given dictionary based on specified paths. A path may look like this ['foo', 'files', '', 'data']. + ``array_format`` controls how ```` segments contribute to the emitted + field name. Supported values: ``"brackets"`` (``foo[]``), ``"repeat"`` and + ``"comma"`` (``foo``), ``"indices"`` (``foo[0]``, ``foo[1]``). + Note: this mutates the given dictionary. """ files: list[tuple[str, FileTypes]] = [] for path in paths: - files.extend(_extract_items(query, path, index=0, flattened_key=None)) + files.extend(_extract_items(query, path, index=0, flattened_key=None, array_format=array_format)) return files +def _array_suffix(array_format: ArrayFormat, array_index: int) -> str: + if array_format == "brackets": + return "[]" + if array_format == "indices": + return f"[{array_index}]" + if array_format == "repeat" or array_format == "comma": + # Both repeat the bare field name for each file part; there is no + # meaningful way to comma-join binary parts. + return "" + raise NotImplementedError( + f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" + ) + + def _extract_items( obj: object, path: Sequence[str], *, index: int, flattened_key: str | None, + array_format: ArrayFormat, ) -> list[tuple[str, FileTypes]]: try: key = path[index] @@ -75,9 +95,11 @@ def _extract_items( if is_list(obj): files: list[tuple[str, FileTypes]] = [] - for entry in obj: - assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "") - files.append((flattened_key + "[]", cast(FileTypes, entry))) + for array_index, entry in enumerate(obj): + suffix = _array_suffix(array_format, array_index) + emitted_key = (flattened_key + suffix) if flattened_key else suffix + assert_is_file_content(entry, key=emitted_key) + files.append((emitted_key, cast(FileTypes, entry))) return files assert_is_file_content(obj, key=flattened_key) @@ -106,6 +128,7 @@ def _extract_items( path, index=index, flattened_key=flattened_key, + array_format=array_format, ) elif is_list(obj): if key != "": @@ -117,9 +140,12 @@ def _extract_items( item, path, index=index, - flattened_key=flattened_key + "[]" if flattened_key is not None else "[]", + flattened_key=( + (flattened_key if flattened_key is not None else "") + _array_suffix(array_format, array_index) + ), + array_format=array_format, ) - for item in obj + for array_index, item in enumerate(obj) ] ) diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index 7c7c5dd..889c22e 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -4,7 +4,7 @@ import pytest -from beeper_desktop_api._types import FileTypes +from beeper_desktop_api._types import FileTypes, ArrayFormat from beeper_desktop_api._utils import extract_files @@ -37,10 +37,7 @@ def test_multiple_files() -> None: 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 extract_files(query, paths=[["files", ""]]) == [("files[]", b"file one"), ("files[]", b"file two")] assert query == {"title": "hello"} @@ -71,3 +68,24 @@ def test_ignores_incorrect_paths( expected: list[tuple[str, FileTypes]], ) -> None: assert extract_files(query, paths=paths) == expected + + +@pytest.mark.parametrize( + "array_format,expected_top_level,expected_nested", + [ + ("brackets", [("files[]", b"a"), ("files[]", b"b")], [("items[][file]", b"a"), ("items[][file]", b"b")]), + ("repeat", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("comma", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("indices", [("files[0]", b"a"), ("files[1]", b"b")], [("items[0][file]", b"a"), ("items[1][file]", b"b")]), + ], +) +def test_array_format_controls_file_field_names( + array_format: ArrayFormat, + expected_top_level: list[tuple[str, FileTypes]], + expected_nested: list[tuple[str, FileTypes]], +) -> None: + top_level = {"files": [b"a", b"b"]} + assert extract_files(top_level, paths=[["files", ""]], array_format=array_format) == expected_top_level + + nested = {"items": [{"file": b"a"}, {"file": b"b"}]} + assert extract_files(nested, paths=[["items", "", "file"]], array_format=array_format) == expected_nested diff --git a/tests/test_files.py b/tests/test_files.py index 310e532..c7492da 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -131,7 +131,7 @@ def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: copied = deepcopy_with_paths(original, [["items", "", "file"]]) extracted = extract_files(copied, paths=[["items", "", "file"]]) - assert extracted == [("items[][file]", file1), ("items[][file]", file2)] + assert [entry for _, entry in extracted] == [file1, file2] assert original == { "items": [ {"file": file1, "extra": 1}, From 6841539c8619a507ec5e717d08a81837e83d76c2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 02:07:23 +0000 Subject: [PATCH 40/47] feat: support setting headers via env --- src/beeper_desktop_api/_client.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index 2f1306e..1f7d397 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -26,6 +26,7 @@ ) from ._utils import ( is_given, + is_mapping_t, maybe_transform, get_async_library, async_maybe_transform, @@ -113,6 +114,15 @@ def __init__( if base_url is None: base_url = f"http://localhost:23373" + custom_headers_env = os.environ.get("BEEPER_DESKTOP_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + super().__init__( version=__version__, base_url=base_url, @@ -408,6 +418,15 @@ def __init__( if base_url is None: base_url = f"http://localhost:23373" + custom_headers_env = os.environ.get("BEEPER_DESKTOP_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + super().__init__( version=__version__, base_url=base_url, From 4ff5262cbac0cd214a861f04c3159cd7236843af Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:50:37 +0000 Subject: [PATCH 41/47] Update Desktop API Stainless config and OpenAPI spec --- .github/workflows/detect-breaking-changes.yml | 42 ++++++++ .github/workflows/publish-pypi.yml | 2 +- .github/workflows/release-doctor.yml | 2 +- .stats.yml | 4 +- README.md | 13 +-- api.md | 4 +- pyproject.toml | 1 + requirements-dev.lock | 3 + scripts/detect-breaking-changes | 19 ++++ scripts/detect-breaking-changes.py | 79 ++++++++++++++ src/beeper_desktop_api/_base_client.py | 34 ++++-- src/beeper_desktop_api/_client.py | 27 +++-- src/beeper_desktop_api/_models.py | 6 ++ src/beeper_desktop_api/_types.py | 3 +- src/beeper_desktop_api/_utils/_logs.py | 2 +- src/beeper_desktop_api/pagination.py | 84 +-------------- src/beeper_desktop_api/resources/assets.py | 34 ++++-- .../resources/chats/chats.py | 102 +++++++++++++++--- .../resources/chats/messages/reactions.py | 4 +- src/beeper_desktop_api/resources/info.py | 12 ++- src/beeper_desktop_api/resources/messages.py | 14 +-- src/beeper_desktop_api/types/account.py | 22 ++-- .../types/chat_create_params.py | 89 ++++++++------- tests/api_resources/test_assets.py | 90 ++++++++++------ tests/api_resources/test_chats.py | 74 +++++++------ tests/api_resources/test_messages.py | 18 ++-- tests/test_client.py | 4 +- 27 files changed, 518 insertions(+), 270 deletions(-) create mode 100644 .github/workflows/detect-breaking-changes.yml create mode 100755 scripts/detect-breaking-changes create mode 100644 scripts/detect-breaking-changes.py diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml new file mode 100644 index 0000000..8514409 --- /dev/null +++ b/.github/workflows/detect-breaking-changes.yml @@ -0,0 +1,42 @@ +name: CI +on: + pull_request: + branches: + - main + - next + +jobs: + detect_breaking_changes: + runs-on: 'ubuntu-latest' + name: detect-breaking-changes + if: github.repository == 'beeper/desktop-api-python' + steps: + - name: Calculate fetch-depth + run: | + echo "FETCH_DEPTH=$(expr ${{ github.event.pull_request.commits }} + 1)" >> $GITHUB_ENV + + - uses: actions/checkout@v6 + with: + # Ensure we can check out the pull request base in the script below. + fetch-depth: ${{ env.FETCH_DEPTH }} + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + - name: Install dependencies + run: | + rye sync --all-features + - name: Detect removed symbols + run: | + rye run python scripts/detect-breaking-changes.py "${{ github.event.pull_request.base.sha }}" + + - name: Detect breaking changes + run: | + # Try to check out previous versions of the breaking change detection script. This ensures that + # we still detect breaking changes when entire files and their tests are removed. + git checkout "${{ github.event.pull_request.base.sha }}" -- ./scripts/detect-breaking-changes 2>/dev/null || true + ./scripts/detect-breaking-changes ${{ github.event.pull_request.base.sha }} \ No newline at end of file diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 08d08f6..54361b5 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -28,4 +28,4 @@ jobs: run: | bash ./bin/publish-pypi env: - PYPI_TOKEN: ${{ secrets.BEEPER_DESKTOP_PYPI_TOKEN || secrets.PYPI_TOKEN }} + PYPI_TOKEN: ${{ secrets.BEEPER_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index 4bccf2f..2d24407 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -18,4 +18,4 @@ jobs: run: | bash ./bin/check-release-environment env: - PYPI_TOKEN: ${{ secrets.BEEPER_DESKTOP_PYPI_TOKEN || secrets.PYPI_TOKEN }} + PYPI_TOKEN: ${{ secrets.BEEPER_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.stats.yml b/.stats.yml index 229f6b5..e925f68 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml -openapi_spec_hash: d6c0a1776048dab04f6c5625c9893c9c -config_hash: 39ed0717b5f415499aaace2468346e1a +openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef +config_hash: 05ebdec072113f63395372504da98192 diff --git a/README.md b/README.md index c0c9be9..bdee98a 100644 --- a/README.md +++ b/README.md @@ -218,10 +218,11 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() -client.chats.reminders.create( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, +chat = client.chats.create( + account_id="accountID", + user={}, ) +print(chat.user) ``` ## File uploads @@ -336,10 +337,10 @@ Note that requests that time out are [retried twice by default](#retries). We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. -You can enable logging by setting the environment variable `BEEPER_DESKTOP_LOG` to `info`. +You can enable logging by setting the environment variable `BEEPER_LOG` to `info`. ```shell -$ export BEEPER_DESKTOP_LOG=info +$ export BEEPER_LOG=info ``` Or to `debug` for more verbose logging. @@ -438,7 +439,7 @@ import httpx from beeper_desktop_api import BeeperDesktop, DefaultHttpxClient client = BeeperDesktop( - # Or use the `BEEPER_DESKTOP_BASE_URL` env var + # Or use the `BEEPER_BASE_URL` env var base_url="http://my.test.server.example.com:8083", http_client=DefaultHttpxClient( proxy="http://my.test.proxy.example.com", diff --git a/api.md b/api.md index 5efec0a..068f976 100644 --- a/api.md +++ b/api.md @@ -91,7 +91,7 @@ from beeper_desktop_api.types import MessageUpdateResponse, MessageSendResponse Methods: - client.messages.update(message_id, \*, chat_id, \*\*params) -> MessageUpdateResponse -- client.messages.list(chat_id, \*\*params) -> SyncCursorSortKey[Message] +- client.messages.list(chat_id, \*\*params) -> SyncCursorNoLimit[Message] - client.messages.search(\*\*params) -> SyncCursorSearch[Message] - client.messages.send(chat_id, \*\*params) -> MessageSendResponse @@ -110,7 +110,7 @@ from beeper_desktop_api.types import ( Methods: - client.assets.download(\*\*params) -> AssetDownloadResponse -- client.assets.serve(\*\*params) -> None +- client.assets.serve(\*\*params) -> BinaryAPIResponse - client.assets.upload(\*\*params) -> AssetUploadResponse - client.assets.upload_base64(\*\*params) -> AssetUploadBase64Response diff --git a/pyproject.toml b/pyproject.toml index 3f8161a..3a1513d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ dev-dependencies = [ "importlib-metadata>=6.7.0", "rich>=13.7.1", "pytest-xdist>=3.6.1", + "griffe>=1", ] [tool.rye.scripts] diff --git a/requirements-dev.lock b/requirements-dev.lock index 2fdb945..c74b1ba 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -34,6 +34,8 @@ backports-asyncio-runner==1.2.0 certifi==2026.1.4 # via httpcore # via httpx +colorama==0.4.6 + # via griffe colorlog==6.10.1 # via nox dependency-groups==1.3.1 @@ -53,6 +55,7 @@ filelock==3.19.1 frozenlist==1.8.0 # via aiohttp # via aiosignal +griffe==1.14.0 h11==0.16.0 # via httpcore httpcore==1.0.9 diff --git a/scripts/detect-breaking-changes b/scripts/detect-breaking-changes new file mode 100755 index 0000000..fb28f3a --- /dev/null +++ b/scripts/detect-breaking-changes @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Detecting breaking changes" + +TEST_PATHS=( tests/api_resources tests/test_client.py tests/test_response.py ) + +for PATHSPEC in "${TEST_PATHS[@]}"; do + # Try to check out previous versions of the test files + # with the current SDK. + git checkout "$1" -- "${PATHSPEC}" 2>/dev/null || true +done + +# Instead of running the tests, use the linter to check if an +# older test is no longer compatible with the latest SDK. +./scripts/lint diff --git a/scripts/detect-breaking-changes.py b/scripts/detect-breaking-changes.py new file mode 100644 index 0000000..c61e8ba --- /dev/null +++ b/scripts/detect-breaking-changes.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import sys +from typing import Iterator +from pathlib import Path + +import rich +import griffe +from rich.text import Text +from rich.style import Style + + +def public_members(obj: griffe.Object | griffe.Alias) -> dict[str, griffe.Object | griffe.Alias]: + if isinstance(obj, griffe.Alias): + # ignore imports for now, they're technically part of the public API + # but we don't have good preventative measures in place to prevent + # changing them + return {} + + return {name: value for name, value in obj.all_members.items() if not name.startswith("_")} + + +def find_breaking_changes( + new_obj: griffe.Object | griffe.Alias, + old_obj: griffe.Object | griffe.Alias, + *, + path: list[str], +) -> Iterator[Text | str]: + new_members = public_members(new_obj) + old_members = public_members(old_obj) + + for name, old_member in old_members.items(): + if isinstance(old_member, griffe.Alias) and len(path) > 2: + # ignore imports in `/types/` for now, they're technically part of the public API + # but we don't have good preventative measures in place to prevent changing them + continue + + new_member = new_members.get(name) + if new_member is None: + cls_name = old_member.__class__.__name__ + yield Text(f"({cls_name})", style=Style(color="rgb(119, 119, 119)")) + yield from [" " for _ in range(10 - len(cls_name))] + yield f" {'.'.join(path)}.{name}" + yield "\n" + continue + + yield from find_breaking_changes(new_member, old_member, path=[*path, name]) + + +def main() -> None: + try: + against_ref = sys.argv[1] + except IndexError as err: + raise RuntimeError("You must specify a base ref to run breaking change detection against") from err + + package = griffe.load( + "beeper_desktop_api", + search_paths=[Path(__file__).parent.parent.joinpath("src")], + ) + old_package = griffe.load_git( + "beeper_desktop_api", + ref=against_ref, + search_paths=["src"], + ) + assert isinstance(package, griffe.Module) + assert isinstance(old_package, griffe.Module) + + output = list(find_breaking_changes(package, old_package, path=["beeper_desktop_api"])) + if output: + rich.print(Text("Breaking changes detected!", style=Style(color="rgb(165, 79, 87)"))) + rich.print() + + for text in output: + rich.print(text, end="") + + sys.exit(1) + + +main() diff --git a/src/beeper_desktop_api/_base_client.py b/src/beeper_desktop_api/_base_client.py index 4e62b4b..5bce507 100644 --- a/src/beeper_desktop_api/_base_client.py +++ b/src/beeper_desktop_api/_base_client.py @@ -63,7 +63,7 @@ ) from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping from ._compat import PYDANTIC_V1, model_copy, model_dump -from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type +from ._models import GenericModel, SecurityOptions, FinalRequestOptions, validate_type, construct_type from ._response import ( APIResponse, BaseAPIResponse, @@ -432,9 +432,27 @@ def _make_status_error( ) -> _exceptions.APIStatusError: raise NotImplementedError() + def _auth_headers( + self, + security: SecurityOptions, # noqa: ARG002 + ) -> dict[str, str]: + return {} + + def _auth_query( + self, + security: SecurityOptions, # noqa: ARG002 + ) -> dict[str, str]: + return {} + + def _custom_auth( + self, + security: SecurityOptions, # noqa: ARG002 + ) -> httpx.Auth | None: + return None + def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0) -> httpx.Headers: custom_headers = options.headers or {} - headers_dict = _merge_mappings(self.default_headers, custom_headers) + headers_dict = _merge_mappings({**self._auth_headers(options.security), **self.default_headers}, custom_headers) self._validate_headers(headers_dict, custom_headers) # headers are case-insensitive while dictionaries are not. @@ -506,7 +524,7 @@ def _build_request( raise RuntimeError(f"Unexpected JSON data type, {type(json_data)}, cannot merge with `extra_body`") headers = self._build_headers(options, retries_taken=retries_taken) - params = _merge_mappings(self.default_query, options.params) + params = _merge_mappings({**self._auth_query(options.security), **self.default_query}, options.params) content_type = headers.get("Content-Type") files = options.files @@ -675,7 +693,6 @@ def default_headers(self) -> dict[str, str | Omit]: "Content-Type": "application/json", "User-Agent": self.user_agent, **self.platform_headers(), - **self.auth_headers, **self._custom_headers, } @@ -994,8 +1011,9 @@ def request( self._prepare_request(request) kwargs: HttpxSendArgs = {} - if self.custom_auth is not None: - kwargs["auth"] = self.custom_auth + custom_auth = self._custom_auth(options.security) + if custom_auth is not None: + kwargs["auth"] = custom_auth if options.follow_redirects is not None: kwargs["follow_redirects"] = options.follow_redirects @@ -1956,6 +1974,7 @@ def make_request_options( idempotency_key: str | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, post_parser: PostParser | NotGiven = not_given, + security: SecurityOptions | None = None, ) -> RequestOptions: """Create a dict of type RequestOptions without keys of NotGiven values.""" options: RequestOptions = {} @@ -1981,6 +2000,9 @@ def make_request_options( # internal options["post_parser"] = post_parser # type: ignore + if security is not None: + options["security"] = security + return options diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index 1f7d397..bf45e4d 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -32,6 +32,7 @@ async_maybe_transform, ) from ._compat import cached_property +from ._models import SecurityOptions from ._version import __version__ from ._response import ( to_raw_response_wrapper, @@ -110,11 +111,11 @@ def __init__( self.access_token = access_token if base_url is None: - base_url = os.environ.get("BEEPER_DESKTOP_BASE_URL") + base_url = os.environ.get("BEEPER_BASE_URL") if base_url is None: base_url = f"http://localhost:23373" - custom_headers_env = os.environ.get("BEEPER_DESKTOP_CUSTOM_HEADERS") + custom_headers_env = os.environ.get("BEEPER_CUSTOM_HEADERS") if custom_headers_env is not None: parsed: dict[str, str] = {} for line in custom_headers_env.split("\n"): @@ -182,9 +183,14 @@ def with_streaming_response(self) -> BeeperDesktopWithStreamedResponse: def qs(self) -> Querystring: return Querystring(array_format="repeat") - @property @override - def auth_headers(self) -> dict[str, str]: + def _auth_headers(self, security: SecurityOptions) -> dict[str, str]: + return { + **(self._bearer_auth if security.get("bearer_auth", False) else {}), + } + + @property + def _bearer_auth(self) -> dict[str, str]: access_token = self.access_token return {"Authorization": f"Bearer {access_token}"} @@ -414,11 +420,11 @@ def __init__( self.access_token = access_token if base_url is None: - base_url = os.environ.get("BEEPER_DESKTOP_BASE_URL") + base_url = os.environ.get("BEEPER_BASE_URL") if base_url is None: base_url = f"http://localhost:23373" - custom_headers_env = os.environ.get("BEEPER_DESKTOP_CUSTOM_HEADERS") + custom_headers_env = os.environ.get("BEEPER_CUSTOM_HEADERS") if custom_headers_env is not None: parsed: dict[str, str] = {} for line in custom_headers_env.split("\n"): @@ -486,9 +492,14 @@ def with_streaming_response(self) -> AsyncBeeperDesktopWithStreamedResponse: def qs(self) -> Querystring: return Querystring(array_format="repeat") - @property @override - def auth_headers(self) -> dict[str, str]: + def _auth_headers(self, security: SecurityOptions) -> dict[str, str]: + return { + **(self._bearer_auth if security.get("bearer_auth", False) else {}), + } + + @property + def _bearer_auth(self) -> dict[str, str]: access_token = self.access_token return {"Authorization": f"Bearer {access_token}"} diff --git a/src/beeper_desktop_api/_models.py b/src/beeper_desktop_api/_models.py index 29070e0..e22dd2a 100644 --- a/src/beeper_desktop_api/_models.py +++ b/src/beeper_desktop_api/_models.py @@ -791,6 +791,10 @@ def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]: return RootModel[type_] # type: ignore +class SecurityOptions(TypedDict, total=False): + bearer_auth: bool + + class FinalRequestOptionsInput(TypedDict, total=False): method: Required[str] url: Required[str] @@ -804,6 +808,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): json_data: Body extra_json: AnyMapping follow_redirects: bool + security: SecurityOptions @final @@ -818,6 +823,7 @@ class FinalRequestOptions(pydantic.BaseModel): idempotency_key: Union[str, None] = None post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() follow_redirects: Union[bool, None] = None + security: SecurityOptions = {"bearer_auth": True} 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/beeper_desktop_api/_types.py b/src/beeper_desktop_api/_types.py index 3c1fd72..a131d99 100644 --- a/src/beeper_desktop_api/_types.py +++ b/src/beeper_desktop_api/_types.py @@ -36,7 +36,7 @@ from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport if TYPE_CHECKING: - from ._models import BaseModel + from ._models import BaseModel, SecurityOptions from ._response import APIResponse, AsyncAPIResponse Transport = BaseTransport @@ -124,6 +124,7 @@ class RequestOptions(TypedDict, total=False): extra_json: AnyMapping idempotency_key: str follow_redirects: bool + security: SecurityOptions # Sentinel class used until PEP 0661 is accepted diff --git a/src/beeper_desktop_api/_utils/_logs.py b/src/beeper_desktop_api/_utils/_logs.py index da351d5..96d73d5 100644 --- a/src/beeper_desktop_api/_utils/_logs.py +++ b/src/beeper_desktop_api/_utils/_logs.py @@ -14,7 +14,7 @@ def _basic_config() -> None: def setup_logging() -> None: - env = os.environ.get("BEEPER_DESKTOP_LOG") + env = os.environ.get("BEEPER_LOG") if env == "debug": _basic_config() logger.setLevel(logging.DEBUG) diff --git a/src/beeper_desktop_api/pagination.py b/src/beeper_desktop_api/pagination.py index 03ecb2a..b3dc44c 100644 --- a/src/beeper_desktop_api/pagination.py +++ b/src/beeper_desktop_api/pagination.py @@ -1,29 +1,17 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Any, List, Generic, TypeVar, Optional, cast -from typing_extensions import Protocol, override, runtime_checkable +from typing import List, Generic, TypeVar, Optional +from typing_extensions import override from pydantic import Field as FieldInfo from ._base_client import BasePage, PageInfo, BaseSyncPage, BaseAsyncPage -__all__ = [ - "SyncCursorSearch", - "AsyncCursorSearch", - "SyncCursorNoLimit", - "AsyncCursorNoLimit", - "SyncCursorSortKey", - "AsyncCursorSortKey", -] +__all__ = ["SyncCursorSearch", "AsyncCursorSearch", "SyncCursorNoLimit", "AsyncCursorNoLimit"] _T = TypeVar("_T") -@runtime_checkable -class CursorSortKeyItem(Protocol): - sort_key: Optional[str] - - class SyncCursorSearch(BaseSyncPage[_T], BasePage[_T], Generic[_T]): items: List[_T] has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) @@ -142,69 +130,3 @@ def next_page_info(self) -> Optional[PageInfo]: return None return PageInfo(params={"cursor": oldest_cursor}) - - -class SyncCursorSortKey(BaseSyncPage[_T], BasePage[_T], Generic[_T]): - items: List[_T] - has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) - - @override - def _get_page_items(self) -> List[_T]: - items = self.items - if not items: - return [] - return items - - @override - def has_next_page(self) -> bool: - has_more = self.has_more - if has_more is not None and has_more is False: - return False - - return super().has_next_page() - - @override - def next_page_info(self) -> Optional[PageInfo]: - items = self.items - if not items: - return None - - item = cast(Any, items[-1]) - if not isinstance(item, CursorSortKeyItem) or item.sort_key is None: - # TODO emit warning log - return None - - return PageInfo(params={"cursor": item.sort_key}) - - -class AsyncCursorSortKey(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): - items: List[_T] - has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) - - @override - def _get_page_items(self) -> List[_T]: - items = self.items - if not items: - return [] - return items - - @override - def has_next_page(self) -> bool: - has_more = self.has_more - if has_more is not None and has_more is False: - return False - - return super().has_next_page() - - @override - def next_page_info(self) -> Optional[PageInfo]: - items = self.items - if not items: - return None - - item = cast(Any, items[-1]) - if not isinstance(item, CursorSortKeyItem) or item.sort_key is None: - # TODO emit warning log - return None - - return PageInfo(params={"cursor": item.sort_key}) diff --git a/src/beeper_desktop_api/resources/assets.py b/src/beeper_desktop_api/resources/assets.py index 652dcd5..dc85070 100644 --- a/src/beeper_desktop_api/resources/assets.py +++ b/src/beeper_desktop_api/resources/assets.py @@ -8,15 +8,23 @@ from ..types import asset_serve_params, asset_upload_params, asset_download_params, asset_upload_base64_params from .._files import deepcopy_with_paths -from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given +from .._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given from .._utils import extract_files, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, to_raw_response_wrapper, to_streamed_response_wrapper, async_to_raw_response_wrapper, + to_custom_raw_response_wrapper, async_to_streamed_response_wrapper, + to_custom_streamed_response_wrapper, + async_to_custom_raw_response_wrapper, + async_to_custom_streamed_response_wrapper, ) from .._base_client import make_request_options from ..types.asset_upload_response import AssetUploadResponse @@ -93,7 +101,7 @@ def serve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> BinaryAPIResponse: """Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if @@ -110,7 +118,7 @@ def serve( timeout: Override the client-level default timeout for this request, in seconds """ - extra_headers = {"Accept": "*/*", **(extra_headers or {})} + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} return self._get( "/v1/assets/serve", options=make_request_options( @@ -120,7 +128,7 @@ def serve( timeout=timeout, query=maybe_transform({"url": url}, asset_serve_params.AssetServeParams), ), - cast_to=NoneType, + cast_to=BinaryAPIResponse, ) def upload( @@ -297,7 +305,7 @@ async def serve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> AsyncBinaryAPIResponse: """Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if @@ -314,7 +322,7 @@ async def serve( timeout: Override the client-level default timeout for this request, in seconds """ - extra_headers = {"Accept": "*/*", **(extra_headers or {})} + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} return await self._get( "/v1/assets/serve", options=make_request_options( @@ -324,7 +332,7 @@ async def serve( timeout=timeout, query=await async_maybe_transform({"url": url}, asset_serve_params.AssetServeParams), ), - cast_to=NoneType, + cast_to=AsyncBinaryAPIResponse, ) async def upload( @@ -441,8 +449,9 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_raw_response_wrapper( assets.download, ) - self.serve = to_raw_response_wrapper( + self.serve = to_custom_raw_response_wrapper( assets.serve, + BinaryAPIResponse, ) self.upload = to_raw_response_wrapper( assets.upload, @@ -459,8 +468,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_raw_response_wrapper( assets.download, ) - self.serve = async_to_raw_response_wrapper( + self.serve = async_to_custom_raw_response_wrapper( assets.serve, + AsyncBinaryAPIResponse, ) self.upload = async_to_raw_response_wrapper( assets.upload, @@ -477,8 +487,9 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_streamed_response_wrapper( assets.download, ) - self.serve = to_streamed_response_wrapper( + self.serve = to_custom_streamed_response_wrapper( assets.serve, + StreamedBinaryAPIResponse, ) self.upload = to_streamed_response_wrapper( assets.upload, @@ -495,8 +506,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_streamed_response_wrapper( assets.download, ) - self.serve = async_to_streamed_response_wrapper( + self.serve = async_to_custom_streamed_response_wrapper( assets.serve, + AsyncStreamedBinaryAPIResponse, ) self.upload = async_to_streamed_response_wrapper( assets.upload, diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index 2a6a92c..318ebf3 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -79,7 +79,14 @@ def with_streaming_response(self) -> ChatsResourceWithStreamingResponse: def create( self, *, - params: chat_create_params.Params | Omit = omit, + account_id: str, + allow_invite: bool | Omit = omit, + message_text: str | Omit = omit, + mode: Literal["start", "create"] | Omit = omit, + participant_ids: SequenceNotStr[str] | Omit = omit, + title: str | Omit = omit, + type: Literal["single", "group"] | Omit = omit, + user: chat_create_params.User | 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, @@ -88,10 +95,31 @@ def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ChatCreateResponse: """ - Create a single/group chat (mode='create') or start a direct chat from merged - user data (mode='start'). + Create a direct or group chat with mode="create", or use mode="start" to resolve + a contact and open a direct chat. Args: + account_id: Account to create or start the chat on. + + allow_invite: Only used for mode='start'. Whether invite-based DM creation is allowed when + required by the platform. + + message_text: Optional first message content if the platform requires it to create the chat. + + mode: Operation mode. Use 'start' to resolve a user/contact and start a direct chat. + Omit or set 'create' to create a chat directly. + + participant_ids: Required for create mode. Provide exactly one user ID for 'single' chats and one + or more for 'group' chats. + + title: Optional title for group chats; ignored for single chats on most networks. + + type: Required for create mode. 'single' creates a direct message chat; 'group' + creates a group chat. + + user: Required for mode='start'. Merged user-like contact payload used to resolve the + best identifier. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -102,7 +130,19 @@ def create( """ return self._post( "/v1/chats", - body=maybe_transform(params, chat_create_params.ChatCreateParams), + body=maybe_transform( + { + "account_id": account_id, + "allow_invite": allow_invite, + "message_text": message_text, + "mode": mode, + "participant_ids": participant_ids, + "title": title, + "type": type, + "user": user, + }, + chat_create_params.ChatCreateParams, + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -273,8 +313,7 @@ def search( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SyncCursorSearch[Chat]: """ - Search chats by title/network or participants using Beeper Desktop's renderer - algorithm. + Search chats by title, network, or participant names. Args: account_ids: Provide an array of account IDs to filter chats from specific messaging accounts @@ -383,7 +422,14 @@ def with_streaming_response(self) -> AsyncChatsResourceWithStreamingResponse: async def create( self, *, - params: chat_create_params.Params | Omit = omit, + account_id: str, + allow_invite: bool | Omit = omit, + message_text: str | Omit = omit, + mode: Literal["start", "create"] | Omit = omit, + participant_ids: SequenceNotStr[str] | Omit = omit, + title: str | Omit = omit, + type: Literal["single", "group"] | Omit = omit, + user: chat_create_params.User | 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, @@ -392,10 +438,31 @@ async def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ChatCreateResponse: """ - Create a single/group chat (mode='create') or start a direct chat from merged - user data (mode='start'). + Create a direct or group chat with mode="create", or use mode="start" to resolve + a contact and open a direct chat. Args: + account_id: Account to create or start the chat on. + + allow_invite: Only used for mode='start'. Whether invite-based DM creation is allowed when + required by the platform. + + message_text: Optional first message content if the platform requires it to create the chat. + + mode: Operation mode. Use 'start' to resolve a user/contact and start a direct chat. + Omit or set 'create' to create a chat directly. + + participant_ids: Required for create mode. Provide exactly one user ID for 'single' chats and one + or more for 'group' chats. + + title: Optional title for group chats; ignored for single chats on most networks. + + type: Required for create mode. 'single' creates a direct message chat; 'group' + creates a group chat. + + user: Required for mode='start'. Merged user-like contact payload used to resolve the + best identifier. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -406,7 +473,19 @@ async def create( """ return await self._post( "/v1/chats", - body=await async_maybe_transform(params, chat_create_params.ChatCreateParams), + body=await async_maybe_transform( + { + "account_id": account_id, + "allow_invite": allow_invite, + "message_text": message_text, + "mode": mode, + "participant_ids": participant_ids, + "title": title, + "type": type, + "user": user, + }, + chat_create_params.ChatCreateParams, + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -577,8 +656,7 @@ def search( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AsyncPaginator[Chat, AsyncCursorSearch[Chat]]: """ - Search chats by title/network or participants using Beeper Desktop's renderer - algorithm. + Search chats by title, network, or participant names. Args: account_ids: Provide an array of account IDs to filter chats from specific messaging accounts diff --git a/src/beeper_desktop_api/resources/chats/messages/reactions.py b/src/beeper_desktop_api/resources/chats/messages/reactions.py index 13a855d..bdf93a5 100644 --- a/src/beeper_desktop_api/resources/chats/messages/reactions.py +++ b/src/beeper_desktop_api/resources/chats/messages/reactions.py @@ -58,7 +58,7 @@ def delete( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ReactionDeleteResponse: """ - Remove the authenticated user's reaction from an existing message. + Remove the reaction added by the authenticated user from an existing message. Args: chat_id: Unique identifier of the chat. @@ -181,7 +181,7 @@ async def delete( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ReactionDeleteResponse: """ - Remove the authenticated user's reaction from an existing message. + Remove the reaction added by the authenticated user from an existing message. Args: chat_id: Unique identifier of the chat. diff --git a/src/beeper_desktop_api/resources/info.py b/src/beeper_desktop_api/resources/info.py index 9b6bc94..2b33a3d 100644 --- a/src/beeper_desktop_api/resources/info.py +++ b/src/beeper_desktop_api/resources/info.py @@ -58,7 +58,11 @@ def retrieve( return self._get( "/v1/info", 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, + security={}, ), cast_to=InfoRetrieveResponse, ) @@ -103,7 +107,11 @@ async def retrieve( return await self._get( "/v1/info", 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, + security={}, ), cast_to=InfoRetrieveResponse, ) diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index af2178e..e0be745 100644 --- a/src/beeper_desktop_api/resources/messages.py +++ b/src/beeper_desktop_api/resources/messages.py @@ -19,7 +19,7 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from ..pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorSortKey, AsyncCursorSortKey +from ..pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorNoLimit, AsyncCursorNoLimit from .._base_client import AsyncPaginator, make_request_options from ..types.shared.message import Message from ..types.message_send_response import MessageSendResponse @@ -106,7 +106,7 @@ def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursorSortKey[Message]: + ) -> SyncCursorNoLimit[Message]: """List all messages in a chat with cursor-based pagination. Sorted by timestamp. @@ -131,7 +131,7 @@ def list( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return self._get_api_list( path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id), - page=SyncCursorSortKey[Message], + page=SyncCursorNoLimit[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -172,7 +172,7 @@ def search( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SyncCursorSearch[Message]: """ - Search messages across chats using Beeper's message index + Search messages across chats. Args: account_ids: Limit search to specific account IDs. @@ -382,7 +382,7 @@ def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[Message, AsyncCursorSortKey[Message]]: + ) -> AsyncPaginator[Message, AsyncCursorNoLimit[Message]]: """List all messages in a chat with cursor-based pagination. Sorted by timestamp. @@ -407,7 +407,7 @@ def list( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return self._get_api_list( path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id), - page=AsyncCursorSortKey[Message], + page=AsyncCursorNoLimit[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -448,7 +448,7 @@ def search( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AsyncPaginator[Message, AsyncCursorSearch[Message]]: """ - Search messages across chats using Beeper's message index + Search messages across chats. Args: account_ids: Limit search to specific account IDs. diff --git a/src/beeper_desktop_api/types/account.py b/src/beeper_desktop_api/types/account.py index c024419..bb569a7 100644 --- a/src/beeper_desktop_api/types/account.py +++ b/src/beeper_desktop_api/types/account.py @@ -1,5 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from typing import Optional from typing_extensions import Literal from pydantic import Field as FieldInfo @@ -11,29 +12,32 @@ class Bridge(BaseModel): - """Bridge metadata for the account. Available from Beeper Desktop v.4.2.719+.""" + """Bridge metadata for the account. Available in Beeper Desktop v4.2.789+.""" id: str - """Bridge instance identifier.""" + """Bridge instance identifier. Available in Beeper Desktop v4.2.789+.""" provider: Literal["cloud", "self-hosted", "local", "platform-sdk"] - """Bridge provider for the account.""" + """Bridge provider for the account. Available in Beeper Desktop v4.2.789+.""" type: str - """Bridge type.""" + """Bridge type. Available in Beeper Desktop v4.2.789+.""" class Account(BaseModel): - """A chat account added to Beeper""" + """A chat account added to Beeper.""" account_id: str = FieldInfo(alias="accountID") """Chat account added to Beeper. Use this to route account-scoped actions.""" bridge: Bridge - """Bridge metadata for the account. Available from Beeper Desktop v.4.2.719+.""" - - network: str - """Human-friendly network name for the account.""" + """Bridge metadata for the account. Available in Beeper Desktop v4.2.789+.""" user: User """User the account belongs to.""" + + network: Optional[str] = None + """Human-friendly network name for the account. + + Omitted when the network is unknown. + """ diff --git a/src/beeper_desktop_api/types/chat_create_params.py b/src/beeper_desktop_api/types/chat_create_params.py index f755b70..b63be4f 100644 --- a/src/beeper_desktop_api/types/chat_create_params.py +++ b/src/beeper_desktop_api/types/chat_create_params.py @@ -2,79 +2,74 @@ from __future__ import annotations -from typing import Union -from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict +from typing_extensions import Literal, Required, Annotated, TypedDict from .._types import SequenceNotStr from .._utils import PropertyInfo -__all__ = ["ChatCreateParams", "Params", "ParamsUnionMember0", "ParamsUnionMember0User", "ParamsUnionMember1"] +__all__ = ["ChatCreateParams", "User"] class ChatCreateParams(TypedDict, total=False): - params: Params - - -class ParamsUnionMember0User(TypedDict, total=False): - """Merged user-like contact payload used to resolve the best identifier.""" - - id: str - """Known user ID when available.""" + account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] + """Account to create or start the chat on.""" - email: str - """Email candidate.""" + allow_invite: Annotated[bool, PropertyInfo(alias="allowInvite")] + """Only used for mode='start'. - full_name: Annotated[str, PropertyInfo(alias="fullName")] - """Display name hint used for ranking only.""" + Whether invite-based DM creation is allowed when required by the platform. + """ - phone_number: Annotated[str, PropertyInfo(alias="phoneNumber")] - """Phone number candidate (E.164 preferred).""" + message_text: Annotated[str, PropertyInfo(alias="messageText")] + """Optional first message content if the platform requires it to create the chat.""" - username: str - """Username/handle candidate.""" + mode: Literal["start", "create"] + """Operation mode. + Use 'start' to resolve a user/contact and start a direct chat. Omit or set + 'create' to create a chat directly. + """ -class ParamsUnionMember0(TypedDict, total=False): - account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] - """Account to create or start the chat on.""" + participant_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")] + """Required for create mode. - mode: Required[Literal["start"]] - """Operation mode. Use 'start' to resolve a user/contact and start a direct chat.""" + Provide exactly one user ID for 'single' chats and one or more for 'group' + chats. + """ - user: Required[ParamsUnionMember0User] - """Merged user-like contact payload used to resolve the best identifier.""" + title: str + """Optional title for group chats; ignored for single chats on most networks.""" - allow_invite: Annotated[bool, PropertyInfo(alias="allowInvite")] - """Whether invite-based DM creation is allowed when required by the platform. + type: Literal["single", "group"] + """Required for create mode. - Used for mode='start'. + 'single' creates a direct message chat; 'group' creates a group chat. """ - message_text: Annotated[str, PropertyInfo(alias="messageText")] - """Optional first message content if the platform requires it to create the chat.""" + user: User + """Required for mode='start'. + Merged user-like contact payload used to resolve the best identifier. + """ -class ParamsUnionMember1(TypedDict, total=False): - account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] - """Account to create or start the chat on.""" - participant_ids: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")]] - """User IDs to include in the new chat.""" +class User(TypedDict, total=False): + """Required for mode='start'. - type: Required[Literal["single", "group"]] - """ - 'single' requires exactly one participantID; 'group' supports multiple - participants and optional title. + Merged user-like contact payload used to resolve the best identifier. """ - message_text: Annotated[str, PropertyInfo(alias="messageText")] - """Optional first message content if the platform requires it to create the chat.""" + id: str + """Known user ID when available.""" - mode: Literal["create"] - """Operation mode. Defaults to 'create' when omitted.""" + email: str + """Email candidate.""" - title: str - """Optional title for group chats; ignored for single chats on most platforms.""" + full_name: Annotated[str, PropertyInfo(alias="fullName")] + """Display name hint used for ranking only.""" + phone_number: Annotated[str, PropertyInfo(alias="phoneNumber")] + """Phone number candidate (E.164 preferred).""" -Params: TypeAlias = Union[ParamsUnionMember0, ParamsUnionMember1] + username: str + """Username/handle candidate.""" diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py index 16d9ffa..f63f7bb 100644 --- a/tests/api_resources/test_assets.py +++ b/tests/api_resources/test_assets.py @@ -5,7 +5,9 @@ import os from typing import Any, cast +import httpx import pytest +from respx import MockRouter from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop @@ -14,6 +16,12 @@ AssetDownloadResponse, AssetUploadBase64Response, ) +from beeper_desktop_api._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, +) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -53,35 +61,46 @@ def test_streaming_response_download(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True @parametrize - def test_method_serve(self, client: BeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + def test_method_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) asset = client.assets.serve( url="x", ) - assert asset is None + assert asset.is_closed + assert asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, BinaryAPIResponse) @parametrize - def test_raw_response_serve(self, client: BeeperDesktop) -> None: - response = client.assets.with_raw_response.serve( + @pytest.mark.respx(base_url=base_url) + def test_raw_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + asset = client.assets.with_raw_response.serve( url="x", ) - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - asset = response.parse() - assert asset is None + assert asset.is_closed is True + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" + assert asset.json() == {"foo": "bar"} + assert isinstance(asset, BinaryAPIResponse) @parametrize - def test_streaming_response_serve(self, client: BeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) with client.assets.with_streaming_response.serve( url="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ) as asset: + assert not asset.is_closed + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" - asset = response.parse() - assert asset is None + assert asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, StreamedBinaryAPIResponse) - assert cast(Any, response.is_closed) is True + assert cast(Any, asset.is_closed) is True @parametrize def test_method_upload(self, client: BeeperDesktop) -> None: @@ -201,35 +220,46 @@ async def test_streaming_response_download(self, async_client: AsyncBeeperDeskto assert cast(Any, response.is_closed) is True @parametrize - async def test_method_serve(self, async_client: AsyncBeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + async def test_method_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) asset = await async_client.assets.serve( url="x", ) - assert asset is None + assert asset.is_closed + assert await asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, AsyncBinaryAPIResponse) @parametrize - async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.assets.with_raw_response.serve( + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + asset = await async_client.assets.with_raw_response.serve( url="x", ) - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - asset = await response.parse() - assert asset is None + assert asset.is_closed is True + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" + assert await asset.json() == {"foo": "bar"} + assert isinstance(asset, AsyncBinaryAPIResponse) @parametrize - async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) async with async_client.assets.with_streaming_response.serve( url="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ) as asset: + assert not asset.is_closed + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" - asset = await response.parse() - assert asset is None + assert await asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, AsyncStreamedBinaryAPIResponse) - assert cast(Any, response.is_closed) is True + assert cast(Any, asset.is_closed) is True @parametrize async def test_method_upload(self, async_client: AsyncBeeperDesktop) -> None: diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index 10de8b2..f03276e 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -25,31 +25,36 @@ class TestChats: @parametrize def test_method_create(self, client: BeeperDesktop) -> None: - chat = client.chats.create() + chat = client.chats.create( + account_id="accountID", + ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: chat = client.chats.create( - params={ - "account_id": "accountID", - "mode": "start", - "user": { - "id": "id", - "email": "email", - "full_name": "fullName", - "phone_number": "phoneNumber", - "username": "username", - }, - "allow_invite": True, - "message_text": "messageText", + account_id="accountID", + allow_invite=True, + message_text="messageText", + mode="start", + participant_ids=["string"], + title="title", + type="single", + user={ + "id": "id", + "email": "email", + "full_name": "fullName", + "phone_number": "phoneNumber", + "username": "username", }, ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize def test_raw_response_create(self, client: BeeperDesktop) -> None: - response = client.chats.with_raw_response.create() + response = client.chats.with_raw_response.create( + account_id="accountID", + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -58,7 +63,9 @@ def test_raw_response_create(self, client: BeeperDesktop) -> None: @parametrize def test_streaming_response_create(self, client: BeeperDesktop) -> None: - with client.chats.with_streaming_response.create() as response: + with client.chats.with_streaming_response.create( + account_id="accountID", + ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -250,31 +257,36 @@ class TestAsyncChats: @parametrize async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.create() + chat = await async_client.chats.create( + account_id="accountID", + ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.create( - params={ - "account_id": "accountID", - "mode": "start", - "user": { - "id": "id", - "email": "email", - "full_name": "fullName", - "phone_number": "phoneNumber", - "username": "username", - }, - "allow_invite": True, - "message_text": "messageText", + account_id="accountID", + allow_invite=True, + message_text="messageText", + mode="start", + participant_ids=["string"], + title="title", + type="single", + user={ + "id": "id", + "email": "email", + "full_name": "fullName", + "phone_number": "phoneNumber", + "username": "username", }, ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.chats.with_raw_response.create() + response = await async_client.chats.with_raw_response.create( + account_id="accountID", + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -283,7 +295,9 @@ async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> No @parametrize async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.chats.with_streaming_response.create() as response: + async with async_client.chats.with_streaming_response.create( + account_id="accountID", + ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index a167221..fec66ee 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -14,7 +14,7 @@ MessageUpdateResponse, ) from beeper_desktop_api._utils import parse_datetime -from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorSortKey, AsyncCursorSortKey +from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorNoLimit, AsyncCursorNoLimit from beeper_desktop_api.types.shared import Message base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -81,7 +81,7 @@ def test_method_list(self, client: BeeperDesktop) -> None: message = client.messages.list( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(SyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(SyncCursorNoLimit[Message], message, path=["response"]) @parametrize def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: @@ -90,7 +90,7 @@ def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) - assert_matches_type(SyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(SyncCursorNoLimit[Message], message, path=["response"]) @parametrize def test_raw_response_list(self, client: BeeperDesktop) -> None: @@ -101,7 +101,7 @@ def test_raw_response_list(self, client: BeeperDesktop) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = response.parse() - assert_matches_type(SyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(SyncCursorNoLimit[Message], message, path=["response"]) @parametrize def test_streaming_response_list(self, client: BeeperDesktop) -> None: @@ -112,7 +112,7 @@ def test_streaming_response_list(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = response.parse() - assert_matches_type(SyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(SyncCursorNoLimit[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True @@ -292,7 +292,7 @@ async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.list( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(AsyncCursorNoLimit[Message], message, path=["response"]) @parametrize async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -301,7 +301,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncBeeperDeskto cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) - assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(AsyncCursorNoLimit[Message], message, path=["response"]) @parametrize async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -312,7 +312,7 @@ async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = await response.parse() - assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(AsyncCursorNoLimit[Message], message, path=["response"]) @parametrize async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -323,7 +323,7 @@ async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) - assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = await response.parse() - assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(AsyncCursorNoLimit[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/test_client.py b/tests/test_client.py index 0e4b49b..d9ab686 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -729,7 +729,7 @@ def test_base_url_setter(self) -> None: client.close() def test_base_url_env(self) -> None: - with update_env(BEEPER_DESKTOP_BASE_URL="http://localhost:5000/from/env"): + with update_env(BEEPER_BASE_URL="http://localhost:5000/from/env"): client = BeeperDesktop(access_token=access_token, _strict_response_validation=True) assert client.base_url == "http://localhost:5000/from/env/" @@ -1680,7 +1680,7 @@ async def test_base_url_setter(self) -> None: await client.close() async def test_base_url_env(self) -> None: - with update_env(BEEPER_DESKTOP_BASE_URL="http://localhost:5000/from/env"): + with update_env(BEEPER_BASE_URL="http://localhost:5000/from/env"): client = AsyncBeeperDesktop(access_token=access_token, _strict_response_validation=True) assert client.base_url == "http://localhost:5000/from/env/" From 8699254fe6877748254383480d1aa9341d79c030 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:53:18 +0000 Subject: [PATCH 42/47] Preserve asset serve SDK compatibility --- .stats.yml | 2 +- api.md | 2 +- src/beeper_desktop_api/resources/assets.py | 34 +++----- tests/api_resources/test_assets.py | 90 ++++++++-------------- 4 files changed, 43 insertions(+), 85 deletions(-) diff --git a/.stats.yml b/.stats.yml index e925f68..1d3cc36 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml -openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef +openapi_spec_hash: 8dff13848934c5b20f3804236e8286d3 config_hash: 05ebdec072113f63395372504da98192 diff --git a/api.md b/api.md index 068f976..c0ddbc2 100644 --- a/api.md +++ b/api.md @@ -110,7 +110,7 @@ from beeper_desktop_api.types import ( Methods: - client.assets.download(\*\*params) -> AssetDownloadResponse -- client.assets.serve(\*\*params) -> BinaryAPIResponse +- client.assets.serve(\*\*params) -> None - client.assets.upload(\*\*params) -> AssetUploadResponse - client.assets.upload_base64(\*\*params) -> AssetUploadBase64Response diff --git a/src/beeper_desktop_api/resources/assets.py b/src/beeper_desktop_api/resources/assets.py index dc85070..652dcd5 100644 --- a/src/beeper_desktop_api/resources/assets.py +++ b/src/beeper_desktop_api/resources/assets.py @@ -8,23 +8,15 @@ from ..types import asset_serve_params, asset_upload_params, asset_download_params, asset_upload_base64_params from .._files import deepcopy_with_paths -from .._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given from .._utils import extract_files, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( - BinaryAPIResponse, - AsyncBinaryAPIResponse, - StreamedBinaryAPIResponse, - AsyncStreamedBinaryAPIResponse, to_raw_response_wrapper, to_streamed_response_wrapper, async_to_raw_response_wrapper, - to_custom_raw_response_wrapper, async_to_streamed_response_wrapper, - to_custom_streamed_response_wrapper, - async_to_custom_raw_response_wrapper, - async_to_custom_streamed_response_wrapper, ) from .._base_client import make_request_options from ..types.asset_upload_response import AssetUploadResponse @@ -101,7 +93,7 @@ def serve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BinaryAPIResponse: + ) -> None: """Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if @@ -118,7 +110,7 @@ def serve( timeout: Override the client-level default timeout for this request, in seconds """ - extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} + extra_headers = {"Accept": "*/*", **(extra_headers or {})} return self._get( "/v1/assets/serve", options=make_request_options( @@ -128,7 +120,7 @@ def serve( timeout=timeout, query=maybe_transform({"url": url}, asset_serve_params.AssetServeParams), ), - cast_to=BinaryAPIResponse, + cast_to=NoneType, ) def upload( @@ -305,7 +297,7 @@ async def serve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncBinaryAPIResponse: + ) -> None: """Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if @@ -322,7 +314,7 @@ async def serve( timeout: Override the client-level default timeout for this request, in seconds """ - extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} + extra_headers = {"Accept": "*/*", **(extra_headers or {})} return await self._get( "/v1/assets/serve", options=make_request_options( @@ -332,7 +324,7 @@ async def serve( timeout=timeout, query=await async_maybe_transform({"url": url}, asset_serve_params.AssetServeParams), ), - cast_to=AsyncBinaryAPIResponse, + cast_to=NoneType, ) async def upload( @@ -449,9 +441,8 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_raw_response_wrapper( assets.download, ) - self.serve = to_custom_raw_response_wrapper( + self.serve = to_raw_response_wrapper( assets.serve, - BinaryAPIResponse, ) self.upload = to_raw_response_wrapper( assets.upload, @@ -468,9 +459,8 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_raw_response_wrapper( assets.download, ) - self.serve = async_to_custom_raw_response_wrapper( + self.serve = async_to_raw_response_wrapper( assets.serve, - AsyncBinaryAPIResponse, ) self.upload = async_to_raw_response_wrapper( assets.upload, @@ -487,9 +477,8 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_streamed_response_wrapper( assets.download, ) - self.serve = to_custom_streamed_response_wrapper( + self.serve = to_streamed_response_wrapper( assets.serve, - StreamedBinaryAPIResponse, ) self.upload = to_streamed_response_wrapper( assets.upload, @@ -506,9 +495,8 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_streamed_response_wrapper( assets.download, ) - self.serve = async_to_custom_streamed_response_wrapper( + self.serve = async_to_streamed_response_wrapper( assets.serve, - AsyncStreamedBinaryAPIResponse, ) self.upload = async_to_streamed_response_wrapper( assets.upload, diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py index f63f7bb..16d9ffa 100644 --- a/tests/api_resources/test_assets.py +++ b/tests/api_resources/test_assets.py @@ -5,9 +5,7 @@ import os from typing import Any, cast -import httpx import pytest -from respx import MockRouter from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop @@ -16,12 +14,6 @@ AssetDownloadResponse, AssetUploadBase64Response, ) -from beeper_desktop_api._response import ( - BinaryAPIResponse, - AsyncBinaryAPIResponse, - StreamedBinaryAPIResponse, - AsyncStreamedBinaryAPIResponse, -) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -61,46 +53,35 @@ def test_streaming_response_download(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True @parametrize - @pytest.mark.respx(base_url=base_url) - def test_method_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: - respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + def test_method_serve(self, client: BeeperDesktop) -> None: asset = client.assets.serve( url="x", ) - assert asset.is_closed - assert asset.json() == {"foo": "bar"} - assert cast(Any, asset.is_closed) is True - assert isinstance(asset, BinaryAPIResponse) + assert asset is None @parametrize - @pytest.mark.respx(base_url=base_url) - def test_raw_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: - respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - - asset = client.assets.with_raw_response.serve( + def test_raw_response_serve(self, client: BeeperDesktop) -> None: + response = client.assets.with_raw_response.serve( url="x", ) - assert asset.is_closed is True - assert asset.http_request.headers.get("X-Stainless-Lang") == "python" - assert asset.json() == {"foo": "bar"} - assert isinstance(asset, BinaryAPIResponse) + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + asset = response.parse() + assert asset is None @parametrize - @pytest.mark.respx(base_url=base_url) - def test_streaming_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: - respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + def test_streaming_response_serve(self, client: BeeperDesktop) -> None: with client.assets.with_streaming_response.serve( url="x", - ) as asset: - assert not asset.is_closed - assert asset.http_request.headers.get("X-Stainless-Lang") == "python" + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" - assert asset.json() == {"foo": "bar"} - assert cast(Any, asset.is_closed) is True - assert isinstance(asset, StreamedBinaryAPIResponse) + asset = response.parse() + assert asset is None - assert cast(Any, asset.is_closed) is True + assert cast(Any, response.is_closed) is True @parametrize def test_method_upload(self, client: BeeperDesktop) -> None: @@ -220,46 +201,35 @@ async def test_streaming_response_download(self, async_client: AsyncBeeperDeskto assert cast(Any, response.is_closed) is True @parametrize - @pytest.mark.respx(base_url=base_url) - async def test_method_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: - respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + async def test_method_serve(self, async_client: AsyncBeeperDesktop) -> None: asset = await async_client.assets.serve( url="x", ) - assert asset.is_closed - assert await asset.json() == {"foo": "bar"} - assert cast(Any, asset.is_closed) is True - assert isinstance(asset, AsyncBinaryAPIResponse) + assert asset is None @parametrize - @pytest.mark.respx(base_url=base_url) - async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: - respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - - asset = await async_client.assets.with_raw_response.serve( + async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.assets.with_raw_response.serve( url="x", ) - assert asset.is_closed is True - assert asset.http_request.headers.get("X-Stainless-Lang") == "python" - assert await asset.json() == {"foo": "bar"} - assert isinstance(asset, AsyncBinaryAPIResponse) + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + asset = await response.parse() + assert asset is None @parametrize - @pytest.mark.respx(base_url=base_url) - async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: - respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop) -> None: async with async_client.assets.with_streaming_response.serve( url="x", - ) as asset: - assert not asset.is_closed - assert asset.http_request.headers.get("X-Stainless-Lang") == "python" + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" - assert await asset.json() == {"foo": "bar"} - assert cast(Any, asset.is_closed) is True - assert isinstance(asset, AsyncStreamedBinaryAPIResponse) + asset = await response.parse() + assert asset is None - assert cast(Any, asset.is_closed) is True + assert cast(Any, response.is_closed) is True @parametrize async def test_method_upload(self, async_client: AsyncBeeperDesktop) -> None: From ae59b66aa21c0eabcd08c4f411b3be45fb3e652f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:54:22 +0000 Subject: [PATCH 43/47] Document asset serve stream response --- .stats.yml | 2 +- api.md | 2 +- src/beeper_desktop_api/resources/assets.py | 34 +++++--- tests/api_resources/test_assets.py | 90 ++++++++++++++-------- 4 files changed, 85 insertions(+), 43 deletions(-) diff --git a/.stats.yml b/.stats.yml index 1d3cc36..e925f68 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml -openapi_spec_hash: 8dff13848934c5b20f3804236e8286d3 +openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef config_hash: 05ebdec072113f63395372504da98192 diff --git a/api.md b/api.md index c0ddbc2..068f976 100644 --- a/api.md +++ b/api.md @@ -110,7 +110,7 @@ from beeper_desktop_api.types import ( Methods: - client.assets.download(\*\*params) -> AssetDownloadResponse -- client.assets.serve(\*\*params) -> None +- client.assets.serve(\*\*params) -> BinaryAPIResponse - client.assets.upload(\*\*params) -> AssetUploadResponse - client.assets.upload_base64(\*\*params) -> AssetUploadBase64Response diff --git a/src/beeper_desktop_api/resources/assets.py b/src/beeper_desktop_api/resources/assets.py index 652dcd5..dc85070 100644 --- a/src/beeper_desktop_api/resources/assets.py +++ b/src/beeper_desktop_api/resources/assets.py @@ -8,15 +8,23 @@ from ..types import asset_serve_params, asset_upload_params, asset_download_params, asset_upload_base64_params from .._files import deepcopy_with_paths -from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given +from .._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given from .._utils import extract_files, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, to_raw_response_wrapper, to_streamed_response_wrapper, async_to_raw_response_wrapper, + to_custom_raw_response_wrapper, async_to_streamed_response_wrapper, + to_custom_streamed_response_wrapper, + async_to_custom_raw_response_wrapper, + async_to_custom_streamed_response_wrapper, ) from .._base_client import make_request_options from ..types.asset_upload_response import AssetUploadResponse @@ -93,7 +101,7 @@ def serve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> BinaryAPIResponse: """Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if @@ -110,7 +118,7 @@ def serve( timeout: Override the client-level default timeout for this request, in seconds """ - extra_headers = {"Accept": "*/*", **(extra_headers or {})} + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} return self._get( "/v1/assets/serve", options=make_request_options( @@ -120,7 +128,7 @@ def serve( timeout=timeout, query=maybe_transform({"url": url}, asset_serve_params.AssetServeParams), ), - cast_to=NoneType, + cast_to=BinaryAPIResponse, ) def upload( @@ -297,7 +305,7 @@ async def serve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> AsyncBinaryAPIResponse: """Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if @@ -314,7 +322,7 @@ async def serve( timeout: Override the client-level default timeout for this request, in seconds """ - extra_headers = {"Accept": "*/*", **(extra_headers or {})} + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} return await self._get( "/v1/assets/serve", options=make_request_options( @@ -324,7 +332,7 @@ async def serve( timeout=timeout, query=await async_maybe_transform({"url": url}, asset_serve_params.AssetServeParams), ), - cast_to=NoneType, + cast_to=AsyncBinaryAPIResponse, ) async def upload( @@ -441,8 +449,9 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_raw_response_wrapper( assets.download, ) - self.serve = to_raw_response_wrapper( + self.serve = to_custom_raw_response_wrapper( assets.serve, + BinaryAPIResponse, ) self.upload = to_raw_response_wrapper( assets.upload, @@ -459,8 +468,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_raw_response_wrapper( assets.download, ) - self.serve = async_to_raw_response_wrapper( + self.serve = async_to_custom_raw_response_wrapper( assets.serve, + AsyncBinaryAPIResponse, ) self.upload = async_to_raw_response_wrapper( assets.upload, @@ -477,8 +487,9 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_streamed_response_wrapper( assets.download, ) - self.serve = to_streamed_response_wrapper( + self.serve = to_custom_streamed_response_wrapper( assets.serve, + StreamedBinaryAPIResponse, ) self.upload = to_streamed_response_wrapper( assets.upload, @@ -495,8 +506,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_streamed_response_wrapper( assets.download, ) - self.serve = async_to_streamed_response_wrapper( + self.serve = async_to_custom_streamed_response_wrapper( assets.serve, + AsyncStreamedBinaryAPIResponse, ) self.upload = async_to_streamed_response_wrapper( assets.upload, diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py index 16d9ffa..f63f7bb 100644 --- a/tests/api_resources/test_assets.py +++ b/tests/api_resources/test_assets.py @@ -5,7 +5,9 @@ import os from typing import Any, cast +import httpx import pytest +from respx import MockRouter from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop @@ -14,6 +16,12 @@ AssetDownloadResponse, AssetUploadBase64Response, ) +from beeper_desktop_api._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, +) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -53,35 +61,46 @@ def test_streaming_response_download(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True @parametrize - def test_method_serve(self, client: BeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + def test_method_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) asset = client.assets.serve( url="x", ) - assert asset is None + assert asset.is_closed + assert asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, BinaryAPIResponse) @parametrize - def test_raw_response_serve(self, client: BeeperDesktop) -> None: - response = client.assets.with_raw_response.serve( + @pytest.mark.respx(base_url=base_url) + def test_raw_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + asset = client.assets.with_raw_response.serve( url="x", ) - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - asset = response.parse() - assert asset is None + assert asset.is_closed is True + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" + assert asset.json() == {"foo": "bar"} + assert isinstance(asset, BinaryAPIResponse) @parametrize - def test_streaming_response_serve(self, client: BeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) with client.assets.with_streaming_response.serve( url="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ) as asset: + assert not asset.is_closed + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" - asset = response.parse() - assert asset is None + assert asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, StreamedBinaryAPIResponse) - assert cast(Any, response.is_closed) is True + assert cast(Any, asset.is_closed) is True @parametrize def test_method_upload(self, client: BeeperDesktop) -> None: @@ -201,35 +220,46 @@ async def test_streaming_response_download(self, async_client: AsyncBeeperDeskto assert cast(Any, response.is_closed) is True @parametrize - async def test_method_serve(self, async_client: AsyncBeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + async def test_method_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) asset = await async_client.assets.serve( url="x", ) - assert asset is None + assert asset.is_closed + assert await asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, AsyncBinaryAPIResponse) @parametrize - async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.assets.with_raw_response.serve( + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + asset = await async_client.assets.with_raw_response.serve( url="x", ) - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - asset = await response.parse() - assert asset is None + assert asset.is_closed is True + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" + assert await asset.json() == {"foo": "bar"} + assert isinstance(asset, AsyncBinaryAPIResponse) @parametrize - async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) async with async_client.assets.with_streaming_response.serve( url="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ) as asset: + assert not asset.is_closed + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" - asset = await response.parse() - assert asset is None + assert await asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, AsyncStreamedBinaryAPIResponse) - assert cast(Any, response.is_closed) is True + assert cast(Any, asset.is_closed) is True @parametrize async def test_method_upload(self, async_client: AsyncBeeperDesktop) -> None: From 8cdbacbf46b9986fc1d0fdca8b542a746b0133e9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:52:44 +0000 Subject: [PATCH 44/47] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index e925f68..a2edbe5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-fdefa92da44ff91a34438591d022c182a83d9daf9a28b452556e325c6ef7b3f0.yml openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef config_hash: 05ebdec072113f63395372504da98192 From 407623a59e11cf3576153511a20293cc9ca91ba7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 03:38:38 +0000 Subject: [PATCH 45/47] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index a2edbe5..ec75571 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-fdefa92da44ff91a34438591d022c182a83d9daf9a28b452556e325c6ef7b3f0.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-356444646dafe352d3ef7c2e01aedf030197a5519b41cf2c3fd8be2571456b43.yml openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef config_hash: 05ebdec072113f63395372504da98192 From f11e2a6c2e25d78bd95e0a81ce52e814dc2a9e79 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 03:42:40 +0000 Subject: [PATCH 46/47] chore(internal): reformat pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3a1513d..7cbdc0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -169,7 +169,7 @@ show_error_codes = true # # We also exclude our `tests` as mypy doesn't always infer # types correctly and Pyright will still catch any type errors. -exclude = ['src/beeper_desktop_api/_files.py', '_dev/.*.py', 'tests/.*'] +exclude = ["src/beeper_desktop_api/_files.py", "_dev/.*.py", "tests/.*"] strict_equality = true implicit_reexport = true From 23f1ea2d87a46110753e84bea8d92ce30ab2d8a4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 03:43:08 +0000 Subject: [PATCH 47/47] release: 4.4.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 59 ++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/beeper_desktop_api/_version.py | 2 +- 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 29102ae..934f2cc 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "4.3.0" + ".": "4.4.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ee4c9..36a4dd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,64 @@ # Changelog +## 4.4.0 (2026-05-01) + +Full Changelog: [v4.3.0...v4.4.0](https://github.com/beeper/desktop-api-python/compare/v4.3.0...v4.4.0) + +### Features + +* **api:** add network, bridge fields to accounts ([af70fc9](https://github.com/beeper/desktop-api-python/commit/af70fc9fab45036721b4be634bb4444964c70d1e)) +* **api:** api update ([770a8e2](https://github.com/beeper/desktop-api-python/commit/770a8e2a6fc4d96dae58b3b787d55072faf63e34)) +* **api:** manual updates ([c84dca5](https://github.com/beeper/desktop-api-python/commit/c84dca576d56b83e314ab798749607e70aea7223)) +* **internal:** implement indices array format for query and form serialization ([de85c3a](https://github.com/beeper/desktop-api-python/commit/de85c3aef481f44350bab667ed81e155573ace81)) +* support setting headers via env ([6841539](https://github.com/beeper/desktop-api-python/commit/6841539c8619a507ec5e717d08a81837e83d76c2)) + + +### Bug Fixes + +* **client:** preserve hardcoded query params when merging with user params ([9e86464](https://github.com/beeper/desktop-api-python/commit/9e86464960e28472bc3a4f137c5d2c025f2acc16)) +* **deps:** bump minimum typing-extensions version ([922d90a](https://github.com/beeper/desktop-api-python/commit/922d90aeb6d75306490a359d26ebbf71a6e340b8)) +* ensure file data are only sent as 1 parameter ([69f6d11](https://github.com/beeper/desktop-api-python/commit/69f6d11ecb0959d1a5eb90c41c76542a1ea5826f)) +* **pydantic:** do not pass `by_alias` unless set ([8b9fe85](https://github.com/beeper/desktop-api-python/commit/8b9fe85df1911bc10a65b5c965e5465c4041e065)) +* sanitize endpoint path params ([900c955](https://github.com/beeper/desktop-api-python/commit/900c955edf1d5f8cf7aa9c7d8a7859e5b61ae379)) +* use correct field name format for multipart file arrays ([d086e7f](https://github.com/beeper/desktop-api-python/commit/d086e7f0ff86653ace4d1f21c5daf1d6605b3369)) + + +### Performance Improvements + +* **client:** optimize file structure copying in multipart requests ([7addb88](https://github.com/beeper/desktop-api-python/commit/7addb88adf0574857ce646e8a9f15e8eb035a48a)) + + +### Chores + +* **ci:** skip lint on metadata-only changes ([1fe013e](https://github.com/beeper/desktop-api-python/commit/1fe013eacfb814508b88eefa3b2ee3bf51618edc)) +* **ci:** skip uploading artifacts on stainless-internal branches ([3f5692e](https://github.com/beeper/desktop-api-python/commit/3f5692eb199bd02db1359e8131c8774eee7fabcf)) +* configure new SDK language ([8b9d76c](https://github.com/beeper/desktop-api-python/commit/8b9d76c76fe4e3ae99d85429f20d9b782bea2520)) +* configure new SDK language ([a54d51a](https://github.com/beeper/desktop-api-python/commit/a54d51a23c31d38e124dc263f748f6ada2f2409c)) +* **internal:** add request options to SSE classes ([fcf96d3](https://github.com/beeper/desktop-api-python/commit/fcf96d3c4f3bdbec2cd1b88745cdbbc48e864be2)) +* **internal:** codegen related update ([a6b8aac](https://github.com/beeper/desktop-api-python/commit/a6b8aac8430c698cd1a73bab2cd257c9cf553df6)) +* **internal:** make `test_proxy_environment_variables` more resilient ([2420dd3](https://github.com/beeper/desktop-api-python/commit/2420dd3d3de95350f142acaf7fb923fd292af59e)) +* **internal:** make `test_proxy_environment_variables` more resilient to env ([1ad2ddf](https://github.com/beeper/desktop-api-python/commit/1ad2ddfe678d2a495d69e45d7a1a8f0856af4211)) +* **internal:** more robust bootstrap script ([ed8c2c4](https://github.com/beeper/desktop-api-python/commit/ed8c2c499c99ac2f1a4e83054ae0000cb9e12c47)) +* **internal:** reformat pyproject.toml ([f11e2a6](https://github.com/beeper/desktop-api-python/commit/f11e2a6c2e25d78bd95e0a81ce52e814dc2a9e79)) +* **internal:** tweak CI branches ([311a998](https://github.com/beeper/desktop-api-python/commit/311a998617de99c1defaa4c54f1b8a308d1bfaf3)) +* **internal:** update gitignore ([3dfb379](https://github.com/beeper/desktop-api-python/commit/3dfb3799f6ebbde6400db092864361e3b6a6e07f)) +* **test:** do not count install time for mock server timeout ([352dc26](https://github.com/beeper/desktop-api-python/commit/352dc26df496dac34b88c8d410a3d5761fad7cde)) +* **tests:** bump steady to v0.19.4 ([68f14af](https://github.com/beeper/desktop-api-python/commit/68f14afb85f168eb61a91398165b1d8222fbeea4)) +* **tests:** bump steady to v0.19.5 ([9229d32](https://github.com/beeper/desktop-api-python/commit/9229d32b59f8494f49677dbf508d2fedd21cd8b4)) +* **tests:** bump steady to v0.19.6 ([166b069](https://github.com/beeper/desktop-api-python/commit/166b069fbd3034b27d16baf50ef96c61a46996d9)) +* **tests:** bump steady to v0.19.7 ([31a8e58](https://github.com/beeper/desktop-api-python/commit/31a8e58a09bd798b9930c195da2be239d50a2e77)) +* **tests:** bump steady to v0.20.1 ([d2cf119](https://github.com/beeper/desktop-api-python/commit/d2cf119042313a4b82f4a395a43c175874ceca1e)) +* **tests:** bump steady to v0.20.2 ([0def55c](https://github.com/beeper/desktop-api-python/commit/0def55c17787849b27d1e8c21cb6cd129069e220)) +* **tests:** bump steady to v0.22.1 ([29127f7](https://github.com/beeper/desktop-api-python/commit/29127f7697a10191b913f86e8664321963b55003)) +* update placeholder string ([f9883db](https://github.com/beeper/desktop-api-python/commit/f9883db325ccb453c60affa4218f2b257c4d41d8)) +* update SDK settings ([b954521](https://github.com/beeper/desktop-api-python/commit/b954521c09743ea77dc1fe3f49e62cadb9cb6b1f)) +* update SDK settings ([5df69bf](https://github.com/beeper/desktop-api-python/commit/5df69bf22554340ee0fd0c694fb755c80907ee22)) + + +### Refactors + +* **tests:** switch from prism to steady ([ef99778](https://github.com/beeper/desktop-api-python/commit/ef99778f642f49a26aad1d65c59df9f9cfa766e9)) + ## 4.3.0 (2026-02-20) Full Changelog: [v4.2.0...v4.3.0](https://github.com/beeper/desktop-api-python/compare/v4.2.0...v4.3.0) diff --git a/pyproject.toml b/pyproject.toml index 7cbdc0a..f04ae30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "beeper_desktop_api" -version = "4.3.0" +version = "4.4.0" description = "The official Python library for the beeperdesktop API" dynamic = ["readme"] license = "MIT" diff --git a/src/beeper_desktop_api/_version.py b/src/beeper_desktop_api/_version.py index 1bc95e4..d6b05f3 100644 --- a/src/beeper_desktop_api/_version.py +++ b/src/beeper_desktop_api/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "beeper_desktop_api" -__version__ = "4.3.0" # x-release-please-version +__version__ = "4.4.0" # x-release-please-version