From ac4cc7a1eccdd6b85e9235cbc76137c28775fb98 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:55:36 +0000 Subject: [PATCH 01/98] chore: configure new SDK language --- .devcontainer/Dockerfile | 9 + .devcontainer/devcontainer.json | 43 + .github/workflows/ci.yml | 98 + .gitignore | 15 + .python-version | 1 + .stats.yml | 4 + .vscode/settings.json | 3 + Brewfile | 2 + CONTRIBUTING.md | 128 ++ LICENSE | 7 + README.md | 475 +++- SECURITY.md | 27 + api.md | 90 + bin/publish-pypi | 6 + examples/.keep | 4 + noxfile.py | 9 + pyproject.toml | 263 +++ requirements-dev.lock | 137 ++ requirements.lock | 75 + scripts/bootstrap | 27 + scripts/format | 8 + scripts/lint | 11 + scripts/mock | 41 + scripts/test | 61 + scripts/utils/ruffen-docs.py | 167 ++ scripts/utils/upload-artifact.sh | 27 + src/beeper_desktop_api/__init__.py | 102 + src/beeper_desktop_api/_base_client.py | 1995 +++++++++++++++++ src/beeper_desktop_api/_client.py | 750 +++++++ src/beeper_desktop_api/_compat.py | 219 ++ src/beeper_desktop_api/_constants.py | 14 + src/beeper_desktop_api/_exceptions.py | 108 + src/beeper_desktop_api/_files.py | 123 + src/beeper_desktop_api/_models.py | 835 +++++++ src/beeper_desktop_api/_qs.py | 150 ++ src/beeper_desktop_api/_resource.py | 43 + src/beeper_desktop_api/_response.py | 832 +++++++ src/beeper_desktop_api/_streaming.py | 333 +++ src/beeper_desktop_api/_types.py | 260 +++ src/beeper_desktop_api/_utils/__init__.py | 64 + src/beeper_desktop_api/_utils/_compat.py | 45 + .../_utils/_datetime_parse.py | 136 ++ src/beeper_desktop_api/_utils/_logs.py | 25 + src/beeper_desktop_api/_utils/_proxy.py | 65 + src/beeper_desktop_api/_utils/_reflection.py | 42 + .../_utils/_resources_proxy.py | 24 + src/beeper_desktop_api/_utils/_streams.py | 12 + src/beeper_desktop_api/_utils/_sync.py | 86 + src/beeper_desktop_api/_utils/_transform.py | 457 ++++ src/beeper_desktop_api/_utils/_typing.py | 156 ++ src/beeper_desktop_api/_utils/_utils.py | 421 ++++ src/beeper_desktop_api/_version.py | 4 + src/beeper_desktop_api/lib/.keep | 4 + src/beeper_desktop_api/pagination.py | 72 + src/beeper_desktop_api/py.typed | 0 src/beeper_desktop_api/resources/__init__.py | 75 + src/beeper_desktop_api/resources/accounts.py | 139 ++ .../resources/chats/__init__.py | 33 + .../resources/chats/chats.py | 680 ++++++ .../resources/chats/reminders.py | 273 +++ src/beeper_desktop_api/resources/contacts.py | 199 ++ src/beeper_desktop_api/resources/messages.py | 423 ++++ src/beeper_desktop_api/resources/token.py | 139 ++ src/beeper_desktop_api/types/__init__.py | 32 + src/beeper_desktop_api/types/account.py | 25 + .../types/account_list_response.py | 10 + src/beeper_desktop_api/types/chat.py | 70 + .../types/chat_archive_params.py | 20 + .../types/chat_create_params.py | 30 + .../types/chat_create_response.py | 14 + .../types/chat_retrieve_params.py | 25 + .../types/chat_search_params.py | 81 + .../types/chats/__init__.py | 6 + .../types/chats/reminder_create_params.py | 28 + .../types/chats/reminder_delete_params.py | 17 + .../types/client_download_asset_params.py | 12 + .../types/client_open_params.py | 26 + .../types/client_search_params.py | 12 + .../types/contact_search_params.py | 17 + .../types/contact_search_response.py | 12 + .../types/download_asset_response.py | 17 + .../types/message_search_params.py | 85 + .../types/message_send_params.py | 20 + .../types/message_send_response.py | 15 + src/beeper_desktop_api/types/open_response.py | 10 + .../types/search_response.py | 48 + .../types/shared/__init__.py | 8 + .../types/shared/attachment.py | 59 + .../types/shared/base_response.py | 13 + src/beeper_desktop_api/types/shared/error.py | 18 + .../types/shared/message.py | 58 + .../types/shared/reaction.py | 36 + src/beeper_desktop_api/types/shared/user.py | 45 + src/beeper_desktop_api/types/user_info.py | 31 + tests/__init__.py | 1 + tests/api_resources/__init__.py | 1 + tests/api_resources/chats/__init__.py | 1 + tests/api_resources/chats/test_reminders.py | 176 ++ tests/api_resources/test_accounts.py | 74 + tests/api_resources/test_chats.py | 374 +++ tests/api_resources/test_client.py | 222 ++ tests/api_resources/test_contacts.py | 92 + tests/api_resources/test_messages.py | 201 ++ tests/api_resources/test_token.py | 74 + tests/conftest.py | 84 + tests/sample_file.txt | 1 + tests/test_client.py | 1736 ++++++++++++++ tests/test_deepcopy.py | 58 + tests/test_extract_files.py | 64 + tests/test_files.py | 51 + tests/test_models.py | 963 ++++++++ tests/test_qs.py | 78 + tests/test_required_args.py | 111 + tests/test_response.py | 277 +++ tests/test_streaming.py | 252 +++ tests/test_transform.py | 460 ++++ tests/test_utils/test_datetime_parse.py | 110 + tests/test_utils/test_proxy.py | 34 + tests/test_utils/test_typing.py | 73 + tests/utils.py | 167 ++ 120 files changed, 17710 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 .stats.yml create mode 100644 .vscode/settings.json create mode 100644 Brewfile create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 api.md create mode 100644 bin/publish-pypi create mode 100644 examples/.keep create mode 100644 noxfile.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.lock create mode 100644 requirements.lock create mode 100755 scripts/bootstrap create mode 100755 scripts/format create mode 100755 scripts/lint create mode 100755 scripts/mock create mode 100755 scripts/test create mode 100644 scripts/utils/ruffen-docs.py create mode 100755 scripts/utils/upload-artifact.sh create mode 100644 src/beeper_desktop_api/__init__.py create mode 100644 src/beeper_desktop_api/_base_client.py create mode 100644 src/beeper_desktop_api/_client.py create mode 100644 src/beeper_desktop_api/_compat.py create mode 100644 src/beeper_desktop_api/_constants.py create mode 100644 src/beeper_desktop_api/_exceptions.py create mode 100644 src/beeper_desktop_api/_files.py create mode 100644 src/beeper_desktop_api/_models.py create mode 100644 src/beeper_desktop_api/_qs.py create mode 100644 src/beeper_desktop_api/_resource.py create mode 100644 src/beeper_desktop_api/_response.py create mode 100644 src/beeper_desktop_api/_streaming.py create mode 100644 src/beeper_desktop_api/_types.py create mode 100644 src/beeper_desktop_api/_utils/__init__.py create mode 100644 src/beeper_desktop_api/_utils/_compat.py create mode 100644 src/beeper_desktop_api/_utils/_datetime_parse.py create mode 100644 src/beeper_desktop_api/_utils/_logs.py create mode 100644 src/beeper_desktop_api/_utils/_proxy.py create mode 100644 src/beeper_desktop_api/_utils/_reflection.py create mode 100644 src/beeper_desktop_api/_utils/_resources_proxy.py create mode 100644 src/beeper_desktop_api/_utils/_streams.py create mode 100644 src/beeper_desktop_api/_utils/_sync.py create mode 100644 src/beeper_desktop_api/_utils/_transform.py create mode 100644 src/beeper_desktop_api/_utils/_typing.py create mode 100644 src/beeper_desktop_api/_utils/_utils.py create mode 100644 src/beeper_desktop_api/_version.py create mode 100644 src/beeper_desktop_api/lib/.keep create mode 100644 src/beeper_desktop_api/pagination.py create mode 100644 src/beeper_desktop_api/py.typed create mode 100644 src/beeper_desktop_api/resources/__init__.py create mode 100644 src/beeper_desktop_api/resources/accounts.py create mode 100644 src/beeper_desktop_api/resources/chats/__init__.py create mode 100644 src/beeper_desktop_api/resources/chats/chats.py create mode 100644 src/beeper_desktop_api/resources/chats/reminders.py create mode 100644 src/beeper_desktop_api/resources/contacts.py create mode 100644 src/beeper_desktop_api/resources/messages.py create mode 100644 src/beeper_desktop_api/resources/token.py create mode 100644 src/beeper_desktop_api/types/__init__.py create mode 100644 src/beeper_desktop_api/types/account.py create mode 100644 src/beeper_desktop_api/types/account_list_response.py create mode 100644 src/beeper_desktop_api/types/chat.py create mode 100644 src/beeper_desktop_api/types/chat_archive_params.py create mode 100644 src/beeper_desktop_api/types/chat_create_params.py create mode 100644 src/beeper_desktop_api/types/chat_create_response.py create mode 100644 src/beeper_desktop_api/types/chat_retrieve_params.py create mode 100644 src/beeper_desktop_api/types/chat_search_params.py create mode 100644 src/beeper_desktop_api/types/chats/__init__.py create mode 100644 src/beeper_desktop_api/types/chats/reminder_create_params.py create mode 100644 src/beeper_desktop_api/types/chats/reminder_delete_params.py create mode 100644 src/beeper_desktop_api/types/client_download_asset_params.py create mode 100644 src/beeper_desktop_api/types/client_open_params.py create mode 100644 src/beeper_desktop_api/types/client_search_params.py create mode 100644 src/beeper_desktop_api/types/contact_search_params.py create mode 100644 src/beeper_desktop_api/types/contact_search_response.py create mode 100644 src/beeper_desktop_api/types/download_asset_response.py create mode 100644 src/beeper_desktop_api/types/message_search_params.py create mode 100644 src/beeper_desktop_api/types/message_send_params.py create mode 100644 src/beeper_desktop_api/types/message_send_response.py create mode 100644 src/beeper_desktop_api/types/open_response.py create mode 100644 src/beeper_desktop_api/types/search_response.py create mode 100644 src/beeper_desktop_api/types/shared/__init__.py create mode 100644 src/beeper_desktop_api/types/shared/attachment.py create mode 100644 src/beeper_desktop_api/types/shared/base_response.py create mode 100644 src/beeper_desktop_api/types/shared/error.py create mode 100644 src/beeper_desktop_api/types/shared/message.py create mode 100644 src/beeper_desktop_api/types/shared/reaction.py create mode 100644 src/beeper_desktop_api/types/shared/user.py create mode 100644 src/beeper_desktop_api/types/user_info.py create mode 100644 tests/__init__.py create mode 100644 tests/api_resources/__init__.py create mode 100644 tests/api_resources/chats/__init__.py create mode 100644 tests/api_resources/chats/test_reminders.py create mode 100644 tests/api_resources/test_accounts.py create mode 100644 tests/api_resources/test_chats.py create mode 100644 tests/api_resources/test_client.py create mode 100644 tests/api_resources/test_contacts.py create mode 100644 tests/api_resources/test_messages.py create mode 100644 tests/api_resources/test_token.py create mode 100644 tests/conftest.py create mode 100644 tests/sample_file.txt create mode 100644 tests/test_client.py create mode 100644 tests/test_deepcopy.py create mode 100644 tests/test_extract_files.py create mode 100644 tests/test_files.py create mode 100644 tests/test_models.py create mode 100644 tests/test_qs.py create mode 100644 tests/test_required_args.py create mode 100644 tests/test_response.py create mode 100644 tests/test_streaming.py create mode 100644 tests/test_transform.py create mode 100644 tests/test_utils/test_datetime_parse.py create mode 100644 tests/test_utils/test_proxy.py create mode 100644 tests/test_utils/test_typing.py create mode 100644 tests/utils.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..ff261ba --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +ARG VARIANT="3.9" +FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} + +USER vscode + +RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.44.0" RYE_INSTALL_OPTION="--yes" bash +ENV PATH=/home/vscode/.rye/shims:$PATH + +RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..c17fdc1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,43 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/debian +{ + "name": "Debian", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + + "postStartCommand": "rye sync --all-features", + + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python" + ], + "settings": { + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": ".venv/bin/python", + "python.defaultInterpreterPath": ".venv/bin/python", + "python.typeChecking": "basic", + "terminal.integrated.env.linux": { + "PATH": "/home/vscode/.rye/shims:${env:PATH}" + } + } + } + }, + "features": { + "ghcr.io/devcontainers/features/node:1": {} + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eb758a6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI +on: + push: + branches-ignore: + - 'generated' + - 'codegen/**' + - 'integrated/**' + - 'stl-preview-head/**' + - 'stl-preview-base/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' + +jobs: + lint: + 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 + steps: + - uses: actions/checkout@v4 + + - 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: Run lints + run: ./scripts/lint + + build: + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + timeout-minutes: 10 + name: build + permissions: + contents: read + id-token: write + runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v4 + + - 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: Run build + run: rye build + + - name: Get GitHub OIDC Token + if: github.repository == 'stainless-sdks/beeper-desktop-api-python' + id: github-oidc + uses: actions/github-script@v6 + with: + script: core.setOutput('github_token', await core.getIDToken()); + + - name: Upload tarball + if: github.repository == 'stainless-sdks/beeper-desktop-api-python' + env: + URL: https://pkg.stainless.com/s + AUTH: ${{ steps.github-oidc.outputs.github_token }} + SHA: ${{ github.sha }} + run: ./scripts/utils/upload-artifact.sh + + test: + timeout-minutes: 10 + name: test + 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 + steps: + - uses: actions/checkout@v4 + + - 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: Bootstrap + run: ./scripts/bootstrap + + - name: Run tests + run: ./scripts/test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95ceb18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.prism.log +_dev + +__pycache__ +.mypy_cache + +dist + +.venv +.idea + +.env +.envrc +codegen.log +Brewfile.lock.json diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..43077b2 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9.18 diff --git a/.stats.yml b/.stats.yml new file mode 100644 index 0000000..0cc6155 --- /dev/null +++ b/.stats.yml @@ -0,0 +1,4 @@ +configured_endpoints: 14 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-8c712fe19f280b0b89ecc8a3ce61e9f6b165cee97ce33f66c66a7a5db339c755.yml +openapi_spec_hash: 1ea71129cc1a1ccc3dc8a99566082311 +config_hash: 62c0fc38bf46dc8cddd3298b7ab75dba diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5b01030 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.analysis.importFormat": "relative", +} diff --git a/Brewfile b/Brewfile new file mode 100644 index 0000000..492ca37 --- /dev/null +++ b/Brewfile @@ -0,0 +1,2 @@ +brew "rye" + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7899f17 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,128 @@ +## Setting up the environment + +### With Rye + +We use [Rye](https://rye.astral.sh/) to manage dependencies because it will automatically provision a Python environment with the expected Python version. To set it up, run: + +```sh +$ ./scripts/bootstrap +``` + +Or [install Rye manually](https://rye.astral.sh/guide/installation/) and run: + +```sh +$ rye sync --all-features +``` + +You can then run scripts using `rye run python script.py` or by activating the virtual environment: + +```sh +# Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work +$ source .venv/bin/activate + +# now you can omit the `rye run` prefix +$ python script.py +``` + +### Without Rye + +Alternatively if you don't want to install `Rye`, you can stick with the standard `pip` setup by ensuring you have the Python version specified in `.python-version`, create a virtual environment however you desire and then install dependencies using this command: + +```sh +$ pip install -r requirements-dev.lock +``` + +## Modifying/Adding code + +Most of the SDK is generated code. Modifications to code will be persisted between generations, but may +result in merge conflicts between manual patches and changes from the generator. The generator will never +modify the contents of the `src/beeper_desktop_api/lib/` and `examples/` directories. + +## Adding and running examples + +All files in the `examples/` directory are not modified by the generator and can be freely edited or added to. + +```py +# add an example to examples/.py + +#!/usr/bin/env -S rye run python +… +``` + +```sh +$ chmod +x examples/.py +# run the example against your api +$ ./examples/.py +``` + +## Using the repository from source + +If you’d like to use the repository from source, you can either install from git or link to a cloned repository: + +To install via git: + +```sh +$ pip install git+ssh://git@github.com/stainless-sdks/beeper-desktop-api-python.git +``` + +Alternatively, you can build from source and install the wheel file: + +Building this package will create two files in the `dist/` directory, a `.tar.gz` containing the source files and a `.whl` that can be used to install the package efficiently. + +To create a distributable version of the library, all you have to do is run this command: + +```sh +$ rye build +# or +$ python -m build +``` + +Then to install: + +```sh +$ 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. + +```sh +# you will need npm installed +$ npx prism mock path/to/your/openapi.yml +``` + +```sh +$ ./scripts/test +``` + +## Linting and formatting + +This repository uses [ruff](https://github.com/astral-sh/ruff) and +[black](https://github.com/psf/black) to format the code in the repository. + +To lint: + +```sh +$ ./scripts/lint +``` + +To format and fix all ruff issues automatically: + +```sh +$ ./scripts/format +``` + +## Publishing and releases + +Changes made to this repository via the automated release PR pipeline should publish to PyPI automatically. If +the changes aren't made through the automated pipeline, you may want to make releases manually. + +### Publish with a GitHub workflow + +You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/stainless-sdks/beeper-desktop-api-python/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. + +### Publish manually + +If you need to manually release a package, you can run the `bin/publish-pypi` script with a `PYPI_TOKEN` set on +the environment. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..59424c8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 beeperdesktop + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 98a44fc..f6a25c5 100644 --- a/README.md +++ b/README.md @@ -1 +1,474 @@ -# beeper-desktop-api-python \ No newline at end of file +# Beeper Desktop Python API library + + +[![PyPI version](https://img.shields.io/pypi/v/beeper_desktop_api.svg?label=pypi%20(stable))](https://pypi.org/project/beeper_desktop_api/) + +The Beeper Desktop Python library provides convenient access to the Beeper Desktop REST API from any Python 3.8+ +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). + +## Documentation + +The REST API documentation can be found on [developers.beeper.com](https://developers.beeper.com/desktop-api/). The full API of this library can be found in [api.md](api.md). + +## Installation + +```sh +# install from this staging repo +pip install git+ssh://git@github.com/stainless-sdks/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( + access_token=os.environ.get("BEEPER_ACCESS_TOKEN"), # This is the default and can be omitted +) + +page = client.chats.search( + include_muted=True, + limit=3, + type="single", +) +print(page.items) +``` + +While you can provide a `access_token` keyword argument, +we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) +to add `BEEPER_ACCESS_TOKEN="My Access Token"` to your `.env` file +so that your Access Token is not stored in source control. + +## Async usage + +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 +) + + +async def main() -> None: + page = await client.chats.search( + include_muted=True, + limit=3, + type="single", + ) + print(page.items) + + +asyncio.run(main()) +``` + +Functionality between the synchronous and asynchronous clients is otherwise identical. + +### With aiohttp + +By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. + +You can enable this by installing `aiohttp`: + +```sh +# install from this staging repo +pip install 'beeper_desktop_api[aiohttp] @ git+ssh://git@github.com/stainless-sdks/beeper-desktop-api-python.git' +``` + +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: + +```python +import asyncio +from beeper_desktop_api import DefaultAioHttpClient +from beeper_desktop_api import AsyncBeeperDesktop + + +async def main() -> None: + async with AsyncBeeperDesktop( + access_token="My Access Token", + http_client=DefaultAioHttpClient(), + ) as client: + page = await client.chats.search( + include_muted=True, + limit=3, + type="single", + ) + print(page.items) + + +asyncio.run(main()) +``` + +## Using types + +Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like: + +- Serializing back into JSON, `model.to_json()` +- Converting to a dictionary, `model.to_dict()` + +Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. + +## Pagination + +List methods in the Beeper Desktop API are paginated. + +This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually: + +```python +from beeper_desktop_api import BeeperDesktop + +client = BeeperDesktop() + +all_messages = [] +# Automatically fetches more pages as needed. +for message in client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", +): + # Do something with message here + all_messages.append(message) +print(all_messages) +``` + +Or, asynchronously: + +```python +import asyncio +from beeper_desktop_api import AsyncBeeperDesktop + +client = AsyncBeeperDesktop() + + +async def main() -> None: + all_messages = [] + # Iterate through items across all pages, issuing requests as needed. + async for message in client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", + ): + all_messages.append(message) + print(all_messages) + + +asyncio.run(main()) +``` + +Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages: + +```python +first_page = await client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", +) +if first_page.has_next_page(): + print(f"will fetch next page using these details: {first_page.next_page_info()}") + next_page = await first_page.get_next_page() + print(f"number of items we just fetched: {len(next_page.items)}") + +# Remove `await` for non-async usage. +``` + +Or just work directly with the returned data: + +```python +first_page = await client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", +) + +print(f"next page cursor: {first_page.oldest_cursor}") # => "next page cursor: ..." +for message in first_page.items: + print(message.id) + +# Remove `await` for non-async usage. +``` + +## Nested params + +Nested parameters are dictionaries, typed using `TypedDict`, for example: + +```python +from beeper_desktop_api import BeeperDesktop + +client = BeeperDesktop() + +base_response = client.chats.reminders.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, +) +print(base_response.reminder) +``` + +## Handling errors + +When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `beeper_desktop_api.APIConnectionError` is raised. + +When the API returns a non-success status code (that is, 4xx or 5xx +response), a subclass of `beeper_desktop_api.APIStatusError` is raised, containing `status_code` and `response` properties. + +All errors inherit from `beeper_desktop_api.APIError`. + +```python +import beeper_desktop_api +from beeper_desktop_api import BeeperDesktop + +client = BeeperDesktop() + +try: + client.messages.send( + chat_id="1229391", + text="Hello! Just checking in on the project status.", + ) +except beeper_desktop_api.APIConnectionError as e: + print("The server could not be reached") + print(e.__cause__) # an underlying Exception, likely raised within httpx. +except beeper_desktop_api.RateLimitError as e: + print("A 429 status code was received; we should back off a bit.") +except beeper_desktop_api.APIStatusError as e: + print("Another non-200-range status code was received") + print(e.status_code) + print(e.response) +``` + +Error codes are as follows: + +| Status Code | Error Type | +| ----------- | -------------------------- | +| 400 | `BadRequestError` | +| 401 | `AuthenticationError` | +| 403 | `PermissionDeniedError` | +| 404 | `NotFoundError` | +| 422 | `UnprocessableEntityError` | +| 429 | `RateLimitError` | +| >=500 | `InternalServerError` | +| N/A | `APIConnectionError` | + +### Retries + +Certain errors are automatically retried 2 times by default, with a short exponential backoff. +Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, +429 Rate Limit, and >=500 Internal errors are all retried by default. + +You can use the `max_retries` option to configure or disable retry settings: + +```python +from beeper_desktop_api import BeeperDesktop + +# Configure the default for all requests: +client = BeeperDesktop( + # default is 2 + max_retries=0, +) + +# Or, configure per-request: +client.with_options(max_retries=5).accounts.list() +``` + +### Timeouts + +By default requests time out after 30 seconds. You can configure this with a `timeout` option, +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: + +```python +from beeper_desktop_api import BeeperDesktop + +# Configure the default for all requests: +client = BeeperDesktop( + # 20 seconds (default is 30 seconds) + timeout=20.0, +) + +# More granular control: +client = BeeperDesktop( + timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), +) + +# Override per-request: +client.with_options(timeout=5.0).accounts.list() +``` + +On timeout, an `APITimeoutError` is thrown. + +Note that requests that time out are [retried twice by default](#retries). + +## Advanced + +### Logging + +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`. + +```shell +$ export BEEPER_DESKTOP_LOG=info +``` + +Or to `debug` for more verbose logging. + +### How to tell whether `None` means `null` or missing + +In an API response, a field may be explicitly `null`, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: + +```py +if response.my_field is None: + if 'my_field' not in response.model_fields_set: + print('Got json like {}, without a "my_field" key present at all.') + else: + print('Got json like {"my_field": null}.') +``` + +### Accessing raw response data (e.g. headers) + +The "raw" Response object can be accessed by prefixing `.with_raw_response.` to any HTTP method call, e.g., + +```py +from beeper_desktop_api import BeeperDesktop + +client = BeeperDesktop() +response = client.accounts.with_raw_response.list() +print(response.headers.get('X-My-Header')) + +account = response.parse() # get the object that `accounts.list()` would have returned +print(account) +``` + +These methods return an [`APIResponse`](https://github.com/stainless-sdks/beeper-desktop-api-python/tree/main/src/beeper_desktop_api/_response.py) object. + +The async client returns an [`AsyncAPIResponse`](https://github.com/stainless-sdks/beeper-desktop-api-python/tree/main/src/beeper_desktop_api/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. + +#### `.with_streaming_response` + +The above interface eagerly reads the full response body when you make the request, which may not always be what you want. + +To stream the response body, use `.with_streaming_response` instead, which requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. In the async client, these are async methods. + +```python +with client.accounts.with_streaming_response.list() as response: + print(response.headers.get("X-My-Header")) + + for line in response.iter_lines(): + print(line) +``` + +The context manager is required so that the response will reliably be closed. + +### Making custom/undocumented requests + +This library is typed for convenient access to the documented API. + +If you need to access undocumented endpoints, params, or response properties, the library can still be used. + +#### Undocumented endpoints + +To make requests to undocumented endpoints, you can make requests using `client.get`, `client.post`, and other +http verbs. Options on the client will be respected (such as retries) when making this request. + +```py +import httpx + +response = client.post( + "/foo", + cast_to=httpx.Response, + body={"my_param": True}, +) + +print(response.headers.get("x-foo")) +``` + +#### Undocumented request params + +If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` request +options. + +#### Undocumented response properties + +To access undocumented response properties, you can access the extra fields like `response.unknown_prop`. You +can also get all the extra fields on the Pydantic model as a dict with +[`response.model_extra`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_extra). + +### Configuring the HTTP client + +You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: + +- Support for [proxies](https://www.python-httpx.org/advanced/proxies/) +- Custom [transports](https://www.python-httpx.org/advanced/transports/) +- Additional [advanced](https://www.python-httpx.org/advanced/clients/) functionality + +```python +import httpx +from beeper_desktop_api import BeeperDesktop, DefaultHttpxClient + +client = BeeperDesktop( + # Or use the `BEEPER_DESKTOP_BASE_URL` env var + base_url="http://my.test.server.example.com:8083", + http_client=DefaultHttpxClient( + proxy="http://my.test.proxy.example.com", + transport=httpx.HTTPTransport(local_address="0.0.0.0"), + ), +) +``` + +You can also customize the client on a per-request basis by using `with_options()`: + +```python +client.with_options(http_client=DefaultHttpxClient(...)) +``` + +### Managing HTTP resources + +By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. + +```py +from beeper_desktop_api import BeeperDesktop + +with BeeperDesktop() as client: + # make requests here + ... + +# HTTP client is now closed +``` + +## Versioning + +This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: + +1. Changes that only affect static types, without breaking runtime behavior. +2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ +3. Changes that we do not expect to impact the vast majority of users in practice. + +We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. + +We are keen for your feedback; please open an [issue](https://www.github.com/stainless-sdks/beeper-desktop-api-python/issues) with questions, bugs, or suggestions. + +### Determining the installed version + +If you've upgraded to the latest version but aren't seeing any new features you were expecting then your python environment is likely still using an older version. + +You can determine the version that is being used at runtime with: + +```py +import beeper_desktop_api +print(beeper_desktop_api.__version__) +``` + +## Requirements + +Python 3.8 or higher. + +## Contributing + +See [the contributing documentation](./CONTRIBUTING.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..60d38a6 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Reporting Security Issues + +This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. + +To report a security issue, please contact the Stainless team at security@stainless.com. + +## Responsible Disclosure + +We appreciate the efforts of security researchers and individuals who help us maintain the security of +SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible +disclosure practices by allowing us a reasonable amount of time to investigate and address the issue +before making any information public. + +## Reporting Non-SDK Related Security Issues + +If you encounter security issues that are not directly related to SDKs but pertain to the services +or products provided by Beeper Desktop, please follow the respective company's security reporting guidelines. + +### Beeper Desktop Terms and Policies + +Please contact security@beeper.com for any questions or concerns regarding the security of our services. + +--- + +Thank you for helping us keep the SDKs and systems they interact with secure. diff --git a/api.md b/api.md new file mode 100644 index 0000000..45fb5c3 --- /dev/null +++ b/api.md @@ -0,0 +1,90 @@ +# Shared Types + +```python +from beeper_desktop_api.types import Attachment, BaseResponse, Error, Message, Reaction, User +``` + +# BeeperDesktop + +Types: + +```python +from beeper_desktop_api.types import DownloadAssetResponse, OpenResponse, SearchResponse +``` + +Methods: + +- client.download_asset(\*\*params) -> DownloadAssetResponse +- client.open(\*\*params) -> OpenResponse +- client.search(\*\*params) -> SearchResponse + +# Accounts + +Types: + +```python +from beeper_desktop_api.types import Account, AccountListResponse +``` + +Methods: + +- client.accounts.list() -> AccountListResponse + +# Contacts + +Types: + +```python +from beeper_desktop_api.types import ContactSearchResponse +``` + +Methods: + +- client.contacts.search(\*\*params) -> ContactSearchResponse + +# Chats + +Types: + +```python +from beeper_desktop_api.types import Chat, ChatCreateResponse +``` + +Methods: + +- client.chats.create(\*\*params) -> ChatCreateResponse +- client.chats.retrieve(\*\*params) -> Chat +- client.chats.archive(\*\*params) -> BaseResponse +- client.chats.search(\*\*params) -> SyncCursor[Chat] + +## Reminders + +Methods: + +- client.chats.reminders.create(\*\*params) -> BaseResponse +- client.chats.reminders.delete(\*\*params) -> BaseResponse + +# Messages + +Types: + +```python +from beeper_desktop_api.types import MessageSendResponse +``` + +Methods: + +- client.messages.search(\*\*params) -> SyncCursor[Message] +- client.messages.send(\*\*params) -> MessageSendResponse + +# Token + +Types: + +```python +from beeper_desktop_api.types import RevokeRequest, UserInfo +``` + +Methods: + +- client.token.info() -> UserInfo diff --git a/bin/publish-pypi b/bin/publish-pypi new file mode 100644 index 0000000..826054e --- /dev/null +++ b/bin/publish-pypi @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -eux +mkdir -p dist +rye build --clean +rye publish --yes --token=$PYPI_TOKEN diff --git a/examples/.keep b/examples/.keep new file mode 100644 index 0000000..d8c73e9 --- /dev/null +++ b/examples/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store example files demonstrating usage of this SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..53bca7f --- /dev/null +++ b/noxfile.py @@ -0,0 +1,9 @@ +import nox + + +@nox.session(reuse_venv=True, name="test-pydantic-v1") +def test_pydantic_v1(session: nox.Session) -> None: + session.install("-r", "requirements-dev.lock") + session.install("pydantic<2") + + session.run("pytest", "--showlocals", "--ignore=tests/functional", *session.posargs) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b90ac16 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,263 @@ +[project] +name = "beeper_desktop_api" +version = "0.0.1" +description = "The official Python library for the beeperdesktop API" +dynamic = ["readme"] +license = "MIT" +authors = [ +{ name = "Beeper Desktop", email = "help@beeper.com" }, +] +dependencies = [ + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.10, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", +] +requires-python = ">= 3.8" +classifiers = [ + "Typing :: Typed", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", + "Operating System :: POSIX", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License" +] + +[project.urls] +Homepage = "https://github.com/stainless-sdks/beeper-desktop-api-python" +Repository = "https://github.com/stainless-sdks/beeper-desktop-api-python" + +[project.optional-dependencies] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] + +[tool.rye] +managed = true +# version pins are in requirements-dev.lock +dev-dependencies = [ + "pyright==1.1.399", + "mypy", + "respx", + "pytest", + "pytest-asyncio", + "ruff", + "time-machine", + "nox", + "dirty-equals>=0.6.0", + "importlib-metadata>=6.7.0", + "rich>=13.7.1", + "pytest-xdist>=3.6.1", +] + +[tool.rye.scripts] +format = { chain = [ + "format:ruff", + "format:docs", + "fix:ruff", + # run formatting again to fix any inconsistencies when imports are stripped + "format:ruff", +]} +"format:docs" = "python scripts/utils/ruffen-docs.py README.md api.md" +"format:ruff" = "ruff format" + +"lint" = { chain = [ + "check:ruff", + "typecheck", + "check:importable", +]} +"check:ruff" = "ruff check ." +"fix:ruff" = "ruff check --fix ." + +"check:importable" = "python -c 'import beeper_desktop_api'" + +typecheck = { chain = [ + "typecheck:pyright", + "typecheck:mypy" +]} +"typecheck:pyright" = "pyright" +"typecheck:verify-types" = "pyright --verifytypes beeper_desktop_api --ignoreexternal" +"typecheck:mypy" = "mypy ." + +[build-system] +requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"] +build-backend = "hatchling.build" + +[tool.hatch.build] +include = [ + "src/*" +] + +[tool.hatch.build.targets.wheel] +packages = ["src/beeper_desktop_api"] + +[tool.hatch.build.targets.sdist] +# Basically everything except hidden files/directories (such as .github, .devcontainers, .python-version, etc) +include = [ + "/*.toml", + "/*.json", + "/*.lock", + "/*.md", + "/mypy.ini", + "/noxfile.py", + "bin/*", + "examples/*", + "src/*", + "tests/*", +] + +[tool.hatch.metadata.hooks.fancy-pypi-readme] +content-type = "text/markdown" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "README.md" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] +# replace relative links with absolute links +pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' +replacement = '[\1](https://github.com/stainless-sdks/beeper-desktop-api-python/tree/main/\g<2>)' + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--tb=short -n auto" +xfail_strict = true +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +filterwarnings = [ + "error" +] + +[tool.pyright] +# this enables practically every flag given by pyright. +# there are a couple of flags that are still disabled by +# default in strict mode as they are experimental and niche. +typeCheckingMode = "strict" +pythonVersion = "3.8" + +exclude = [ + "_dev", + ".venv", + ".nox", + ".git", +] + +reportImplicitOverride = true +reportOverlappingOverload = false + +reportImportCycles = false +reportPrivateUsage = false + +[tool.mypy] +pretty = true +show_error_codes = true + +# Exclude _files.py because mypy isn't smart enough to apply +# the correct type narrowing and as this is an internal module +# it's fine to just use Pyright. +# +# 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/.*'] + +strict_equality = true +implicit_reexport = true +check_untyped_defs = true +no_implicit_optional = true + +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true + +# Turn these options off as it could cause conflicts +# with the Pyright options. +warn_unused_ignores = false +warn_redundant_casts = false + +disallow_any_generics = true +disallow_untyped_defs = true +disallow_untyped_calls = true +disallow_subclassing_any = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +cache_fine_grained = true + +# By default, mypy reports an error if you assign a value to the result +# of a function call that doesn't return anything. We do this in our test +# cases: +# ``` +# result = ... +# assert result is None +# ``` +# Changing this codegen to make mypy happy would increase complexity +# and would not be worth it. +disable_error_code = "func-returns-value,overload-cannot-match" + +# https://github.com/python/mypy/issues/12162 +[[tool.mypy.overrides]] +module = "black.files.*" +ignore_errors = true +ignore_missing_imports = true + + +[tool.ruff] +line-length = 120 +output-format = "grouped" +target-version = "py38" + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +select = [ + # isort + "I", + # bugbear rules + "B", + # remove unused imports + "F401", + # bare except statements + "E722", + # unused arguments + "ARG", + # print statements + "T201", + "T203", + # misuse of typing.TYPE_CHECKING + "TC004", + # import rules + "TID251", +] +ignore = [ + # mutable defaults + "B006", +] +unfixable = [ + # disable auto fix for print statements + "T201", + "T203", +] + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"functools.lru_cache".msg = "This function does not retain type information for the wrapped function's arguments; The `lru_cache` function from `_utils` should be used instead" + +[tool.ruff.lint.isort] +length-sort = true +length-sort-straight = true +combine-as-imports = true +extra-standard-library = ["typing_extensions"] +known-first-party = ["beeper_desktop_api", "tests"] + +[tool.ruff.lint.per-file-ignores] +"bin/**.py" = ["T201", "T203"] +"scripts/**.py" = ["T201", "T203"] +"tests/**.py" = ["T201", "T203"] +"examples/**.py" = ["T201", "T203"] diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 0000000..219c95e --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,137 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: true +# with-sources: false +# generate-hashes: false +# universal: false + +-e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.8 + # via beeper-desktop-api + # via httpx-aiohttp +aiosignal==1.3.2 + # via aiohttp +annotated-types==0.6.0 + # via pydantic +anyio==4.4.0 + # via beeper-desktop-api + # via httpx +argcomplete==3.1.2 + # via nox +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp +certifi==2023.7.22 + # via httpcore + # via httpx +colorlog==6.7.0 + # via nox +dirty-equals==0.6.0 +distlib==0.3.7 + # via virtualenv +distro==1.8.0 + # via beeper-desktop-api +exceptiongroup==1.2.2 + # via anyio + # via pytest +execnet==2.1.1 + # via pytest-xdist +filelock==3.12.4 + # via virtualenv +frozenlist==1.6.2 + # via aiohttp + # via aiosignal +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via beeper-desktop-api + # via httpx-aiohttp + # via respx +httpx-aiohttp==0.1.8 + # via beeper-desktop-api +idna==3.4 + # via anyio + # via httpx + # via yarl +importlib-metadata==7.0.0 +iniconfig==2.0.0 + # via pytest +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +multidict==6.4.4 + # via aiohttp + # via yarl +mypy==1.14.1 +mypy-extensions==1.0.0 + # via mypy +nodeenv==1.8.0 + # via pyright +nox==2023.4.22 +packaging==23.2 + # via nox + # via pytest +platformdirs==3.11.0 + # via virtualenv +pluggy==1.5.0 + # via pytest +propcache==0.3.1 + # via aiohttp + # via yarl +pydantic==2.11.9 + # via beeper-desktop-api +pydantic-core==2.33.2 + # via pydantic +pygments==2.18.0 + # via rich +pyright==1.1.399 +pytest==8.3.3 + # via pytest-asyncio + # via pytest-xdist +pytest-asyncio==0.24.0 +pytest-xdist==3.7.0 +python-dateutil==2.8.2 + # via time-machine +pytz==2023.3.post1 + # via dirty-equals +respx==0.22.0 +rich==13.7.1 +ruff==0.9.4 +setuptools==68.2.2 + # via nodeenv +six==1.16.0 + # via python-dateutil +sniffio==1.3.0 + # via anyio + # via beeper-desktop-api +time-machine==2.9.0 +tomli==2.0.2 + # via mypy + # via pytest +typing-extensions==4.12.2 + # via anyio + # via beeper-desktop-api + # via multidict + # via mypy + # via pydantic + # via pydantic-core + # via pyright + # via typing-inspection +typing-inspection==0.4.1 + # via pydantic +virtualenv==20.24.5 + # via nox +yarl==1.20.0 + # via aiohttp +zipp==3.17.0 + # via importlib-metadata diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..22d2655 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,75 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: true +# with-sources: false +# generate-hashes: false +# universal: false + +-e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.8 + # via beeper-desktop-api + # via httpx-aiohttp +aiosignal==1.3.2 + # via aiohttp +annotated-types==0.6.0 + # via pydantic +anyio==4.4.0 + # via beeper-desktop-api + # via httpx +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp +certifi==2023.7.22 + # via httpcore + # via httpx +distro==1.8.0 + # via beeper-desktop-api +exceptiongroup==1.2.2 + # via anyio +frozenlist==1.6.2 + # via aiohttp + # via aiosignal +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via beeper-desktop-api + # via httpx-aiohttp +httpx-aiohttp==0.1.8 + # via beeper-desktop-api +idna==3.4 + # via anyio + # via httpx + # via yarl +multidict==6.4.4 + # via aiohttp + # via yarl +propcache==0.3.1 + # via aiohttp + # via yarl +pydantic==2.11.9 + # via beeper-desktop-api +pydantic-core==2.33.2 + # via pydantic +sniffio==1.3.0 + # via anyio + # via beeper-desktop-api +typing-extensions==4.12.2 + # via anyio + # via beeper-desktop-api + # via multidict + # via pydantic + # via pydantic-core + # via typing-inspection +typing-inspection==0.4.1 + # via pydantic +yarl==1.20.0 + # via aiohttp diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 0000000..b430fee --- /dev/null +++ b/scripts/bootstrap @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +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 + case "$response" in + [yY][eE][sS]|[yY]) + brew bundle + ;; + *) + ;; + esac + echo + } +fi + +echo "==> Installing Python dependencies…" + +# experimental uv support makes installations significantly faster +rye config --set-bool behavior.use-uv=true + +rye sync --all-features diff --git a/scripts/format b/scripts/format new file mode 100755 index 0000000..667ec2d --- /dev/null +++ b/scripts/format @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running formatters" +rye run format diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..8d20626 --- /dev/null +++ b/scripts/lint @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running lints" +rye run lint + +echo "==> Making sure it imports" +rye run python -c 'import beeper_desktop_api' diff --git a/scripts/mock b/scripts/mock new file mode 100755 index 0000000..0b28f6e --- /dev/null +++ b/scripts/mock @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [[ -n "$1" && "$1" != '--'* ]]; then + URL="$1" + shift +else + URL="$(grep 'openapi_spec_url' .stats.yml | cut -d' ' -f2)" +fi + +# Check if the URL is empty +if [ -z "$URL" ]; then + echo "Error: No OpenAPI spec path/url provided or found in .stats.yml" + exit 1 +fi + +echo "==> Starting mock server with URL ${URL}" + +# Run prism mock on the given spec +if [ "$1" == "--daemon" ]; then + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & + + # Wait for server to come online + echo -n "Waiting for server" + while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + 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" +fi diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..dbeda2d --- /dev/null +++ b/scripts/test @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +RED='\033[0;31m' +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 +} + +kill_server_on_port() { + pids=$(lsof -t -i tcp:"$1" || echo "") + if [ "$pids" != "" ]; then + kill "$pids" + echo "Stopped $pids." + fi +} + +function is_overriding_api_base_url() { + [ -n "$TEST_API_BASE_URL" ] +} + +if ! is_overriding_api_base_url && ! prism_is_running ; then + # When we exit this script, make sure to kill the background mock server process + trap 'kill_server_on_port 4010' EXIT + + # Start the dev server + ./scripts/mock --daemon +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" + 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 + echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" + echo + + exit 1 +else + echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" + echo +fi + +export DEFER_PYDANTIC_BUILD=false + +echo "==> Running tests" +rye run pytest "$@" + +echo "==> Running Pydantic v1 tests" +rye run nox -s test-pydantic-v1 -- "$@" diff --git a/scripts/utils/ruffen-docs.py b/scripts/utils/ruffen-docs.py new file mode 100644 index 0000000..0cf2bd2 --- /dev/null +++ b/scripts/utils/ruffen-docs.py @@ -0,0 +1,167 @@ +# fork of https://github.com/asottile/blacken-docs adapted for ruff +from __future__ import annotations + +import re +import sys +import argparse +import textwrap +import contextlib +import subprocess +from typing import Match, Optional, Sequence, Generator, NamedTuple, cast + +MD_RE = re.compile( + r"(?P^(?P *)```\s*python\n)" r"(?P.*?)" r"(?P^(?P=indent)```\s*$)", + re.DOTALL | re.MULTILINE, +) +MD_PYCON_RE = re.compile( + r"(?P^(?P *)```\s*pycon\n)" r"(?P.*?)" r"(?P^(?P=indent)```.*$)", + re.DOTALL | re.MULTILINE, +) +PYCON_PREFIX = ">>> " +PYCON_CONTINUATION_PREFIX = "..." +PYCON_CONTINUATION_RE = re.compile( + rf"^{re.escape(PYCON_CONTINUATION_PREFIX)}( |$)", +) +DEFAULT_LINE_LENGTH = 100 + + +class CodeBlockError(NamedTuple): + offset: int + exc: Exception + + +def format_str( + src: str, +) -> tuple[str, Sequence[CodeBlockError]]: + errors: list[CodeBlockError] = [] + + @contextlib.contextmanager + def _collect_error(match: Match[str]) -> Generator[None, None, None]: + try: + yield + except Exception as e: + errors.append(CodeBlockError(match.start(), e)) + + def _md_match(match: Match[str]) -> str: + code = textwrap.dedent(match["code"]) + with _collect_error(match): + code = format_code_block(code) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + def _pycon_match(match: Match[str]) -> str: + code = "" + fragment = cast(Optional[str], None) + + def finish_fragment() -> None: + nonlocal code + nonlocal fragment + + if fragment is not None: + with _collect_error(match): + fragment = format_code_block(fragment) + fragment_lines = fragment.splitlines() + code += f"{PYCON_PREFIX}{fragment_lines[0]}\n" + for line in fragment_lines[1:]: + # Skip blank lines to handle Black adding a blank above + # functions within blocks. A blank line would end the REPL + # continuation prompt. + # + # >>> if True: + # ... def f(): + # ... pass + # ... + if line: + code += f"{PYCON_CONTINUATION_PREFIX} {line}\n" + if fragment_lines[-1].startswith(" "): + code += f"{PYCON_CONTINUATION_PREFIX}\n" + fragment = None + + indentation = None + for line in match["code"].splitlines(): + orig_line, line = line, line.lstrip() + if indentation is None and line: + indentation = len(orig_line) - len(line) + continuation_match = PYCON_CONTINUATION_RE.match(line) + if continuation_match and fragment is not None: + fragment += line[continuation_match.end() :] + "\n" + else: + finish_fragment() + if line.startswith(PYCON_PREFIX): + fragment = line[len(PYCON_PREFIX) :] + "\n" + else: + code += orig_line[indentation:] + "\n" + finish_fragment() + return code + + def _md_pycon_match(match: Match[str]) -> str: + code = _pycon_match(match) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + src = MD_RE.sub(_md_match, src) + src = MD_PYCON_RE.sub(_md_pycon_match, src) + return src, errors + + +def format_code_block(code: str) -> str: + return subprocess.check_output( + [ + sys.executable, + "-m", + "ruff", + "format", + "--stdin-filename=script.py", + f"--line-length={DEFAULT_LINE_LENGTH}", + ], + encoding="utf-8", + input=code, + ) + + +def format_file( + filename: str, + skip_errors: bool, +) -> int: + with open(filename, encoding="UTF-8") as f: + contents = f.read() + new_contents, errors = format_str(contents) + for error in errors: + lineno = contents[: error.offset].count("\n") + 1 + print(f"{filename}:{lineno}: code block parse error {error.exc}") + if errors and not skip_errors: + return 1 + if contents != new_contents: + print(f"{filename}: Rewriting...") + with open(filename, "w", encoding="UTF-8") as f: + f.write(new_contents) + return 0 + else: + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "-l", + "--line-length", + type=int, + default=DEFAULT_LINE_LENGTH, + ) + parser.add_argument( + "-S", + "--skip-string-normalization", + action="store_true", + ) + parser.add_argument("-E", "--skip-errors", action="store_true") + parser.add_argument("filenames", nargs="*") + args = parser.parse_args(argv) + + retv = 0 + for filename in args.filenames: + retv |= format_file(filename, skip_errors=args.skip_errors) + return retv + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh new file mode 100755 index 0000000..439b398 --- /dev/null +++ b/scripts/utils/upload-artifact.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -exuo pipefail + +FILENAME=$(basename dist/*.whl) + +RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ + -H "Authorization: Bearer $AUTH" \ + -H "Content-Type: application/json") + +SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') + +if [[ "$SIGNED_URL" == "null" ]]; then + echo -e "\033[31mFailed to get signed URL.\033[0m" + exit 1 +fi + +UPLOAD_RESPONSE=$(curl -v -X PUT \ + -H "Content-Type: binary/octet-stream" \ + --data-binary "@dist/$FILENAME" "$SIGNED_URL" 2>&1) + +if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then + echo -e "\033[32mUploaded build to Stainless storage.\033[0m" + echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/beeper-desktop-api-python/$SHA/$FILENAME'\033[0m" +else + echo -e "\033[31mFailed to upload artifact.\033[0m" + exit 1 +fi diff --git a/src/beeper_desktop_api/__init__.py b/src/beeper_desktop_api/__init__.py new file mode 100644 index 0000000..bd9b7d9 --- /dev/null +++ b/src/beeper_desktop_api/__init__.py @@ -0,0 +1,102 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import typing as _t + +from . import types +from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given +from ._utils import file_from_path +from ._client import ( + Client, + Stream, + Timeout, + Transport, + AsyncClient, + AsyncStream, + BeeperDesktop, + RequestOptions, + AsyncBeeperDesktop, +) +from ._models import BaseModel +from ._version import __title__, __version__ +from ._response import APIResponse as APIResponse, AsyncAPIResponse as AsyncAPIResponse +from ._constants import DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES, DEFAULT_CONNECTION_LIMITS +from ._exceptions import ( + APIError, + ConflictError, + NotFoundError, + APIStatusError, + RateLimitError, + APITimeoutError, + BadRequestError, + APIConnectionError, + BeeperDesktopError, + AuthenticationError, + InternalServerError, + PermissionDeniedError, + UnprocessableEntityError, + APIResponseValidationError, +) +from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient +from ._utils._logs import setup_logging as _setup_logging + +__all__ = [ + "types", + "__version__", + "__title__", + "NoneType", + "Transport", + "ProxiesTypes", + "NotGiven", + "NOT_GIVEN", + "not_given", + "Omit", + "omit", + "BeeperDesktopError", + "APIError", + "APIStatusError", + "APITimeoutError", + "APIConnectionError", + "APIResponseValidationError", + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", + "Timeout", + "RequestOptions", + "Client", + "AsyncClient", + "Stream", + "AsyncStream", + "BeeperDesktop", + "AsyncBeeperDesktop", + "file_from_path", + "BaseModel", + "DEFAULT_TIMEOUT", + "DEFAULT_MAX_RETRIES", + "DEFAULT_CONNECTION_LIMITS", + "DefaultHttpxClient", + "DefaultAsyncHttpxClient", + "DefaultAioHttpClient", +] + +if not _t.TYPE_CHECKING: + from ._utils._resources_proxy import resources as resources + +_setup_logging() + +# Update the __module__ attribute for exported symbols so that +# error messages point to this module instead of the module +# it was originally defined in, e.g. +# beeper_desktop_api._exceptions.NotFoundError -> beeper_desktop_api.NotFoundError +__locals = locals() +for __name in __all__: + if not __name.startswith("__"): + try: + __locals[__name].__module__ = "beeper_desktop_api" + except (TypeError, AttributeError): + # Some of our exported symbols are builtins which we can't set attributes for. + pass diff --git a/src/beeper_desktop_api/_base_client.py b/src/beeper_desktop_api/_base_client.py new file mode 100644 index 0000000..16539a1 --- /dev/null +++ b/src/beeper_desktop_api/_base_client.py @@ -0,0 +1,1995 @@ +from __future__ import annotations + +import sys +import json +import time +import uuid +import email +import asyncio +import inspect +import logging +import platform +import email.utils +from types import TracebackType +from random import random +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Type, + Union, + Generic, + Mapping, + TypeVar, + Iterable, + Iterator, + Optional, + Generator, + AsyncIterator, + cast, + overload, +) +from typing_extensions import Literal, override, get_origin + +import anyio +import httpx +import distro +import pydantic +from httpx import URL +from pydantic import PrivateAttr + +from . import _exceptions +from ._qs import Querystring +from ._files import to_httpx_files, async_to_httpx_files +from ._types import ( + Body, + Omit, + Query, + Headers, + Timeout, + NotGiven, + ResponseT, + AnyMapping, + PostParser, + RequestFiles, + HttpxSendArgs, + RequestOptions, + HttpxRequestFiles, + ModelBuilderProtocol, + not_given, +) +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 ._response import ( + APIResponse, + BaseAPIResponse, + AsyncAPIResponse, + extract_response_type, +) +from ._constants import ( + DEFAULT_TIMEOUT, + MAX_RETRY_DELAY, + DEFAULT_MAX_RETRIES, + INITIAL_RETRY_DELAY, + RAW_RESPONSE_HEADER, + OVERRIDE_CAST_TO_HEADER, + DEFAULT_CONNECTION_LIMITS, +) +from ._streaming import Stream, SSEDecoder, AsyncStream, SSEBytesDecoder +from ._exceptions import ( + APIStatusError, + APITimeoutError, + APIConnectionError, + APIResponseValidationError, +) + +log: logging.Logger = logging.getLogger(__name__) + +# TODO: make base page type vars covariant +SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]") +AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]") + + +_T = TypeVar("_T") +_T_co = TypeVar("_T_co", covariant=True) + +_StreamT = TypeVar("_StreamT", bound=Stream[Any]) +_AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any]) + +if TYPE_CHECKING: + from httpx._config import ( + DEFAULT_TIMEOUT_CONFIG, # pyright: ignore[reportPrivateImportUsage] + ) + + HTTPX_DEFAULT_TIMEOUT = DEFAULT_TIMEOUT_CONFIG +else: + try: + from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT + except ImportError: + # taken from https://github.com/encode/httpx/blob/3ba5fe0d7ac70222590e759c31442b1cab263791/httpx/_config.py#L366 + HTTPX_DEFAULT_TIMEOUT = Timeout(5.0) + + +class PageInfo: + """Stores the necessary information to build the request to retrieve the next page. + + Either `url` or `params` must be set. + """ + + url: URL | NotGiven + params: Query | NotGiven + json: Body | NotGiven + + @overload + def __init__( + self, + *, + url: URL, + ) -> None: ... + + @overload + def __init__( + self, + *, + params: Query, + ) -> None: ... + + @overload + def __init__( + self, + *, + json: Body, + ) -> None: ... + + def __init__( + self, + *, + url: URL | NotGiven = not_given, + json: Body | NotGiven = not_given, + params: Query | NotGiven = not_given, + ) -> None: + self.url = url + self.json = json + self.params = params + + @override + def __repr__(self) -> str: + if self.url: + return f"{self.__class__.__name__}(url={self.url})" + if self.json: + return f"{self.__class__.__name__}(json={self.json})" + return f"{self.__class__.__name__}(params={self.params})" + + +class BasePage(GenericModel, Generic[_T]): + """ + Defines the core interface for pagination. + + Type Args: + ModelT: The pydantic model that represents an item in the response. + + Methods: + has_next_page(): Check if there is another page available + next_page_info(): Get the necessary information to make a request for the next page + """ + + _options: FinalRequestOptions = PrivateAttr() + _model: Type[_T] = PrivateAttr() + + def has_next_page(self) -> bool: + items = self._get_page_items() + if not items: + return False + return self.next_page_info() is not None + + def next_page_info(self) -> Optional[PageInfo]: ... + + def _get_page_items(self) -> Iterable[_T]: # type: ignore[empty-body] + ... + + def _params_from_url(self, url: URL) -> httpx.QueryParams: + # TODO: do we have to preprocess params here? + return httpx.QueryParams(cast(Any, self._options.params)).merge(url.params) + + def _info_to_options(self, info: PageInfo) -> FinalRequestOptions: + options = model_copy(self._options) + options._strip_raw_response_header() + + if not isinstance(info.params, NotGiven): + options.params = {**options.params, **info.params} + return options + + if not isinstance(info.url, NotGiven): + params = self._params_from_url(info.url) + url = info.url.copy_with(params=params) + options.params = dict(url.params) + options.url = str(url) + return options + + if not isinstance(info.json, NotGiven): + if not is_mapping(info.json): + raise TypeError("Pagination is only supported with mappings") + + if not options.json_data: + options.json_data = {**info.json} + else: + if not is_mapping(options.json_data): + raise TypeError("Pagination is only supported with mappings") + + options.json_data = {**options.json_data, **info.json} + return options + + raise ValueError("Unexpected PageInfo state") + + +class BaseSyncPage(BasePage[_T], Generic[_T]): + _client: SyncAPIClient = pydantic.PrivateAttr() + + def _set_private_attributes( + self, + client: SyncAPIClient, + model: Type[_T], + options: FinalRequestOptions, + ) -> None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + + self._model = model + self._client = client + self._options = options + + # Pydantic uses a custom `__iter__` method to support casting BaseModels + # to dictionaries. e.g. dict(model). + # As we want to support `for item in page`, this is inherently incompatible + # with the default pydantic behaviour. It is not possible to support both + # use cases at once. Fortunately, this is not a big deal as all other pydantic + # methods should continue to work as expected as there is an alternative method + # to cast a model to a dictionary, model.dict(), which is used internally + # by pydantic. + def __iter__(self) -> Iterator[_T]: # type: ignore + for page in self.iter_pages(): + for item in page._get_page_items(): + yield item + + def iter_pages(self: SyncPageT) -> Iterator[SyncPageT]: + page = self + while True: + yield page + if page.has_next_page(): + page = page.get_next_page() + else: + return + + def get_next_page(self: SyncPageT) -> SyncPageT: + info = self.next_page_info() + if not info: + raise RuntimeError( + "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." + ) + + options = self._info_to_options(info) + return self._client._request_api_list(self._model, page=self.__class__, options=options) + + +class AsyncPaginator(Generic[_T, AsyncPageT]): + def __init__( + self, + client: AsyncAPIClient, + options: FinalRequestOptions, + page_cls: Type[AsyncPageT], + model: Type[_T], + ) -> None: + self._model = model + self._client = client + self._options = options + self._page_cls = page_cls + + def __await__(self) -> Generator[Any, None, AsyncPageT]: + return self._get_page().__await__() + + async def _get_page(self) -> AsyncPageT: + def _parser(resp: AsyncPageT) -> AsyncPageT: + resp._set_private_attributes( + model=self._model, + options=self._options, + client=self._client, + ) + return resp + + self._options.post_parser = _parser + + return await self._client.request(self._page_cls, self._options) + + async def __aiter__(self) -> AsyncIterator[_T]: + # https://github.com/microsoft/pyright/issues/3464 + page = cast( + AsyncPageT, + await self, # type: ignore + ) + async for item in page: + yield item + + +class BaseAsyncPage(BasePage[_T], Generic[_T]): + _client: AsyncAPIClient = pydantic.PrivateAttr() + + def _set_private_attributes( + self, + model: Type[_T], + client: AsyncAPIClient, + options: FinalRequestOptions, + ) -> None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + + self._model = model + self._client = client + self._options = options + + async def __aiter__(self) -> AsyncIterator[_T]: + async for page in self.iter_pages(): + for item in page._get_page_items(): + yield item + + async def iter_pages(self: AsyncPageT) -> AsyncIterator[AsyncPageT]: + page = self + while True: + yield page + if page.has_next_page(): + page = await page.get_next_page() + else: + return + + async def get_next_page(self: AsyncPageT) -> AsyncPageT: + info = self.next_page_info() + if not info: + raise RuntimeError( + "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." + ) + + options = self._info_to_options(info) + return await self._client._request_api_list(self._model, page=self.__class__, options=options) + + +_HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient]) +_DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]]) + + +class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]): + _client: _HttpxClientT + _version: str + _base_url: URL + max_retries: int + timeout: Union[float, Timeout, None] + _strict_response_validation: bool + _idempotency_header: str | None + _default_stream_cls: type[_DefaultStreamT] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + _strict_response_validation: bool, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None = DEFAULT_TIMEOUT, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + ) -> None: + self._version = version + self._base_url = self._enforce_trailing_slash(URL(base_url)) + self.max_retries = max_retries + self.timeout = timeout + self._custom_headers = custom_headers or {} + self._custom_query = custom_query or {} + self._strict_response_validation = _strict_response_validation + self._idempotency_header = None + self._platform: Platform | None = None + + if max_retries is None: # pyright: ignore[reportUnnecessaryComparison] + raise TypeError( + "max_retries cannot be None. If you want to disable retries, pass `0`; if you want unlimited retries, pass `math.inf` or a very high number; if you want the default behavior, pass `beeper_desktop_api.DEFAULT_MAX_RETRIES`" + ) + + def _enforce_trailing_slash(self, url: URL) -> URL: + if url.raw_path.endswith(b"/"): + return url + return url.copy_with(raw_path=url.raw_path + b"/") + + def _make_status_error_from_response( + self, + response: httpx.Response, + ) -> APIStatusError: + if response.is_closed and not response.is_stream_consumed: + # We can't read the response body as it has been closed + # before it was read. This can happen if an event hook + # raises a status error. + body = None + err_msg = f"Error code: {response.status_code}" + else: + err_text = response.text.strip() + body = err_text + + try: + body = json.loads(err_text) + err_msg = f"Error code: {response.status_code} - {body}" + except Exception: + err_msg = err_text or f"Error code: {response.status_code}" + + return self._make_status_error(err_msg, body=body, response=response) + + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> _exceptions.APIStatusError: + raise NotImplementedError() + + 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) + self._validate_headers(headers_dict, custom_headers) + + # headers are case-insensitive while dictionaries are not. + headers = httpx.Headers(headers_dict) + + idempotency_header = self._idempotency_header + if idempotency_header and options.idempotency_key and idempotency_header not in headers: + headers[idempotency_header] = options.idempotency_key + + # Don't set these headers if they were already set or removed by the caller. We check + # `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case. + lower_custom_headers = [header.lower() for header in custom_headers] + if "x-stainless-retry-count" not in lower_custom_headers: + headers["x-stainless-retry-count"] = str(retries_taken) + if "x-stainless-read-timeout" not in lower_custom_headers: + timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout + if isinstance(timeout, Timeout): + timeout = timeout.read + if timeout is not None: + headers["x-stainless-read-timeout"] = str(timeout) + + return headers + + def _prepare_url(self, url: str) -> URL: + """ + Merge a URL argument together with any 'base_url' on the client, + to create the URL used for the outgoing request. + """ + # Copied from httpx's `_merge_url` method. + merge_url = URL(url) + if merge_url.is_relative_url: + merge_raw_path = self.base_url.raw_path + merge_url.raw_path.lstrip(b"/") + return self.base_url.copy_with(raw_path=merge_raw_path) + + return merge_url + + def _make_sse_decoder(self) -> SSEDecoder | SSEBytesDecoder: + return SSEDecoder() + + def _build_request( + self, + options: FinalRequestOptions, + *, + retries_taken: int = 0, + ) -> httpx.Request: + if log.isEnabledFor(logging.DEBUG): + log.debug("Request options: %s", model_dump(options, exclude_unset=True)) + + kwargs: dict[str, Any] = {} + + json_data = options.json_data + if options.extra_json is not None: + if json_data is None: + json_data = cast(Body, options.extra_json) + elif is_mapping(json_data): + json_data = _merge_mappings(json_data, options.extra_json) + else: + 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) + content_type = headers.get("Content-Type") + files = options.files + + # If the given Content-Type header is multipart/form-data then it + # has to be removed so that httpx can generate the header with + # additional information for us as it has to be in this form + # for the server to be able to correctly parse the request: + # multipart/form-data; boundary=---abc-- + if content_type is not None and content_type.startswith("multipart/form-data"): + if "boundary" not in content_type: + # only remove the header if the boundary hasn't been explicitly set + # as the caller doesn't want httpx to come up with their own boundary + headers.pop("Content-Type") + + # As we are now sending multipart/form-data instead of application/json + # we need to tell httpx to use it, https://www.python-httpx.org/advanced/clients/#multipart-file-encoding + if json_data: + if not is_dict(json_data): + raise TypeError( + f"Expected query input to be a dictionary for multipart requests but got {type(json_data)} instead." + ) + kwargs["data"] = self._serialize_multipartform(json_data) + + # httpx determines whether or not to send a "multipart/form-data" + # request based on the truthiness of the "files" argument. + # This gets around that issue by generating a dict value that + # evaluates to true. + # + # https://github.com/encode/httpx/discussions/2399#discussioncomment-3814186 + if not files: + files = cast(HttpxRequestFiles, ForceMultipartDict()) + + prepared_url = self._prepare_url(options.url) + if "_" in prepared_url.host: + # work around https://github.com/encode/httpx/discussions/2880 + kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} + + is_body_allowed = options.method.lower() != "get" + + if is_body_allowed: + if isinstance(json_data, bytes): + kwargs["content"] = json_data + else: + kwargs["json"] = json_data if is_given(json_data) else None + kwargs["files"] = files + else: + headers.pop("Content-Type", None) + kwargs.pop("data", None) + + # TODO: report this error to httpx + return self._client.build_request( # pyright: ignore[reportUnknownMemberType] + headers=headers, + timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout, + method=options.method, + url=prepared_url, + # the `Query` type that we use is incompatible with qs' + # `Params` type as it needs to be typed as `Mapping[str, object]` + # so that passing a `TypedDict` doesn't cause an error. + # https://github.com/microsoft/pyright/issues/3526#event-6715453066 + params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, + **kwargs, + ) + + def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, object]: + items = self.qs.stringify_items( + # TODO: type ignore is required as stringify_items is well typed but we can't be + # well typed without heavy validation. + data, # type: ignore + array_format="brackets", + ) + serialized: dict[str, object] = {} + for key, value in items: + existing = serialized.get(key) + + if not existing: + serialized[key] = value + continue + + # If a value has already been set for this key then that + # means we're sending data like `array[]=[1, 2, 3]` and we + # need to tell httpx that we want to send multiple values with + # the same key which is done by using a list or a tuple. + # + # Note: 2d arrays should never result in the same key at both + # levels so it's safe to assume that if the value is a list, + # it was because we changed it to be a list. + if is_list(existing): + existing.append(value) + else: + serialized[key] = [existing, value] + + return serialized + + def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalRequestOptions) -> type[ResponseT]: + if not is_given(options.headers): + return cast_to + + # make a copy of the headers so we don't mutate user-input + headers = dict(options.headers) + + # we internally support defining a temporary header to override the + # default `cast_to` type for use with `.with_raw_response` and `.with_streaming_response` + # see _response.py for implementation details + override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, not_given) + if is_given(override_cast_to): + options.headers = headers + return cast(Type[ResponseT], override_cast_to) + + return cast_to + + def _should_stream_response_body(self, request: httpx.Request) -> bool: + return request.headers.get(RAW_RESPONSE_HEADER) == "stream" # type: ignore[no-any-return] + + def _process_response_data( + self, + *, + data: object, + cast_to: type[ResponseT], + response: httpx.Response, + ) -> ResponseT: + if data is None: + return cast(ResponseT, None) + + if cast_to is object: + return cast(ResponseT, data) + + try: + if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol): + return cast(ResponseT, cast_to.build(response=response, data=data)) + + if self._strict_response_validation: + return cast(ResponseT, validate_type(type_=cast_to, value=data)) + + return cast(ResponseT, construct_type(type_=cast_to, value=data)) + except pydantic.ValidationError as err: + raise APIResponseValidationError(response=response, body=data) from err + + @property + def qs(self) -> Querystring: + return Querystring() + + @property + def custom_auth(self) -> httpx.Auth | None: + return None + + @property + def auth_headers(self) -> dict[str, str]: + return {} + + @property + def default_headers(self) -> dict[str, str | Omit]: + return { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": self.user_agent, + **self.platform_headers(), + **self.auth_headers, + **self._custom_headers, + } + + @property + def default_query(self) -> dict[str, object]: + return { + **self._custom_query, + } + + def _validate_headers( + self, + headers: Headers, # noqa: ARG002 + custom_headers: Headers, # noqa: ARG002 + ) -> None: + """Validate the given default headers and custom headers. + + Does nothing by default. + """ + return + + @property + def user_agent(self) -> str: + return f"{self.__class__.__name__}/Python {self._version}" + + @property + def base_url(self) -> URL: + return self._base_url + + @base_url.setter + def base_url(self, url: URL | str) -> None: + self._base_url = self._enforce_trailing_slash(url if isinstance(url, URL) else URL(url)) + + def platform_headers(self) -> Dict[str, str]: + # the actual implementation is in a separate `lru_cache` decorated + # function because adding `lru_cache` to methods will leak memory + # https://github.com/python/cpython/issues/88476 + return platform_headers(self._version, platform=self._platform) + + def _parse_retry_after_header(self, response_headers: Optional[httpx.Headers] = None) -> float | None: + """Returns a float of the number of seconds (not milliseconds) to wait after retrying, or None if unspecified. + + About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After + See also https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After#syntax + """ + if response_headers is None: + return None + + # First, try the non-standard `retry-after-ms` header for milliseconds, + # which is more precise than integer-seconds `retry-after` + try: + retry_ms_header = response_headers.get("retry-after-ms", None) + return float(retry_ms_header) / 1000 + except (TypeError, ValueError): + pass + + # Next, try parsing `retry-after` header as seconds (allowing nonstandard floats). + retry_header = response_headers.get("retry-after") + try: + # note: the spec indicates that this should only ever be an integer + # but if someone sends a float there's no reason for us to not respect it + return float(retry_header) + except (TypeError, ValueError): + pass + + # Last, try parsing `retry-after` as a date. + retry_date_tuple = email.utils.parsedate_tz(retry_header) + if retry_date_tuple is None: + return None + + retry_date = email.utils.mktime_tz(retry_date_tuple) + return float(retry_date - time.time()) + + def _calculate_retry_timeout( + self, + remaining_retries: int, + options: FinalRequestOptions, + response_headers: Optional[httpx.Headers] = None, + ) -> float: + max_retries = options.get_max_retries(self.max_retries) + + # If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says. + retry_after = self._parse_retry_after_header(response_headers) + if retry_after is not None and 0 < retry_after <= 60: + return retry_after + + # Also cap retry count to 1000 to avoid any potential overflows with `pow` + nb_retries = min(max_retries - remaining_retries, 1000) + + # Apply exponential backoff, but not more than the max. + sleep_seconds = min(INITIAL_RETRY_DELAY * pow(2.0, nb_retries), MAX_RETRY_DELAY) + + # Apply some jitter, plus-or-minus half a second. + jitter = 1 - 0.25 * random() + timeout = sleep_seconds * jitter + return timeout if timeout >= 0 else 0 + + def _should_retry(self, response: httpx.Response) -> bool: + # Note: this is not a standard header + should_retry_header = response.headers.get("x-should-retry") + + # If the server explicitly says whether or not to retry, obey. + if should_retry_header == "true": + log.debug("Retrying as header `x-should-retry` is set to `true`") + return True + if should_retry_header == "false": + log.debug("Not retrying as header `x-should-retry` is set to `false`") + return False + + # Retry on request timeouts. + if response.status_code == 408: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry on lock timeouts. + if response.status_code == 409: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry on rate limits. + if response.status_code == 429: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry internal errors. + if response.status_code >= 500: + log.debug("Retrying due to status code %i", response.status_code) + return True + + log.debug("Not retrying") + return False + + def _idempotency_key(self) -> str: + return f"stainless-python-retry-{uuid.uuid4()}" + + +class _DefaultHttpxClient(httpx.Client): + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +if TYPE_CHECKING: + DefaultHttpxClient = httpx.Client + """An alias to `httpx.Client` that provides the same defaults that this SDK + uses internally. + + This is useful because overriding the `http_client` with your own instance of + `httpx.Client` will result in httpx's defaults being used, not ours. + """ +else: + DefaultHttpxClient = _DefaultHttpxClient + + +class SyncHttpxClientWrapper(DefaultHttpxClient): + def __del__(self) -> None: + if self.is_closed: + return + + try: + self.close() + except Exception: + pass + + +class SyncAPIClient(BaseClient[httpx.Client, Stream[Any]]): + _client: httpx.Client + _default_stream_cls: type[Stream[Any]] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None | NotGiven = not_given, + http_client: httpx.Client | None = None, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + _strict_response_validation: bool, + ) -> None: + if not is_given(timeout): + # if the user passed in a custom http client with a non-default + # timeout set then we use that timeout. + # + # note: there is an edge case here where the user passes in a client + # where they've explicitly set the timeout to match the default timeout + # as this check is structural, meaning that we'll think they didn't + # pass in a timeout and will ignore it + if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = http_client.timeout + else: + timeout = DEFAULT_TIMEOUT + + if http_client is not None and not isinstance(http_client, httpx.Client): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"Invalid `http_client` argument; Expected an instance of `httpx.Client` but got {type(http_client)}" + ) + + super().__init__( + version=version, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + base_url=base_url, + max_retries=max_retries, + custom_query=custom_query, + custom_headers=custom_headers, + _strict_response_validation=_strict_response_validation, + ) + self._client = http_client or SyncHttpxClientWrapper( + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + ) + + def is_closed(self) -> bool: + return self._client.is_closed + + def close(self) -> None: + """Close the underlying HTTPX client. + + The client will *not* be usable after this. + """ + # If an error is thrown while constructing a client, self._client + # may not be present + if hasattr(self, "_client"): + self._client.close() + + def __enter__(self: _T) -> _T: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def _prepare_options( + self, + options: FinalRequestOptions, # noqa: ARG002 + ) -> FinalRequestOptions: + """Hook for mutating the given options""" + return options + + def _prepare_request( + self, + request: httpx.Request, # noqa: ARG002 + ) -> None: + """This method is used as a callback for mutating the `Request` object + after it has been constructed. + This is useful for cases where you want to add certain headers based off of + the request properties, e.g. `url`, `method` etc. + """ + return None + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[True], + stream_cls: Type[_StreamT], + ) -> _StreamT: ... + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: Type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + cast_to = self._maybe_override_cast_to(cast_to, options) + + # create a copy of the options we were given so that if the + # options are mutated later & we then retry, the retries are + # given the original options + input_options = model_copy(options) + if input_options.idempotency_key is None and input_options.method.lower() != "get": + # ensure the idempotency key is reused between requests + input_options.idempotency_key = self._idempotency_key() + + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) + + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = self._prepare_options(options) + + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + self._prepare_request(request) + + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth + + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + + log.debug("Sending HTTP Request: %s %s", request.method, request.url) + + response = None + try: + response = self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, + ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + err.response.close() + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue + + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + err.response.read() + + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None + + break + + assert response is not None, "could not resolve response (should never happen)" + return self._process_response( + cast_to=cast_to, + options=options, + response=response, + stream=stream, + stream_cls=stream_cls, + retries_taken=retries_taken, + ) + + def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken + if remaining_retries == 1: + log.debug("1 retry left") + else: + log.debug("%i retries left", remaining_retries) + + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) + log.info("Retrying request to %s in %f seconds", options.url, timeout) + + time.sleep(timeout) + + def _process_response( + self, + *, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + response: httpx.Response, + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + retries_taken: int = 0, + ) -> ResponseT: + origin = get_origin(cast_to) or cast_to + + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): + if not issubclass(origin, APIResponse): + raise TypeError(f"API Response types must subclass {APIResponse}; Received {origin}") + + response_cls = cast("type[BaseAPIResponse[Any]]", cast_to) + return cast( + ResponseT, + response_cls( + raw=response, + client=self, + cast_to=extract_response_type(response_cls), + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ), + ) + + if cast_to == httpx.Response: + return cast(ResponseT, response) + + api_response = APIResponse( + raw=response, + client=self, + cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast] + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ) + if bool(response.request.headers.get(RAW_RESPONSE_HEADER)): + return cast(ResponseT, api_response) + + return api_response.parse() + + def _request_api_list( + self, + model: Type[object], + page: Type[SyncPageT], + options: FinalRequestOptions, + ) -> SyncPageT: + def _parser(resp: SyncPageT) -> SyncPageT: + resp._set_private_attributes( + client=self, + model=model, + options=options, + ) + return resp + + options.post_parser = _parser + + return self.request(page, options, stream=False) + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_StreamT], + ) -> _StreamT: ... + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + opts = FinalRequestOptions.construct(method="get", url=path, **options) + # cast is required because mypy complains about returning Any even though + # it understands the type variables + return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: Literal[True], + stream_cls: type[_StreamT], + ) -> _StreamT: ... + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: bool, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + opts = FinalRequestOptions.construct( + method="post", url=path, json_data=body, files=to_httpx_files(files), **options + ) + return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) + + def patch( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + return self.request(cast_to, opts) + + def put( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct( + method="put", url=path, json_data=body, files=to_httpx_files(files), **options + ) + return self.request(cast_to, opts) + + def delete( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + return self.request(cast_to, opts) + + def get_api_list( + self, + path: str, + *, + model: Type[object], + page: Type[SyncPageT], + body: Body | None = None, + options: RequestOptions = {}, + method: str = "get", + ) -> SyncPageT: + opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options) + return self._request_api_list(model, page, opts) + + +class _DefaultAsyncHttpxClient(httpx.AsyncClient): + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +try: + import httpx_aiohttp +except ImportError: + + class _DefaultAioHttpClient(httpx.AsyncClient): + def __init__(self, **_kwargs: Any) -> None: + raise RuntimeError("To use the aiohttp client you must have installed the package with the `aiohttp` extra") +else: + + class _DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + + super().__init__(**kwargs) + + +if TYPE_CHECKING: + DefaultAsyncHttpxClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that provides the same defaults that this SDK + uses internally. + + This is useful because overriding the `http_client` with your own instance of + `httpx.AsyncClient` will result in httpx's defaults being used, not ours. + """ + + DefaultAioHttpClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that changes the default HTTP transport to `aiohttp`.""" +else: + DefaultAsyncHttpxClient = _DefaultAsyncHttpxClient + DefaultAioHttpClient = _DefaultAioHttpClient + + +class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient): + def __del__(self) -> None: + if self.is_closed: + return + + try: + # TODO(someday): support non asyncio runtimes here + asyncio.get_running_loop().create_task(self.aclose()) + except Exception: + pass + + +class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]): + _client: httpx.AsyncClient + _default_stream_cls: type[AsyncStream[Any]] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + _strict_response_validation: bool, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None | NotGiven = not_given, + http_client: httpx.AsyncClient | None = None, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + ) -> None: + if not is_given(timeout): + # if the user passed in a custom http client with a non-default + # timeout set then we use that timeout. + # + # note: there is an edge case here where the user passes in a client + # where they've explicitly set the timeout to match the default timeout + # as this check is structural, meaning that we'll think they didn't + # pass in a timeout and will ignore it + if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = http_client.timeout + else: + timeout = DEFAULT_TIMEOUT + + if http_client is not None and not isinstance(http_client, httpx.AsyncClient): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` but got {type(http_client)}" + ) + + super().__init__( + version=version, + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + max_retries=max_retries, + custom_query=custom_query, + custom_headers=custom_headers, + _strict_response_validation=_strict_response_validation, + ) + self._client = http_client or AsyncHttpxClientWrapper( + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + ) + + def is_closed(self) -> bool: + return self._client.is_closed + + async def close(self) -> None: + """Close the underlying HTTPX client. + + The client will *not* be usable after this. + """ + await self._client.aclose() + + async def __aenter__(self: _T) -> _T: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() + + async def _prepare_options( + self, + options: FinalRequestOptions, # noqa: ARG002 + ) -> FinalRequestOptions: + """Hook for mutating the given options""" + return options + + async def _prepare_request( + self, + request: httpx.Request, # noqa: ARG002 + ) -> None: + """This method is used as a callback for mutating the `Request` object + after it has been constructed. + This is useful for cases where you want to add certain headers based off of + the request properties, e.g. `url`, `method` etc. + """ + return None + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + if self._platform is None: + # `get_platform` can make blocking IO calls so we + # execute it earlier while we are in an async context + self._platform = await asyncify(get_platform)() + + cast_to = self._maybe_override_cast_to(cast_to, options) + + # create a copy of the options we were given so that if the + # options are mutated later & we then retry, the retries are + # given the original options + input_options = model_copy(options) + if input_options.idempotency_key is None and input_options.method.lower() != "get": + # ensure the idempotency key is reused between requests + input_options.idempotency_key = self._idempotency_key() + + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) + + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = await self._prepare_options(options) + + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + await self._prepare_request(request) + + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth + + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + + log.debug("Sending HTTP Request: %s %s", request.method, request.url) + + response = None + try: + response = await self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, + ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + await err.response.aclose() + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue + + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + await err.response.aread() + + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None + + break + + assert response is not None, "could not resolve response (should never happen)" + return await self._process_response( + cast_to=cast_to, + options=options, + response=response, + stream=stream, + stream_cls=stream_cls, + retries_taken=retries_taken, + ) + + async def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken + if remaining_retries == 1: + log.debug("1 retry left") + else: + log.debug("%i retries left", remaining_retries) + + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) + log.info("Retrying request to %s in %f seconds", options.url, timeout) + + await anyio.sleep(timeout) + + async def _process_response( + self, + *, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + response: httpx.Response, + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + retries_taken: int = 0, + ) -> ResponseT: + origin = get_origin(cast_to) or cast_to + + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): + if not issubclass(origin, AsyncAPIResponse): + raise TypeError(f"API Response types must subclass {AsyncAPIResponse}; Received {origin}") + + response_cls = cast("type[BaseAPIResponse[Any]]", cast_to) + return cast( + "ResponseT", + response_cls( + raw=response, + client=self, + cast_to=extract_response_type(response_cls), + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ), + ) + + if cast_to == httpx.Response: + return cast(ResponseT, response) + + api_response = AsyncAPIResponse( + raw=response, + client=self, + cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast] + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ) + if bool(response.request.headers.get(RAW_RESPONSE_HEADER)): + return cast(ResponseT, api_response) + + return await api_response.parse() + + def _request_api_list( + self, + model: Type[_T], + page: Type[AsyncPageT], + options: FinalRequestOptions, + ) -> AsyncPaginator[_T, AsyncPageT]: + return AsyncPaginator(client=self, options=options, page_cls=page, model=model) + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + opts = FinalRequestOptions.construct(method="get", url=path, **options) + return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + opts = FinalRequestOptions.construct( + method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options + ) + return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) + + async def patch( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + return await self.request(cast_to, opts) + + async def put( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct( + method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options + ) + return await self.request(cast_to, opts) + + async def delete( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + return await self.request(cast_to, opts) + + def get_api_list( + self, + path: str, + *, + model: Type[_T], + page: Type[AsyncPageT], + body: Body | None = None, + options: RequestOptions = {}, + method: str = "get", + ) -> AsyncPaginator[_T, AsyncPageT]: + opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options) + return self._request_api_list(model, page, opts) + + +def make_request_options( + *, + query: Query | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + idempotency_key: str | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + post_parser: PostParser | NotGiven = not_given, +) -> RequestOptions: + """Create a dict of type RequestOptions without keys of NotGiven values.""" + options: RequestOptions = {} + if extra_headers is not None: + options["headers"] = extra_headers + + if extra_body is not None: + options["extra_json"] = cast(AnyMapping, extra_body) + + if query is not None: + options["params"] = query + + if extra_query is not None: + options["params"] = {**options.get("params", {}), **extra_query} + + if not isinstance(timeout, NotGiven): + options["timeout"] = timeout + + if idempotency_key is not None: + options["idempotency_key"] = idempotency_key + + if is_given(post_parser): + # internal + options["post_parser"] = post_parser # type: ignore + + return options + + +class ForceMultipartDict(Dict[str, None]): + def __bool__(self) -> bool: + return True + + +class OtherPlatform: + def __init__(self, name: str) -> None: + self.name = name + + @override + def __str__(self) -> str: + return f"Other:{self.name}" + + +Platform = Union[ + OtherPlatform, + Literal[ + "MacOS", + "Linux", + "Windows", + "FreeBSD", + "OpenBSD", + "iOS", + "Android", + "Unknown", + ], +] + + +def get_platform() -> Platform: + try: + system = platform.system().lower() + platform_name = platform.platform().lower() + except Exception: + return "Unknown" + + if "iphone" in platform_name or "ipad" in platform_name: + # Tested using Python3IDE on an iPhone 11 and Pythonista on an iPad 7 + # system is Darwin and platform_name is a string like: + # - Darwin-21.6.0-iPhone12,1-64bit + # - Darwin-21.6.0-iPad7,11-64bit + return "iOS" + + if system == "darwin": + return "MacOS" + + if system == "windows": + return "Windows" + + if "android" in platform_name: + # Tested using Pydroid 3 + # system is Linux and platform_name is a string like 'Linux-5.10.81-android12-9-00001-geba40aecb3b7-ab8534902-aarch64-with-libc' + return "Android" + + if system == "linux": + # https://distro.readthedocs.io/en/latest/#distro.id + distro_id = distro.id() + if distro_id == "freebsd": + return "FreeBSD" + + if distro_id == "openbsd": + return "OpenBSD" + + return "Linux" + + if platform_name: + return OtherPlatform(platform_name) + + return "Unknown" + + +@lru_cache(maxsize=None) +def platform_headers(version: str, *, platform: Platform | None) -> Dict[str, str]: + return { + "X-Stainless-Lang": "python", + "X-Stainless-Package-Version": version, + "X-Stainless-OS": str(platform or get_platform()), + "X-Stainless-Arch": str(get_architecture()), + "X-Stainless-Runtime": get_python_runtime(), + "X-Stainless-Runtime-Version": get_python_version(), + } + + +class OtherArch: + def __init__(self, name: str) -> None: + self.name = name + + @override + def __str__(self) -> str: + return f"other:{self.name}" + + +Arch = Union[OtherArch, Literal["x32", "x64", "arm", "arm64", "unknown"]] + + +def get_python_runtime() -> str: + try: + return platform.python_implementation() + except Exception: + return "unknown" + + +def get_python_version() -> str: + try: + return platform.python_version() + except Exception: + return "unknown" + + +def get_architecture() -> Arch: + try: + machine = platform.machine().lower() + except Exception: + return "unknown" + + if machine in ("arm64", "aarch64"): + return "arm64" + + # TODO: untested + if machine == "arm": + return "arm" + + if machine == "x86_64": + return "x64" + + # TODO: untested + if sys.maxsize <= 2**32: + return "x32" + + if machine: + return OtherArch(machine) + + return "unknown" + + +def _merge_mappings( + obj1: Mapping[_T_co, Union[_T, Omit]], + obj2: Mapping[_T_co, Union[_T, Omit]], +) -> Dict[_T_co, _T]: + """Merge two mappings of the same type, removing any values that are instances of `Omit`. + + In cases with duplicate keys the second mapping takes precedence. + """ + merged = {**obj1, **obj2} + return {key: value for key, value in merged.items() if not isinstance(value, Omit)} diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py new file mode 100644 index 0000000..309a7fa --- /dev/null +++ b/src/beeper_desktop_api/_client.py @@ -0,0 +1,750 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, Mapping +from typing_extensions import Self, override + +import httpx + +from . import _exceptions +from ._qs import Querystring +from .types import client_open_params, client_search_params, client_download_asset_params +from ._types import ( + Body, + Omit, + Query, + Headers, + Timeout, + NotGiven, + Transport, + ProxiesTypes, + RequestOptions, + omit, + not_given, +) +from ._utils import ( + is_given, + maybe_transform, + get_async_library, + async_maybe_transform, +) +from ._version import __version__ +from ._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .resources import token, accounts, contacts, messages +from ._streaming import Stream as Stream, AsyncStream as AsyncStream +from ._exceptions import APIStatusError, BeeperDesktopError +from ._base_client import ( + DEFAULT_MAX_RETRIES, + SyncAPIClient, + AsyncAPIClient, + make_request_options, +) +from .resources.chats import chats +from .types.open_response import OpenResponse +from .types.search_response import SearchResponse +from .types.download_asset_response import DownloadAssetResponse + +__all__ = [ + "Timeout", + "Transport", + "ProxiesTypes", + "RequestOptions", + "BeeperDesktop", + "AsyncBeeperDesktop", + "Client", + "AsyncClient", +] + + +class BeeperDesktop(SyncAPIClient): + accounts: accounts.AccountsResource + contacts: contacts.ContactsResource + chats: chats.ChatsResource + messages: messages.MessagesResource + token: token.TokenResource + with_raw_response: BeeperDesktopWithRawResponse + with_streaming_response: BeeperDesktopWithStreamedResponse + + # client options + access_token: str + + def __init__( + self, + *, + access_token: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + # Configure a custom httpx client. + # We provide a `DefaultHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. + # See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details. + http_client: httpx.Client | None = None, + # Enable or disable schema validation for data returned by the API. + # When enabled an error APIResponseValidationError is raised + # if the API responds with invalid data for the expected schema. + # + # This parameter may be removed or changed in the future. + # If you rely on this feature, please open a GitHub issue + # outlining your use-case to help us decide if it should be + # part of our public interface in the future. + _strict_response_validation: bool = False, + ) -> None: + """Construct a new synchronous BeeperDesktop client instance. + + This automatically infers the `access_token` argument from the `BEEPER_ACCESS_TOKEN` environment variable if it is not provided. + """ + if access_token is None: + access_token = os.environ.get("BEEPER_ACCESS_TOKEN") + if access_token is None: + raise BeeperDesktopError( + "The access_token client option must be set either by passing access_token to the client or by setting the BEEPER_ACCESS_TOKEN environment variable" + ) + self.access_token = access_token + + if base_url is None: + base_url = os.environ.get("BEEPER_DESKTOP_BASE_URL") + if base_url is None: + base_url = f"http://localhost:23373" + + super().__init__( + version=__version__, + base_url=base_url, + max_retries=max_retries, + timeout=timeout, + http_client=http_client, + custom_headers=default_headers, + custom_query=default_query, + _strict_response_validation=_strict_response_validation, + ) + + self.accounts = accounts.AccountsResource(self) + self.contacts = contacts.ContactsResource(self) + self.chats = chats.ChatsResource(self) + self.messages = messages.MessagesResource(self) + self.token = token.TokenResource(self) + self.with_raw_response = BeeperDesktopWithRawResponse(self) + self.with_streaming_response = BeeperDesktopWithStreamedResponse(self) + + @property + @override + def qs(self) -> Querystring: + return Querystring(array_format="repeat") + + @property + @override + def auth_headers(self) -> dict[str, str]: + access_token = self.access_token + return {"Authorization": f"Bearer {access_token}"} + + @property + @override + def default_headers(self) -> dict[str, str | Omit]: + return { + **super().default_headers, + "X-Stainless-Async": "false", + **self._custom_headers, + } + + def copy( + self, + *, + access_token: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + http_client: httpx.Client | None = None, + max_retries: int | NotGiven = not_given, + default_headers: Mapping[str, str] | None = None, + set_default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + set_default_query: Mapping[str, object] | None = None, + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: + """ + Create a new client instance re-using the same options given to the current client with optional overriding. + """ + if default_headers is not None and set_default_headers is not None: + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") + + if default_query is not None and set_default_query is not None: + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") + + headers = self._custom_headers + if default_headers is not None: + headers = {**headers, **default_headers} + elif set_default_headers is not None: + headers = set_default_headers + + params = self._custom_query + if default_query is not None: + params = {**params, **default_query} + elif set_default_query is not None: + params = set_default_query + + http_client = http_client or self._client + return self.__class__( + access_token=access_token or self.access_token, + base_url=base_url or self.base_url, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy + + def download_asset( + self, + *, + url: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DownloadAssetResponse: + """ + Download a Matrix asset using its mxc:// or localmxc:// URL and return the local + file URL. + + Args: + url: Matrix content URL (mxc:// or localmxc://) for the asset to download. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self.post( + "/v0/download-asset", + body=maybe_transform({"url": url}, client_download_asset_params.ClientDownloadAssetParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DownloadAssetResponse, + ) + + def open( + self, + *, + chat_id: str | Omit = omit, + draft_attachment_path: str | Omit = omit, + draft_text: str | Omit = omit, + message_id: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OpenResponse: + """ + Open Beeper Desktop and optionally navigate to a specific chat, message, or + pre-fill draft text and attachment. + + Args: + chat_id: Optional Beeper chat ID (or local chat ID) to focus after opening the app. If + omitted, only opens/focuses the app. + + draft_attachment_path: Optional draft attachment path to populate in the message input field. + + draft_text: Optional draft text to populate in the message input field. + + message_id: Optional message ID. Jumps to that message in the chat when opening. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self.post( + "/v0/open-app", + body=maybe_transform( + { + "chat_id": chat_id, + "draft_attachment_path": draft_attachment_path, + "draft_text": draft_text, + "message_id": message_id, + }, + client_open_params.ClientOpenParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=OpenResponse, + ) + + def search( + self, + *, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SearchResponse: + """ + Returns matching chats, participant name matches in groups, and the first page + of messages in one call. Paginate messages via search-messages. Paginate chats + via search-chats. Uses the same sorting as the chat search in the app. + + Args: + query: User-typed search text. Literal word matching (NOT semantic). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self.get( + "/v0/search", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"query": query}, client_search_params.ClientSearchParams), + ), + cast_to=SearchResponse, + ) + + @override + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + + +class AsyncBeeperDesktop(AsyncAPIClient): + accounts: accounts.AsyncAccountsResource + contacts: contacts.AsyncContactsResource + chats: chats.AsyncChatsResource + messages: messages.AsyncMessagesResource + token: token.AsyncTokenResource + with_raw_response: AsyncBeeperDesktopWithRawResponse + with_streaming_response: AsyncBeeperDesktopWithStreamedResponse + + # client options + access_token: str + + def __init__( + self, + *, + access_token: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + # Configure a custom httpx client. + # We provide a `DefaultAsyncHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. + # See the [httpx documentation](https://www.python-httpx.org/api/#asyncclient) for more details. + http_client: httpx.AsyncClient | None = None, + # Enable or disable schema validation for data returned by the API. + # When enabled an error APIResponseValidationError is raised + # if the API responds with invalid data for the expected schema. + # + # This parameter may be removed or changed in the future. + # If you rely on this feature, please open a GitHub issue + # outlining your use-case to help us decide if it should be + # part of our public interface in the future. + _strict_response_validation: bool = False, + ) -> None: + """Construct a new async AsyncBeeperDesktop client instance. + + This automatically infers the `access_token` argument from the `BEEPER_ACCESS_TOKEN` environment variable if it is not provided. + """ + if access_token is None: + access_token = os.environ.get("BEEPER_ACCESS_TOKEN") + if access_token is None: + raise BeeperDesktopError( + "The access_token client option must be set either by passing access_token to the client or by setting the BEEPER_ACCESS_TOKEN environment variable" + ) + self.access_token = access_token + + if base_url is None: + base_url = os.environ.get("BEEPER_DESKTOP_BASE_URL") + if base_url is None: + base_url = f"http://localhost:23373" + + super().__init__( + version=__version__, + base_url=base_url, + max_retries=max_retries, + timeout=timeout, + http_client=http_client, + custom_headers=default_headers, + custom_query=default_query, + _strict_response_validation=_strict_response_validation, + ) + + self.accounts = accounts.AsyncAccountsResource(self) + self.contacts = contacts.AsyncContactsResource(self) + self.chats = chats.AsyncChatsResource(self) + self.messages = messages.AsyncMessagesResource(self) + self.token = token.AsyncTokenResource(self) + self.with_raw_response = AsyncBeeperDesktopWithRawResponse(self) + self.with_streaming_response = AsyncBeeperDesktopWithStreamedResponse(self) + + @property + @override + def qs(self) -> Querystring: + return Querystring(array_format="repeat") + + @property + @override + def auth_headers(self) -> dict[str, str]: + access_token = self.access_token + return {"Authorization": f"Bearer {access_token}"} + + @property + @override + def default_headers(self) -> dict[str, str | Omit]: + return { + **super().default_headers, + "X-Stainless-Async": f"async:{get_async_library()}", + **self._custom_headers, + } + + def copy( + self, + *, + access_token: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + http_client: httpx.AsyncClient | None = None, + max_retries: int | NotGiven = not_given, + default_headers: Mapping[str, str] | None = None, + set_default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + set_default_query: Mapping[str, object] | None = None, + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: + """ + Create a new client instance re-using the same options given to the current client with optional overriding. + """ + if default_headers is not None and set_default_headers is not None: + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") + + if default_query is not None and set_default_query is not None: + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") + + headers = self._custom_headers + if default_headers is not None: + headers = {**headers, **default_headers} + elif set_default_headers is not None: + headers = set_default_headers + + params = self._custom_query + if default_query is not None: + params = {**params, **default_query} + elif set_default_query is not None: + params = set_default_query + + http_client = http_client or self._client + return self.__class__( + access_token=access_token or self.access_token, + base_url=base_url or self.base_url, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy + + async def download_asset( + self, + *, + url: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DownloadAssetResponse: + """ + Download a Matrix asset using its mxc:// or localmxc:// URL and return the local + file URL. + + Args: + url: Matrix content URL (mxc:// or localmxc://) for the asset to download. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self.post( + "/v0/download-asset", + body=await async_maybe_transform({"url": url}, client_download_asset_params.ClientDownloadAssetParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DownloadAssetResponse, + ) + + async def open( + self, + *, + chat_id: str | Omit = omit, + draft_attachment_path: str | Omit = omit, + draft_text: str | Omit = omit, + message_id: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OpenResponse: + """ + Open Beeper Desktop and optionally navigate to a specific chat, message, or + pre-fill draft text and attachment. + + Args: + chat_id: Optional Beeper chat ID (or local chat ID) to focus after opening the app. If + omitted, only opens/focuses the app. + + draft_attachment_path: Optional draft attachment path to populate in the message input field. + + draft_text: Optional draft text to populate in the message input field. + + message_id: Optional message ID. Jumps to that message in the chat when opening. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self.post( + "/v0/open-app", + body=await async_maybe_transform( + { + "chat_id": chat_id, + "draft_attachment_path": draft_attachment_path, + "draft_text": draft_text, + "message_id": message_id, + }, + client_open_params.ClientOpenParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=OpenResponse, + ) + + async def search( + self, + *, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SearchResponse: + """ + Returns matching chats, participant name matches in groups, and the first page + of messages in one call. Paginate messages via search-messages. Paginate chats + via search-chats. Uses the same sorting as the chat search in the app. + + Args: + query: User-typed search text. Literal word matching (NOT semantic). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self.get( + "/v0/search", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"query": query}, client_search_params.ClientSearchParams), + ), + cast_to=SearchResponse, + ) + + @override + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + + +class BeeperDesktopWithRawResponse: + def __init__(self, client: BeeperDesktop) -> None: + self.accounts = accounts.AccountsResourceWithRawResponse(client.accounts) + self.contacts = contacts.ContactsResourceWithRawResponse(client.contacts) + self.chats = chats.ChatsResourceWithRawResponse(client.chats) + self.messages = messages.MessagesResourceWithRawResponse(client.messages) + self.token = token.TokenResourceWithRawResponse(client.token) + + self.download_asset = to_raw_response_wrapper( + client.download_asset, + ) + self.open = to_raw_response_wrapper( + client.open, + ) + self.search = to_raw_response_wrapper( + client.search, + ) + + +class AsyncBeeperDesktopWithRawResponse: + def __init__(self, client: AsyncBeeperDesktop) -> None: + self.accounts = accounts.AsyncAccountsResourceWithRawResponse(client.accounts) + self.contacts = contacts.AsyncContactsResourceWithRawResponse(client.contacts) + self.chats = chats.AsyncChatsResourceWithRawResponse(client.chats) + self.messages = messages.AsyncMessagesResourceWithRawResponse(client.messages) + self.token = token.AsyncTokenResourceWithRawResponse(client.token) + + self.download_asset = async_to_raw_response_wrapper( + client.download_asset, + ) + self.open = async_to_raw_response_wrapper( + client.open, + ) + self.search = async_to_raw_response_wrapper( + client.search, + ) + + +class BeeperDesktopWithStreamedResponse: + def __init__(self, client: BeeperDesktop) -> None: + self.accounts = accounts.AccountsResourceWithStreamingResponse(client.accounts) + self.contacts = contacts.ContactsResourceWithStreamingResponse(client.contacts) + self.chats = chats.ChatsResourceWithStreamingResponse(client.chats) + self.messages = messages.MessagesResourceWithStreamingResponse(client.messages) + self.token = token.TokenResourceWithStreamingResponse(client.token) + + self.download_asset = to_streamed_response_wrapper( + client.download_asset, + ) + self.open = to_streamed_response_wrapper( + client.open, + ) + self.search = to_streamed_response_wrapper( + client.search, + ) + + +class AsyncBeeperDesktopWithStreamedResponse: + def __init__(self, client: AsyncBeeperDesktop) -> None: + self.accounts = accounts.AsyncAccountsResourceWithStreamingResponse(client.accounts) + self.contacts = contacts.AsyncContactsResourceWithStreamingResponse(client.contacts) + self.chats = chats.AsyncChatsResourceWithStreamingResponse(client.chats) + self.messages = messages.AsyncMessagesResourceWithStreamingResponse(client.messages) + self.token = token.AsyncTokenResourceWithStreamingResponse(client.token) + + self.download_asset = async_to_streamed_response_wrapper( + client.download_asset, + ) + self.open = async_to_streamed_response_wrapper( + client.open, + ) + self.search = async_to_streamed_response_wrapper( + client.search, + ) + + +Client = BeeperDesktop + +AsyncClient = AsyncBeeperDesktop diff --git a/src/beeper_desktop_api/_compat.py b/src/beeper_desktop_api/_compat.py new file mode 100644 index 0000000..bdef67f --- /dev/null +++ b/src/beeper_desktop_api/_compat.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload +from datetime import date, datetime +from typing_extensions import Self, Literal + +import pydantic +from pydantic.fields import FieldInfo + +from ._types import IncEx, StrBytesIntFloat + +_T = TypeVar("_T") +_ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel) + +# --------------- Pydantic v2, v3 compatibility --------------- + +# Pyright incorrectly reports some of our functions as overriding a method when they don't +# pyright: reportIncompatibleMethodOverride=false + +PYDANTIC_V1 = pydantic.VERSION.startswith("1.") + +if TYPE_CHECKING: + + def parse_date(value: date | StrBytesIntFloat) -> date: # noqa: ARG001 + ... + + def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: # noqa: ARG001 + ... + + def get_args(t: type[Any]) -> tuple[Any, ...]: # noqa: ARG001 + ... + + def is_union(tp: type[Any] | None) -> bool: # noqa: ARG001 + ... + + def get_origin(t: type[Any]) -> type[Any] | None: # noqa: ARG001 + ... + + def is_literal_type(type_: type[Any]) -> bool: # noqa: ARG001 + ... + + def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001 + ... + +else: + # v1 re-exports + if PYDANTIC_V1: + from pydantic.typing import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, + ) + from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + else: + from ._utils import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + parse_date as parse_date, + is_typeddict as is_typeddict, + parse_datetime as parse_datetime, + is_literal_type as is_literal_type, + ) + + +# refactored config +if TYPE_CHECKING: + from pydantic import ConfigDict as ConfigDict +else: + if PYDANTIC_V1: + # TODO: provide an error message here? + ConfigDict = None + else: + from pydantic import ConfigDict as ConfigDict + + +# renamed methods / properties +def parse_obj(model: type[_ModelT], value: object) -> _ModelT: + if PYDANTIC_V1: + return cast(_ModelT, model.parse_obj(value)) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + else: + return model.model_validate(value) + + +def field_is_required(field: FieldInfo) -> bool: + if PYDANTIC_V1: + return field.required # type: ignore + return field.is_required() + + +def field_get_default(field: FieldInfo) -> Any: + value = field.get_default() + if PYDANTIC_V1: + return value + from pydantic_core import PydanticUndefined + + if value == PydanticUndefined: + return None + return value + + +def field_outer_type(field: FieldInfo) -> Any: + if PYDANTIC_V1: + return field.outer_type_ # type: ignore + return field.annotation + + +def get_model_config(model: type[pydantic.BaseModel]) -> Any: + if PYDANTIC_V1: + return model.__config__ # type: ignore + return model.model_config + + +def get_model_fields(model: type[pydantic.BaseModel]) -> dict[str, FieldInfo]: + if PYDANTIC_V1: + return model.__fields__ # type: ignore + return model.model_fields + + +def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT: + if PYDANTIC_V1: + return model.copy(deep=deep) # type: ignore + return model.model_copy(deep=deep) + + +def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: + if PYDANTIC_V1: + return model.json(indent=indent) # type: ignore + return model.model_dump_json(indent=indent) + + +def model_dump( + model: pydantic.BaseModel, + *, + exclude: IncEx | None = None, + exclude_unset: bool = False, + exclude_defaults: bool = False, + warnings: bool = True, + mode: Literal["json", "python"] = "python", +) -> dict[str, Any]: + if (not PYDANTIC_V1) or hasattr(model, "model_dump"): + return model.model_dump( + mode=mode, + exclude=exclude, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + # warnings are not supported in Pydantic v1 + warnings=True if PYDANTIC_V1 else warnings, + ) + return cast( + "dict[str, Any]", + model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + exclude=exclude, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + ), + ) + + +def model_parse(model: type[_ModelT], data: Any) -> _ModelT: + if PYDANTIC_V1: + return model.parse_obj(data) # pyright: ignore[reportDeprecated] + return model.model_validate(data) + + +# generic models +if TYPE_CHECKING: + + class GenericModel(pydantic.BaseModel): ... + +else: + if PYDANTIC_V1: + import pydantic.generics + + class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... + else: + # there no longer needs to be a distinction in v2 but + # we still have to create our own subclass to avoid + # inconsistent MRO ordering errors + class GenericModel(pydantic.BaseModel): ... + + +# cached properties +if TYPE_CHECKING: + cached_property = property + + # we define a separate type (copied from typeshed) + # that represents that `cached_property` is `set`able + # at runtime, which differs from `@property`. + # + # this is a separate type as editors likely special case + # `@property` and we don't want to cause issues just to have + # more helpful internal types. + + class typed_cached_property(Generic[_T]): + func: Callable[[Any], _T] + attrname: str | None + + def __init__(self, func: Callable[[Any], _T]) -> None: ... + + @overload + def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ... + + @overload + def __get__(self, instance: object, owner: type[Any] | None = None) -> _T: ... + + def __get__(self, instance: object, owner: type[Any] | None = None) -> _T | Self: + raise NotImplementedError() + + def __set_name__(self, owner: type[Any], name: str) -> None: ... + + # __set__ is not defined at runtime, but @cached_property is designed to be settable + def __set__(self, instance: object, value: _T) -> None: ... +else: + from functools import cached_property as cached_property + + typed_cached_property = cached_property diff --git a/src/beeper_desktop_api/_constants.py b/src/beeper_desktop_api/_constants.py new file mode 100644 index 0000000..b57f90b --- /dev/null +++ b/src/beeper_desktop_api/_constants.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import httpx + +RAW_RESPONSE_HEADER = "X-Stainless-Raw-Response" +OVERRIDE_CAST_TO_HEADER = "____stainless_override_cast_to" + +# default timeout is 30 seconds +DEFAULT_TIMEOUT = httpx.Timeout(timeout=30, connect=5.0) +DEFAULT_MAX_RETRIES = 2 +DEFAULT_CONNECTION_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20) + +INITIAL_RETRY_DELAY = 0.5 +MAX_RETRY_DELAY = 8.0 diff --git a/src/beeper_desktop_api/_exceptions.py b/src/beeper_desktop_api/_exceptions.py new file mode 100644 index 0000000..ce04c57 --- /dev/null +++ b/src/beeper_desktop_api/_exceptions.py @@ -0,0 +1,108 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal + +import httpx + +__all__ = [ + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", +] + + +class BeeperDesktopError(Exception): + pass + + +class APIError(BeeperDesktopError): + message: str + request: httpx.Request + + body: object | None + """The API response body. + + If the API responded with a valid JSON structure then this property will be the + decoded result. + + If it isn't a valid JSON structure then this will be the raw response. + + If there was no response associated with this error then it will be `None`. + """ + + def __init__(self, message: str, request: httpx.Request, *, body: object | None) -> None: # noqa: ARG002 + super().__init__(message) + self.request = request + self.message = message + self.body = body + + +class APIResponseValidationError(APIError): + response: httpx.Response + status_code: int + + def __init__(self, response: httpx.Response, body: object | None, *, message: str | None = None) -> None: + super().__init__(message or "Data returned by API invalid for expected schema.", response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIStatusError(APIError): + """Raised when an API response has a status code of 4xx or 5xx.""" + + response: httpx.Response + status_code: int + + def __init__(self, message: str, *, response: httpx.Response, body: object | None) -> None: + super().__init__(message, response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIConnectionError(APIError): + def __init__(self, *, message: str = "Connection error.", request: httpx.Request) -> None: + super().__init__(message, request, body=None) + + +class APITimeoutError(APIConnectionError): + def __init__(self, request: httpx.Request) -> None: + super().__init__(message="Request timed out.", request=request) + + +class BadRequestError(APIStatusError): + status_code: Literal[400] = 400 # pyright: ignore[reportIncompatibleVariableOverride] + + +class AuthenticationError(APIStatusError): + status_code: Literal[401] = 401 # pyright: ignore[reportIncompatibleVariableOverride] + + +class PermissionDeniedError(APIStatusError): + status_code: Literal[403] = 403 # pyright: ignore[reportIncompatibleVariableOverride] + + +class NotFoundError(APIStatusError): + status_code: Literal[404] = 404 # pyright: ignore[reportIncompatibleVariableOverride] + + +class ConflictError(APIStatusError): + status_code: Literal[409] = 409 # pyright: ignore[reportIncompatibleVariableOverride] + + +class UnprocessableEntityError(APIStatusError): + status_code: Literal[422] = 422 # pyright: ignore[reportIncompatibleVariableOverride] + + +class RateLimitError(APIStatusError): + status_code: Literal[429] = 429 # pyright: ignore[reportIncompatibleVariableOverride] + + +class InternalServerError(APIStatusError): + pass diff --git a/src/beeper_desktop_api/_files.py b/src/beeper_desktop_api/_files.py new file mode 100644 index 0000000..cc14c14 --- /dev/null +++ b/src/beeper_desktop_api/_files.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import io +import os +import pathlib +from typing import overload +from typing_extensions import TypeGuard + +import anyio + +from ._types import ( + FileTypes, + FileContent, + RequestFiles, + HttpxFileTypes, + Base64FileInput, + HttpxFileContent, + HttpxRequestFiles, +) +from ._utils import is_tuple_t, is_mapping_t, is_sequence_t + + +def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: + return isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) + + +def is_file_content(obj: object) -> TypeGuard[FileContent]: + return ( + isinstance(obj, bytes) or isinstance(obj, tuple) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) + ) + + +def assert_is_file_content(obj: object, *, key: str | None = None) -> None: + if not is_file_content(obj): + prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`" + raise RuntimeError( + f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead." + ) from None + + +@overload +def to_httpx_files(files: None) -> None: ... + + +@overload +def to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... + + +def to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: + if files is None: + return None + + if is_mapping_t(files): + files = {key: _transform_file(file) for key, file in files.items()} + elif is_sequence_t(files): + files = [(key, _transform_file(file)) for key, file in files] + else: + raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence") + + return files + + +def _transform_file(file: FileTypes) -> HttpxFileTypes: + if is_file_content(file): + if isinstance(file, os.PathLike): + path = pathlib.Path(file) + return (path.name, path.read_bytes()) + + return file + + if is_tuple_t(file): + return (file[0], read_file_content(file[1]), *file[2:]) + + raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") + + +def read_file_content(file: FileContent) -> HttpxFileContent: + if isinstance(file, os.PathLike): + return pathlib.Path(file).read_bytes() + return file + + +@overload +async def async_to_httpx_files(files: None) -> None: ... + + +@overload +async def async_to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... + + +async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: + if files is None: + return None + + if is_mapping_t(files): + files = {key: await _async_transform_file(file) for key, file in files.items()} + elif is_sequence_t(files): + files = [(key, await _async_transform_file(file)) for key, file in files] + else: + raise TypeError("Unexpected file type input {type(files)}, expected mapping or sequence") + + return files + + +async def _async_transform_file(file: FileTypes) -> HttpxFileTypes: + if is_file_content(file): + if isinstance(file, os.PathLike): + path = anyio.Path(file) + return (path.name, await path.read_bytes()) + + return file + + if is_tuple_t(file): + return (file[0], await async_read_file_content(file[1]), *file[2:]) + + raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") + + +async def async_read_file_content(file: FileContent) -> HttpxFileContent: + if isinstance(file, os.PathLike): + return await anyio.Path(file).read_bytes() + + return file diff --git a/src/beeper_desktop_api/_models.py b/src/beeper_desktop_api/_models.py new file mode 100644 index 0000000..6a3cd1d --- /dev/null +++ b/src/beeper_desktop_api/_models.py @@ -0,0 +1,835 @@ +from __future__ import annotations + +import os +import inspect +from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast +from datetime import date, datetime +from typing_extensions import ( + List, + Unpack, + Literal, + ClassVar, + Protocol, + Required, + ParamSpec, + TypedDict, + TypeGuard, + final, + override, + runtime_checkable, +) + +import pydantic +from pydantic.fields import FieldInfo + +from ._types import ( + Body, + IncEx, + Query, + ModelT, + Headers, + Timeout, + NotGiven, + AnyMapping, + HttpxRequestFiles, +) +from ._utils import ( + PropertyInfo, + is_list, + is_given, + json_safe, + lru_cache, + is_mapping, + parse_date, + coerce_boolean, + parse_datetime, + strip_not_given, + extract_type_arg, + is_annotated_type, + is_type_alias_type, + strip_annotated_type, +) +from ._compat import ( + PYDANTIC_V1, + ConfigDict, + GenericModel as BaseGenericModel, + get_args, + is_union, + parse_obj, + get_origin, + is_literal_type, + get_model_config, + get_model_fields, + field_get_default, +) +from ._constants import RAW_RESPONSE_HEADER + +if TYPE_CHECKING: + from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema + +__all__ = ["BaseModel", "GenericModel"] + +_T = TypeVar("_T") +_BaseModelT = TypeVar("_BaseModelT", bound="BaseModel") + +P = ParamSpec("P") + + +@runtime_checkable +class _ConfigProtocol(Protocol): + allow_population_by_field_name: bool + + +class BaseModel(pydantic.BaseModel): + if PYDANTIC_V1: + + @property + @override + def model_fields_set(self) -> set[str]: + # a forwards-compat shim for pydantic v2 + return self.__fields_set__ # type: ignore + + class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] + extra: Any = pydantic.Extra.allow # type: ignore + else: + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) + ) + + def to_dict( + self, + *, + mode: Literal["json", "python"] = "python", + use_api_names: bool = True, + exclude_unset: bool = True, + exclude_defaults: bool = False, + exclude_none: bool = False, + warnings: bool = True, + ) -> dict[str, object]: + """Recursively generate a dictionary representation of the model, optionally specifying which fields to include or exclude. + + By default, fields that were not set by the API will not be included, + and keys will match the API response, *not* the property names from the model. + + For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property, + the output will use the `"fooBar"` key (unless `use_api_names=False` is passed). + + Args: + mode: + If mode is 'json', the dictionary will only contain JSON serializable types. e.g. `datetime` will be turned into a string, `"2024-3-22T18:11:19.117000Z"`. + If mode is 'python', the dictionary may contain any Python objects. e.g. `datetime(2024, 3, 22)` + + use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value from the output. + exclude_none: Whether to exclude fields that have a value of `None` from the output. + warnings: Whether to log warnings when invalid fields are encountered. This is only supported in Pydantic v2. + """ + return self.model_dump( + mode=mode, + by_alias=use_api_names, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + warnings=warnings, + ) + + def to_json( + self, + *, + indent: int | None = 2, + use_api_names: bool = True, + exclude_unset: bool = True, + exclude_defaults: bool = False, + exclude_none: bool = False, + warnings: bool = True, + ) -> str: + """Generates a JSON string representing this model as it would be received from or sent to the API (but with indentation). + + By default, fields that were not set by the API will not be included, + and keys will match the API response, *not* the property names from the model. + + For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property, + the output will use the `"fooBar"` key (unless `use_api_names=False` is passed). + + Args: + indent: Indentation to use in the JSON output. If `None` is passed, the output will be compact. Defaults to `2` + use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that have the default value. + exclude_none: Whether to exclude fields that have a value of `None`. + warnings: Whether to show any warnings that occurred during serialization. This is only supported in Pydantic v2. + """ + return self.model_dump_json( + indent=indent, + by_alias=use_api_names, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + warnings=warnings, + ) + + @override + def __str__(self) -> str: + # mypy complains about an invalid self arg + return f"{self.__repr_name__()}({self.__repr_str__(', ')})" # type: ignore[misc] + + # Override the 'construct' method in a way that supports recursive parsing without validation. + # Based on https://github.com/samuelcolvin/pydantic/issues/1168#issuecomment-817742836. + @classmethod + @override + def construct( # pyright: ignore[reportIncompatibleMethodOverride] + __cls: Type[ModelT], + _fields_set: set[str] | None = None, + **values: object, + ) -> ModelT: + m = __cls.__new__(__cls) + fields_values: dict[str, object] = {} + + config = get_model_config(__cls) + populate_by_name = ( + config.allow_population_by_field_name + if isinstance(config, _ConfigProtocol) + else config.get("populate_by_name") + ) + + if _fields_set is None: + _fields_set = set() + + model_fields = get_model_fields(__cls) + for name, field in model_fields.items(): + key = field.alias + if key is None or (key not in values and populate_by_name): + key = name + + if key in values: + fields_values[name] = _construct_field(value=values[key], field=field, key=key) + _fields_set.add(name) + else: + fields_values[name] = field_get_default(field) + + extra_field_type = _get_extra_fields_type(__cls) + + _extra = {} + for key, value in values.items(): + if key not in model_fields: + parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value + + if PYDANTIC_V1: + _fields_set.add(key) + fields_values[key] = parsed + else: + _extra[key] = parsed + + object.__setattr__(m, "__dict__", fields_values) + + if PYDANTIC_V1: + # init_private_attributes() does not exist in v2 + m._init_private_attributes() # type: ignore + + # copied from Pydantic v1's `construct()` method + object.__setattr__(m, "__fields_set__", _fields_set) + else: + # these properties are copied from Pydantic's `model_construct()` method + object.__setattr__(m, "__pydantic_private__", None) + object.__setattr__(m, "__pydantic_extra__", _extra) + object.__setattr__(m, "__pydantic_fields_set__", _fields_set) + + return m + + if not TYPE_CHECKING: + # type checkers incorrectly complain about this assignment + # because the type signatures are technically different + # although not in practice + model_construct = construct + + if PYDANTIC_V1: + # we define aliases for some of the new pydantic v2 methods so + # that we can just document these methods without having to specify + # a specific pydantic version as some users may not know which + # pydantic version they are currently using + + @override + def model_dump( + self, + *, + mode: Literal["json", "python"] | str = "python", + include: IncEx | None = None, + exclude: IncEx | None = None, + by_alias: bool | None = None, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool | Literal["none", "warn", "error"] = True, + context: dict[str, Any] | None = None, + serialize_as_any: bool = False, + fallback: Callable[[Any], Any] | None = None, + ) -> dict[str, Any]: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump + + Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. + + Args: + mode: The mode in which `to_python` should run. + If mode is 'json', the dictionary will only contain JSON serializable types. + If mode is 'python', the dictionary may contain any Python objects. + include: A list of fields to include in the output. + exclude: A list of fields to exclude from the output. + by_alias: Whether to use the field's alias in the dictionary key if defined. + exclude_unset: Whether to exclude fields that are unset or None from the output. + exclude_defaults: Whether to exclude fields that are set to their default value from the output. + exclude_none: Whether to exclude fields that have a value of `None` from the output. + round_trip: Whether to enable serialization and deserialization round-trip support. + warnings: Whether to log warnings when invalid fields are encountered. + + Returns: + A dictionary representation of the model. + """ + if mode not in {"json", "python"}: + raise ValueError("mode must be either 'json' or 'python'") + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + if context is not None: + raise ValueError("context is only supported in Pydantic v2") + if serialize_as_any != False: + raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") + dumped = super().dict( # pyright: ignore[reportDeprecated] + include=include, + exclude=exclude, + by_alias=by_alias if by_alias is not None else False, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + return cast("dict[str, Any]", json_safe(dumped)) if mode == "json" else dumped + + @override + def model_dump_json( + self, + *, + indent: int | None = None, + include: IncEx | None = None, + exclude: IncEx | None = None, + by_alias: bool | None = None, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool | Literal["none", "warn", "error"] = True, + context: dict[str, Any] | None = None, + fallback: Callable[[Any], Any] | None = None, + serialize_as_any: bool = False, + ) -> str: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json + + Generates a JSON representation of the model using Pydantic's `to_json` method. + + Args: + indent: Indentation to use in the JSON output. If None is passed, the output will be compact. + include: Field(s) to include in the JSON output. Can take either a string or set of strings. + exclude: Field(s) to exclude from the JSON output. Can take either a string or set of strings. + by_alias: Whether to serialize using field aliases. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that have the default value. + exclude_none: Whether to exclude fields that have a value of `None`. + round_trip: Whether to use serialization/deserialization between JSON and class instance. + warnings: Whether to show any warnings that occurred during serialization. + + Returns: + A JSON string representation of the model. + """ + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + if context is not None: + raise ValueError("context is only supported in Pydantic v2") + if serialize_as_any != False: + raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") + return super().json( # type: ignore[reportDeprecated] + indent=indent, + include=include, + exclude=exclude, + by_alias=by_alias if by_alias is not None else False, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + +def _construct_field(value: object, field: FieldInfo, key: str) -> object: + if value is None: + return field_get_default(field) + + if PYDANTIC_V1: + type_ = cast(type, field.outer_type_) # type: ignore + else: + type_ = field.annotation # type: ignore + + if type_ is None: + raise RuntimeError(f"Unexpected field type is None for {key}") + + return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) + + +def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: + if PYDANTIC_V1: + # TODO + return None + + schema = cls.__pydantic_core_schema__ + if schema["type"] == "model": + fields = schema["schema"] + if fields["type"] == "model-fields": + extras = fields.get("extras_schema") + if extras and "cls" in extras: + # mypy can't narrow the type + return extras["cls"] # type: ignore[no-any-return] + + return None + + +def is_basemodel(type_: type) -> bool: + """Returns whether or not the given type is either a `BaseModel` or a union of `BaseModel`""" + if is_union(type_): + for variant in get_args(type_): + if is_basemodel(variant): + return True + + return False + + return is_basemodel_type(type_) + + +def is_basemodel_type(type_: type) -> TypeGuard[type[BaseModel] | type[GenericModel]]: + origin = get_origin(type_) or type_ + if not inspect.isclass(origin): + return False + return issubclass(origin, BaseModel) or issubclass(origin, GenericModel) + + +def build( + base_model_cls: Callable[P, _BaseModelT], + *args: P.args, + **kwargs: P.kwargs, +) -> _BaseModelT: + """Construct a BaseModel class without validation. + + This is useful for cases where you need to instantiate a `BaseModel` + from an API response as this provides type-safe params which isn't supported + by helpers like `construct_type()`. + + ```py + build(MyModel, my_field_a="foo", my_field_b=123) + ``` + """ + if args: + raise TypeError( + "Received positional arguments which are not supported; Keyword arguments must be used instead", + ) + + return cast(_BaseModelT, construct_type(type_=base_model_cls, value=kwargs)) + + +def construct_type_unchecked(*, value: object, type_: type[_T]) -> _T: + """Loose coercion to the expected type with construction of nested values. + + Note: the returned value from this function is not guaranteed to match the + given type. + """ + return cast(_T, construct_type(value=value, type_=type_)) + + +def construct_type(*, value: object, type_: object, metadata: Optional[List[Any]] = None) -> object: + """Loose coercion to the expected type with construction of nested values. + + If the given value does not match the expected type then it is returned as-is. + """ + + # store a reference to the original type we were given before we extract any inner + # types so that we can properly resolve forward references in `TypeAliasType` annotations + original_type = None + + # we allow `object` as the input type because otherwise, passing things like + # `Literal['value']` will be reported as a type error by type checkers + type_ = cast("type[object]", type_) + if is_type_alias_type(type_): + original_type = type_ # type: ignore[unreachable] + type_ = type_.__value__ # type: ignore[unreachable] + + # unwrap `Annotated[T, ...]` -> `T` + if metadata is not None and len(metadata) > 0: + meta: tuple[Any, ...] = tuple(metadata) + elif is_annotated_type(type_): + meta = get_args(type_)[1:] + type_ = extract_type_arg(type_, 0) + else: + meta = tuple() + + # we need to use the origin class for any types that are subscripted generics + # e.g. Dict[str, object] + origin = get_origin(type_) or type_ + args = get_args(type_) + + if is_union(origin): + try: + return validate_type(type_=cast("type[object]", original_type or type_), value=value) + except Exception: + pass + + # if the type is a discriminated union then we want to construct the right variant + # in the union, even if the data doesn't match exactly, otherwise we'd break code + # that relies on the constructed class types, e.g. + # + # class FooType: + # kind: Literal['foo'] + # value: str + # + # class BarType: + # kind: Literal['bar'] + # value: int + # + # without this block, if the data we get is something like `{'kind': 'bar', 'value': 'foo'}` then + # we'd end up constructing `FooType` when it should be `BarType`. + discriminator = _build_discriminated_union_meta(union=type_, meta_annotations=meta) + if discriminator and is_mapping(value): + variant_value = value.get(discriminator.field_alias_from or discriminator.field_name) + if variant_value and isinstance(variant_value, str): + variant_type = discriminator.mapping.get(variant_value) + if variant_type: + return construct_type(type_=variant_type, value=value) + + # if the data is not valid, use the first variant that doesn't fail while deserializing + for variant in args: + try: + return construct_type(value=value, type_=variant) + except Exception: + continue + + raise RuntimeError(f"Could not convert data into a valid instance of {type_}") + + if origin == dict: + if not is_mapping(value): + return value + + _, items_type = get_args(type_) # Dict[_, items_type] + return {key: construct_type(value=item, type_=items_type) for key, item in value.items()} + + if ( + not is_literal_type(type_) + and inspect.isclass(origin) + and (issubclass(origin, BaseModel) or issubclass(origin, GenericModel)) + ): + if is_list(value): + return [cast(Any, type_).construct(**entry) if is_mapping(entry) else entry for entry in value] + + if is_mapping(value): + if issubclass(type_, BaseModel): + return type_.construct(**value) # type: ignore[arg-type] + + return cast(Any, type_).construct(**value) + + if origin == list: + if not is_list(value): + return value + + inner_type = args[0] # List[inner_type] + return [construct_type(value=entry, type_=inner_type) for entry in value] + + if origin == float: + if isinstance(value, int): + coerced = float(value) + if coerced != value: + return value + return coerced + + return value + + if type_ == datetime: + try: + return parse_datetime(value) # type: ignore + except Exception: + return value + + if type_ == date: + try: + return parse_date(value) # type: ignore + except Exception: + return value + + return value + + +@runtime_checkable +class CachedDiscriminatorType(Protocol): + __discriminator__: DiscriminatorDetails + + +class DiscriminatorDetails: + field_name: str + """The name of the discriminator field in the variant class, e.g. + + ```py + class Foo(BaseModel): + type: Literal['foo'] + ``` + + Will result in field_name='type' + """ + + field_alias_from: str | None + """The name of the discriminator field in the API response, e.g. + + ```py + class Foo(BaseModel): + type: Literal['foo'] = Field(alias='type_from_api') + ``` + + Will result in field_alias_from='type_from_api' + """ + + mapping: dict[str, type] + """Mapping of discriminator value to variant type, e.g. + + {'foo': FooVariant, 'bar': BarVariant} + """ + + def __init__( + self, + *, + mapping: dict[str, type], + discriminator_field: str, + discriminator_alias: str | None, + ) -> None: + self.mapping = mapping + self.field_name = discriminator_field + self.field_alias_from = discriminator_alias + + +def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None: + if isinstance(union, CachedDiscriminatorType): + return union.__discriminator__ + + discriminator_field_name: str | None = None + + for annotation in meta_annotations: + if isinstance(annotation, PropertyInfo) and annotation.discriminator is not None: + discriminator_field_name = annotation.discriminator + break + + if not discriminator_field_name: + return None + + mapping: dict[str, type] = {} + discriminator_alias: str | None = None + + for variant in get_args(union): + variant = strip_annotated_type(variant) + if is_basemodel_type(variant): + if PYDANTIC_V1: + field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + if not field_info: + continue + + # Note: if one variant defines an alias then they all should + discriminator_alias = field_info.alias + + if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): + for entry in get_args(annotation): + if isinstance(entry, str): + mapping[entry] = variant + else: + field = _extract_field_schema_pv2(variant, discriminator_field_name) + if not field: + continue + + # Note: if one variant defines an alias then they all should + discriminator_alias = field.get("serialization_alias") + + field_schema = field["schema"] + + if field_schema["type"] == "literal": + for entry in cast("LiteralSchema", field_schema)["expected"]: + if isinstance(entry, str): + mapping[entry] = variant + + if not mapping: + return None + + details = DiscriminatorDetails( + mapping=mapping, + discriminator_field=discriminator_field_name, + discriminator_alias=discriminator_alias, + ) + cast(CachedDiscriminatorType, union).__discriminator__ = details + return details + + +def _extract_field_schema_pv2(model: type[BaseModel], field_name: str) -> ModelField | None: + schema = model.__pydantic_core_schema__ + if schema["type"] == "definitions": + schema = schema["schema"] + + if schema["type"] != "model": + return None + + schema = cast("ModelSchema", schema) + fields_schema = schema["schema"] + if fields_schema["type"] != "model-fields": + return None + + fields_schema = cast("ModelFieldsSchema", fields_schema) + field = fields_schema["fields"].get(field_name) + if not field: + return None + + return cast("ModelField", field) # pyright: ignore[reportUnnecessaryCast] + + +def validate_type(*, type_: type[_T], value: object) -> _T: + """Strict validation that the given value matches the expected type""" + if inspect.isclass(type_) and issubclass(type_, pydantic.BaseModel): + return cast(_T, parse_obj(type_, value)) + + return cast(_T, _validate_non_model_type(type_=type_, value=value)) + + +def set_pydantic_config(typ: Any, config: pydantic.ConfigDict) -> None: + """Add a pydantic config for the given type. + + Note: this is a no-op on Pydantic v1. + """ + setattr(typ, "__pydantic_config__", config) # noqa: B010 + + +# our use of subclassing here causes weirdness for type checkers, +# so we just pretend that we don't subclass +if TYPE_CHECKING: + GenericModel = BaseModel +else: + + class GenericModel(BaseGenericModel, BaseModel): + pass + + +if not PYDANTIC_V1: + from pydantic import TypeAdapter as _TypeAdapter + + _CachedTypeAdapter = cast("TypeAdapter[object]", lru_cache(maxsize=None)(_TypeAdapter)) + + if TYPE_CHECKING: + from pydantic import TypeAdapter + else: + TypeAdapter = _CachedTypeAdapter + + def _validate_non_model_type(*, type_: type[_T], value: object) -> _T: + return TypeAdapter(type_).validate_python(value) + +elif not TYPE_CHECKING: # TODO: condition is weird + + class RootModel(GenericModel, Generic[_T]): + """Used as a placeholder to easily convert runtime types to a Pydantic format + to provide validation. + + For example: + ```py + validated = RootModel[int](__root__="5").__root__ + # validated: 5 + ``` + """ + + __root__: _T + + def _validate_non_model_type(*, type_: type[_T], value: object) -> _T: + model = _create_pydantic_model(type_).validate(value) + return cast(_T, model.__root__) + + def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]: + return RootModel[type_] # type: ignore + + +class FinalRequestOptionsInput(TypedDict, total=False): + method: Required[str] + url: Required[str] + params: Query + headers: Headers + max_retries: int + timeout: float | Timeout | None + files: HttpxRequestFiles | None + idempotency_key: str + json_data: Body + extra_json: AnyMapping + follow_redirects: bool + + +@final +class FinalRequestOptions(pydantic.BaseModel): + method: str + url: str + params: Query = {} + headers: Union[Headers, NotGiven] = NotGiven() + max_retries: Union[int, NotGiven] = NotGiven() + timeout: Union[float, Timeout, None, NotGiven] = NotGiven() + files: Union[HttpxRequestFiles, None] = None + idempotency_key: Union[str, None] = None + post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() + follow_redirects: Union[bool, None] = None + + # It should be noted that we cannot use `json` here as that would override + # a BaseModel method in an incompatible fashion. + json_data: Union[Body, None] = None + extra_json: Union[AnyMapping, None] = None + + if PYDANTIC_V1: + + class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] + arbitrary_types_allowed: bool = True + else: + model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) + + def get_max_retries(self, max_retries: int) -> int: + if isinstance(self.max_retries, NotGiven): + return max_retries + return self.max_retries + + def _strip_raw_response_header(self) -> None: + if not is_given(self.headers): + return + + if self.headers.get(RAW_RESPONSE_HEADER): + self.headers = {**self.headers} + self.headers.pop(RAW_RESPONSE_HEADER) + + # override the `construct` method so that we can run custom transformations. + # this is necessary as we don't want to do any actual runtime type checking + # (which means we can't use validators) but we do want to ensure that `NotGiven` + # values are not present + # + # type ignore required because we're adding explicit types to `**values` + @classmethod + def construct( # type: ignore + cls, + _fields_set: set[str] | None = None, + **values: Unpack[FinalRequestOptionsInput], + ) -> FinalRequestOptions: + kwargs: dict[str, Any] = { + # we unconditionally call `strip_not_given` on any value + # as it will just ignore any non-mapping types + key: strip_not_given(value) + for key, value in values.items() + } + if PYDANTIC_V1: + return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] + return super().model_construct(_fields_set, **kwargs) + + if not TYPE_CHECKING: + # type checkers incorrectly complain about this assignment + model_construct = construct diff --git a/src/beeper_desktop_api/_qs.py b/src/beeper_desktop_api/_qs.py new file mode 100644 index 0000000..ada6fd3 --- /dev/null +++ b/src/beeper_desktop_api/_qs.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from typing import Any, List, Tuple, Union, Mapping, TypeVar +from urllib.parse import parse_qs, urlencode +from typing_extensions import Literal, get_args + +from ._types import NotGiven, 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 +Data = Union[PrimitiveData, List[Any], Tuple[Any], "Mapping[str, Any]"] +Params = Mapping[str, Data] + + +class Querystring: + array_format: ArrayFormat + nested_format: NestedFormat + + def __init__( + self, + *, + array_format: ArrayFormat = "repeat", + nested_format: NestedFormat = "brackets", + ) -> None: + self.array_format = array_format + self.nested_format = nested_format + + def parse(self, query: str) -> Mapping[str, object]: + # Note: custom format syntax is not supported yet + return parse_qs(query) + + def stringify( + self, + params: Params, + *, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, + ) -> str: + return urlencode( + self.stringify_items( + params, + array_format=array_format, + nested_format=nested_format, + ) + ) + + def stringify_items( + self, + params: Params, + *, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, + ) -> list[tuple[str, str]]: + opts = Options( + qs=self, + array_format=array_format, + nested_format=nested_format, + ) + return flatten([self._stringify_item(key, value, opts) for key, value in params.items()]) + + def _stringify_item( + self, + key: str, + value: Data, + opts: Options, + ) -> list[tuple[str, str]]: + if isinstance(value, Mapping): + items: list[tuple[str, str]] = [] + nested_format = opts.nested_format + for subkey, subvalue in value.items(): + items.extend( + self._stringify_item( + # TODO: error if unknown format + f"{key}.{subkey}" if nested_format == "dots" else f"{key}[{subkey}]", + subvalue, + opts, + ) + ) + return items + + if isinstance(value, (list, tuple)): + array_format = opts.array_format + if array_format == "comma": + return [ + ( + key, + ",".join(self._primitive_value_to_str(item) for item in value if item is not None), + ), + ] + elif array_format == "repeat": + items = [] + for item in value: + items.extend(self._stringify_item(key, item, opts)) + return items + elif array_format == "indices": + raise NotImplementedError("The array indices format is not supported yet") + elif array_format == "brackets": + items = [] + key = key + "[]" + for item in value: + items.extend(self._stringify_item(key, item, opts)) + return items + else: + raise NotImplementedError( + f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" + ) + + serialised = self._primitive_value_to_str(value) + if not serialised: + return [] + return [(key, serialised)] + + def _primitive_value_to_str(self, value: PrimitiveData) -> str: + # copied from httpx + if value is True: + return "true" + elif value is False: + return "false" + elif value is None: + return "" + return str(value) + + +_qs = Querystring() +parse = _qs.parse +stringify = _qs.stringify +stringify_items = _qs.stringify_items + + +class Options: + array_format: ArrayFormat + nested_format: NestedFormat + + def __init__( + self, + qs: Querystring = _qs, + *, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, + ) -> None: + self.array_format = qs.array_format if isinstance(array_format, NotGiven) else array_format + self.nested_format = qs.nested_format if isinstance(nested_format, NotGiven) else nested_format diff --git a/src/beeper_desktop_api/_resource.py b/src/beeper_desktop_api/_resource.py new file mode 100644 index 0000000..3606e7d --- /dev/null +++ b/src/beeper_desktop_api/_resource.py @@ -0,0 +1,43 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import anyio + +if TYPE_CHECKING: + from ._client import BeeperDesktop, AsyncBeeperDesktop + + +class SyncAPIResource: + _client: BeeperDesktop + + def __init__(self, client: BeeperDesktop) -> None: + self._client = client + self._get = client.get + self._post = client.post + self._patch = client.patch + self._put = client.put + self._delete = client.delete + self._get_api_list = client.get_api_list + + def _sleep(self, seconds: float) -> None: + time.sleep(seconds) + + +class AsyncAPIResource: + _client: AsyncBeeperDesktop + + def __init__(self, client: AsyncBeeperDesktop) -> None: + self._client = client + self._get = client.get + self._post = client.post + self._patch = client.patch + self._put = client.put + self._delete = client.delete + self._get_api_list = client.get_api_list + + async def _sleep(self, seconds: float) -> None: + await anyio.sleep(seconds) diff --git a/src/beeper_desktop_api/_response.py b/src/beeper_desktop_api/_response.py new file mode 100644 index 0000000..5d155b7 --- /dev/null +++ b/src/beeper_desktop_api/_response.py @@ -0,0 +1,832 @@ +from __future__ import annotations + +import os +import inspect +import logging +import datetime +import functools +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Any, + Union, + Generic, + TypeVar, + Callable, + Iterator, + AsyncIterator, + cast, + overload, +) +from typing_extensions import Awaitable, ParamSpec, override, get_origin + +import anyio +import httpx +import pydantic + +from ._types import NoneType +from ._utils import is_given, extract_type_arg, is_annotated_type, is_type_alias_type, extract_type_var_from_base +from ._models import BaseModel, is_basemodel +from ._constants import RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER +from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type +from ._exceptions import BeeperDesktopError, APIResponseValidationError + +if TYPE_CHECKING: + from ._models import FinalRequestOptions + from ._base_client import BaseClient + + +P = ParamSpec("P") +R = TypeVar("R") +_T = TypeVar("_T") +_APIResponseT = TypeVar("_APIResponseT", bound="APIResponse[Any]") +_AsyncAPIResponseT = TypeVar("_AsyncAPIResponseT", bound="AsyncAPIResponse[Any]") + +log: logging.Logger = logging.getLogger(__name__) + + +class BaseAPIResponse(Generic[R]): + _cast_to: type[R] + _client: BaseClient[Any, Any] + _parsed_by_type: dict[type[Any], Any] + _is_sse_stream: bool + _stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None + _options: FinalRequestOptions + + http_response: httpx.Response + + retries_taken: int + """The number of retries made. If no retries happened this will be `0`""" + + def __init__( + self, + *, + raw: httpx.Response, + cast_to: type[R], + client: BaseClient[Any, Any], + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + options: FinalRequestOptions, + retries_taken: int = 0, + ) -> None: + self._cast_to = cast_to + self._client = client + self._parsed_by_type = {} + self._is_sse_stream = stream + self._stream_cls = stream_cls + self._options = options + self.http_response = raw + self.retries_taken = retries_taken + + @property + def headers(self) -> httpx.Headers: + return self.http_response.headers + + @property + def http_request(self) -> httpx.Request: + """Returns the httpx Request instance associated with the current response.""" + return self.http_response.request + + @property + def status_code(self) -> int: + return self.http_response.status_code + + @property + def url(self) -> httpx.URL: + """Returns the URL for which the request was made.""" + return self.http_response.url + + @property + def method(self) -> str: + return self.http_request.method + + @property + def http_version(self) -> str: + return self.http_response.http_version + + @property + def elapsed(self) -> datetime.timedelta: + """The time taken for the complete request/response cycle to complete.""" + return self.http_response.elapsed + + @property + def is_closed(self) -> bool: + """Whether or not the response body has been closed. + + If this is False then there is response data that has not been read yet. + You must either fully consume the response body or call `.close()` + before discarding the response to prevent resource leaks. + """ + return self.http_response.is_closed + + @override + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__} [{self.status_code} {self.http_response.reason_phrase}] type={self._cast_to}>" + ) + + def _parse(self, *, to: type[_T] | None = None) -> R | _T: + cast_to = to if to is not None else self._cast_to + + # unwrap `TypeAlias('Name', T)` -> `T` + if is_type_alias_type(cast_to): + cast_to = cast_to.__value__ # type: ignore[unreachable] + + # unwrap `Annotated[T, ...]` -> `T` + if cast_to and is_annotated_type(cast_to): + cast_to = extract_type_arg(cast_to, 0) + + origin = get_origin(cast_to) or cast_to + + if self._is_sse_stream: + if to: + if not is_stream_class_type(to): + raise TypeError(f"Expected custom parse type to be a subclass of {Stream} or {AsyncStream}") + + return cast( + _T, + to( + cast_to=extract_stream_chunk_type( + to, + failure_message="Expected custom stream type to be passed with a type argument, e.g. Stream[ChunkType]", + ), + response=self.http_response, + client=cast(Any, self._client), + ), + ) + + if self._stream_cls: + return cast( + R, + self._stream_cls( + cast_to=extract_stream_chunk_type(self._stream_cls), + response=self.http_response, + client=cast(Any, self._client), + ), + ) + + stream_cls = cast("type[Stream[Any]] | type[AsyncStream[Any]] | None", self._client._default_stream_cls) + if stream_cls is None: + raise MissingStreamClassError() + + return cast( + R, + stream_cls( + cast_to=cast_to, + response=self.http_response, + client=cast(Any, self._client), + ), + ) + + if cast_to is NoneType: + return cast(R, None) + + response = self.http_response + if cast_to == str: + return cast(R, response.text) + + if cast_to == bytes: + return cast(R, response.content) + + if cast_to == int: + return cast(R, int(response.text)) + + if cast_to == float: + return cast(R, float(response.text)) + + if cast_to == bool: + return cast(R, response.text.lower() == "true") + + if origin == APIResponse: + raise RuntimeError("Unexpected state - cast_to is `APIResponse`") + + if inspect.isclass(origin) and issubclass(origin, httpx.Response): + # Because of the invariance of our ResponseT TypeVar, users can subclass httpx.Response + # and pass that class to our request functions. We cannot change the variance to be either + # covariant or contravariant as that makes our usage of ResponseT illegal. We could construct + # the response class ourselves but that is something that should be supported directly in httpx + # as it would be easy to incorrectly construct the Response object due to the multitude of arguments. + if cast_to != httpx.Response: + raise ValueError(f"Subclasses of httpx.Response cannot be passed to `cast_to`") + return cast(R, response) + + if ( + inspect.isclass( + origin # pyright: ignore[reportUnknownArgumentType] + ) + and not issubclass(origin, BaseModel) + and issubclass(origin, pydantic.BaseModel) + ): + raise TypeError( + "Pydantic models must subclass our base model type, e.g. `from beeper_desktop_api import BaseModel`" + ) + + if ( + cast_to is not object + and not origin is list + and not origin is dict + and not origin is Union + and not issubclass(origin, BaseModel) + ): + raise RuntimeError( + f"Unsupported type, expected {cast_to} to be a subclass of {BaseModel}, {dict}, {list}, {Union}, {NoneType}, {str} or {httpx.Response}." + ) + + # split is required to handle cases where additional information is included + # in the response, e.g. application/json; charset=utf-8 + content_type, *_ = response.headers.get("content-type", "*").split(";") + if not content_type.endswith("json"): + if is_basemodel(cast_to): + try: + data = response.json() + except Exception as exc: + log.debug("Could not read JSON from response data due to %s - %s", type(exc), exc) + else: + return self._client._process_response_data( + data=data, + cast_to=cast_to, # type: ignore + response=response, + ) + + if self._client._strict_response_validation: + raise APIResponseValidationError( + response=response, + message=f"Expected Content-Type response header to be `application/json` but received `{content_type}` instead.", + body=response.text, + ) + + # If the API responds with content that isn't JSON then we just return + # the (decoded) text without performing any parsing so that you can still + # handle the response however you need to. + return response.text # type: ignore + + data = response.json() + + return self._client._process_response_data( + data=data, + cast_to=cast_to, # type: ignore + response=response, + ) + + +class APIResponse(BaseAPIResponse[R]): + @overload + def parse(self, *, to: type[_T]) -> _T: ... + + @overload + def parse(self) -> R: ... + + def parse(self, *, to: type[_T] | None = None) -> R | _T: + """Returns the rich python representation of this response's data. + + For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`. + + You can customise the type that the response is parsed into through + the `to` argument, e.g. + + ```py + from beeper_desktop_api import BaseModel + + + class MyModel(BaseModel): + foo: str + + + obj = response.parse(to=MyModel) + print(obj.foo) + ``` + + We support parsing: + - `BaseModel` + - `dict` + - `list` + - `Union` + - `str` + - `int` + - `float` + - `httpx.Response` + """ + cache_key = to if to is not None else self._cast_to + cached = self._parsed_by_type.get(cache_key) + if cached is not None: + return cached # type: ignore[no-any-return] + + if not self._is_sse_stream: + self.read() + + parsed = self._parse(to=to) + if is_given(self._options.post_parser): + parsed = self._options.post_parser(parsed) + + self._parsed_by_type[cache_key] = parsed + return parsed + + def read(self) -> bytes: + """Read and return the binary response content.""" + try: + return self.http_response.read() + except httpx.StreamConsumed as exc: + # The default error raised by httpx isn't very + # helpful in our case so we re-raise it with + # a different error message. + raise StreamAlreadyConsumed() from exc + + def text(self) -> str: + """Read and decode the response content into a string.""" + self.read() + return self.http_response.text + + def json(self) -> object: + """Read and decode the JSON response content.""" + self.read() + return self.http_response.json() + + def close(self) -> None: + """Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + self.http_response.close() + + def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]: + """ + A byte-iterator over the decoded response content. + + This automatically handles gzip, deflate and brotli encoded responses. + """ + for chunk in self.http_response.iter_bytes(chunk_size): + yield chunk + + def iter_text(self, chunk_size: int | None = None) -> Iterator[str]: + """A str-iterator over the decoded response content + that handles both gzip, deflate, etc but also detects the content's + string encoding. + """ + for chunk in self.http_response.iter_text(chunk_size): + yield chunk + + def iter_lines(self) -> Iterator[str]: + """Like `iter_text()` but will only yield chunks for each line""" + for chunk in self.http_response.iter_lines(): + yield chunk + + +class AsyncAPIResponse(BaseAPIResponse[R]): + @overload + async def parse(self, *, to: type[_T]) -> _T: ... + + @overload + async def parse(self) -> R: ... + + async def parse(self, *, to: type[_T] | None = None) -> R | _T: + """Returns the rich python representation of this response's data. + + For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`. + + You can customise the type that the response is parsed into through + the `to` argument, e.g. + + ```py + from beeper_desktop_api import BaseModel + + + class MyModel(BaseModel): + foo: str + + + obj = response.parse(to=MyModel) + print(obj.foo) + ``` + + We support parsing: + - `BaseModel` + - `dict` + - `list` + - `Union` + - `str` + - `httpx.Response` + """ + cache_key = to if to is not None else self._cast_to + cached = self._parsed_by_type.get(cache_key) + if cached is not None: + return cached # type: ignore[no-any-return] + + if not self._is_sse_stream: + await self.read() + + parsed = self._parse(to=to) + if is_given(self._options.post_parser): + parsed = self._options.post_parser(parsed) + + self._parsed_by_type[cache_key] = parsed + return parsed + + async def read(self) -> bytes: + """Read and return the binary response content.""" + try: + return await self.http_response.aread() + except httpx.StreamConsumed as exc: + # the default error raised by httpx isn't very + # helpful in our case so we re-raise it with + # a different error message + raise StreamAlreadyConsumed() from exc + + async def text(self) -> str: + """Read and decode the response content into a string.""" + await self.read() + return self.http_response.text + + async def json(self) -> object: + """Read and decode the JSON response content.""" + await self.read() + return self.http_response.json() + + async def close(self) -> None: + """Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + await self.http_response.aclose() + + async def iter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]: + """ + A byte-iterator over the decoded response content. + + This automatically handles gzip, deflate and brotli encoded responses. + """ + async for chunk in self.http_response.aiter_bytes(chunk_size): + yield chunk + + async def iter_text(self, chunk_size: int | None = None) -> AsyncIterator[str]: + """A str-iterator over the decoded response content + that handles both gzip, deflate, etc but also detects the content's + string encoding. + """ + async for chunk in self.http_response.aiter_text(chunk_size): + yield chunk + + async def iter_lines(self) -> AsyncIterator[str]: + """Like `iter_text()` but will only yield chunks for each line""" + async for chunk in self.http_response.aiter_lines(): + yield chunk + + +class BinaryAPIResponse(APIResponse[bytes]): + """Subclass of APIResponse providing helpers for dealing with binary data. + + Note: If you want to stream the response data instead of eagerly reading it + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + + def write_to_file( + self, + file: str | os.PathLike[str], + ) -> None: + """Write the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + + Note: if you want to stream the data to the file instead of writing + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + with open(file, mode="wb") as f: + for data in self.iter_bytes(): + f.write(data) + + +class AsyncBinaryAPIResponse(AsyncAPIResponse[bytes]): + """Subclass of APIResponse providing helpers for dealing with binary data. + + Note: If you want to stream the response data instead of eagerly reading it + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + + async def write_to_file( + self, + file: str | os.PathLike[str], + ) -> None: + """Write the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + + Note: if you want to stream the data to the file instead of writing + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + path = anyio.Path(file) + async with await path.open(mode="wb") as f: + async for data in self.iter_bytes(): + await f.write(data) + + +class StreamedBinaryAPIResponse(APIResponse[bytes]): + def stream_to_file( + self, + file: str | os.PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: + """Streams the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + """ + with open(file, mode="wb") as f: + for data in self.iter_bytes(chunk_size): + f.write(data) + + +class AsyncStreamedBinaryAPIResponse(AsyncAPIResponse[bytes]): + async def stream_to_file( + self, + file: str | os.PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: + """Streams the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + """ + path = anyio.Path(file) + async with await path.open(mode="wb") as f: + async for data in self.iter_bytes(chunk_size): + await f.write(data) + + +class MissingStreamClassError(TypeError): + def __init__(self) -> None: + super().__init__( + "The `stream` argument was set to `True` but the `stream_cls` argument was not given. See `beeper_desktop_api._streaming` for reference", + ) + + +class StreamAlreadyConsumed(BeeperDesktopError): + """ + Attempted to read or stream content, but the content has already + been streamed. + + This can happen if you use a method like `.iter_lines()` and then attempt + to read th entire response body afterwards, e.g. + + ```py + response = await client.post(...) + async for line in response.iter_lines(): + ... # do something with `line` + + content = await response.read() + # ^ error + ``` + + If you want this behaviour you'll need to either manually accumulate the response + content or call `await response.read()` before iterating over the stream. + """ + + def __init__(self) -> None: + message = ( + "Attempted to read or stream some content, but the content has " + "already been streamed. " + "This could be due to attempting to stream the response " + "content more than once." + "\n\n" + "You can fix this by manually accumulating the response content while streaming " + "or by calling `.read()` before starting to stream." + ) + super().__init__(message) + + +class ResponseContextManager(Generic[_APIResponseT]): + """Context manager for ensuring that a request is not made + until it is entered and that the response will always be closed + when the context manager exits + """ + + def __init__(self, request_func: Callable[[], _APIResponseT]) -> None: + self._request_func = request_func + self.__response: _APIResponseT | None = None + + def __enter__(self) -> _APIResponseT: + self.__response = self._request_func() + return self.__response + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self.__response is not None: + self.__response.close() + + +class AsyncResponseContextManager(Generic[_AsyncAPIResponseT]): + """Context manager for ensuring that a request is not made + until it is entered and that the response will always be closed + when the context manager exits + """ + + def __init__(self, api_request: Awaitable[_AsyncAPIResponseT]) -> None: + self._api_request = api_request + self.__response: _AsyncAPIResponseT | None = None + + async def __aenter__(self) -> _AsyncAPIResponseT: + self.__response = await self._api_request + return self.__response + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self.__response is not None: + await self.__response.close() + + +def to_streamed_response_wrapper(func: Callable[P, R]) -> Callable[P, ResponseContextManager[APIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support streaming and returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[APIResponse[R]]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + + kwargs["extra_headers"] = extra_headers + + make_request = functools.partial(func, *args, **kwargs) + + return ResponseContextManager(cast(Callable[[], APIResponse[R]], make_request)) + + return wrapped + + +def async_to_streamed_response_wrapper( + func: Callable[P, Awaitable[R]], +) -> Callable[P, AsyncResponseContextManager[AsyncAPIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support streaming and returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[AsyncAPIResponse[R]]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + + kwargs["extra_headers"] = extra_headers + + make_request = func(*args, **kwargs) + + return AsyncResponseContextManager(cast(Awaitable[AsyncAPIResponse[R]], make_request)) + + return wrapped + + +def to_custom_streamed_response_wrapper( + func: Callable[P, object], + response_cls: type[_APIResponseT], +) -> Callable[P, ResponseContextManager[_APIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support streaming and returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[_APIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + make_request = functools.partial(func, *args, **kwargs) + + return ResponseContextManager(cast(Callable[[], _APIResponseT], make_request)) + + return wrapped + + +def async_to_custom_streamed_response_wrapper( + func: Callable[P, Awaitable[object]], + response_cls: type[_AsyncAPIResponseT], +) -> Callable[P, AsyncResponseContextManager[_AsyncAPIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support streaming and returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[_AsyncAPIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + make_request = func(*args, **kwargs) + + return AsyncResponseContextManager(cast(Awaitable[_AsyncAPIResponseT], make_request)) + + return wrapped + + +def to_raw_response_wrapper(func: Callable[P, R]) -> Callable[P, APIResponse[R]]: + """Higher order function that takes one of our bound API methods and wraps it + to support returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> APIResponse[R]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + + kwargs["extra_headers"] = extra_headers + + return cast(APIResponse[R], func(*args, **kwargs)) + + return wrapped + + +def async_to_raw_response_wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[AsyncAPIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + async def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncAPIResponse[R]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + + kwargs["extra_headers"] = extra_headers + + return cast(AsyncAPIResponse[R], await func(*args, **kwargs)) + + return wrapped + + +def to_custom_raw_response_wrapper( + func: Callable[P, object], + response_cls: type[_APIResponseT], +) -> Callable[P, _APIResponseT]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> _APIResponseT: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + return cast(_APIResponseT, func(*args, **kwargs)) + + return wrapped + + +def async_to_custom_raw_response_wrapper( + func: Callable[P, Awaitable[object]], + response_cls: type[_AsyncAPIResponseT], +) -> Callable[P, Awaitable[_AsyncAPIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> Awaitable[_AsyncAPIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + return cast(Awaitable[_AsyncAPIResponseT], func(*args, **kwargs)) + + return wrapped + + +def extract_response_type(typ: type[BaseAPIResponse[Any]]) -> type: + """Given a type like `APIResponse[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyResponse(APIResponse[bytes]): + ... + + extract_response_type(MyResponse) -> bytes + ``` + """ + return extract_type_var_from_base( + typ, + generic_bases=cast("tuple[type, ...]", (BaseAPIResponse, APIResponse, AsyncAPIResponse)), + index=0, + ) diff --git a/src/beeper_desktop_api/_streaming.py b/src/beeper_desktop_api/_streaming.py new file mode 100644 index 0000000..3462cf4 --- /dev/null +++ b/src/beeper_desktop_api/_streaming.py @@ -0,0 +1,333 @@ +# Note: initially copied from https://github.com/florimondmanca/httpx-sse/blob/master/src/httpx_sse/_decoders.py +from __future__ import annotations + +import json +import inspect +from types import TracebackType +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, AsyncIterator, cast +from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable + +import httpx + +from ._utils import extract_type_var_from_base + +if TYPE_CHECKING: + from ._client import BeeperDesktop, AsyncBeeperDesktop + + +_T = TypeVar("_T") + + +class Stream(Generic[_T]): + """Provides the core interface to iterate over a synchronous stream response.""" + + response: httpx.Response + + _decoder: SSEBytesDecoder + + def __init__( + self, + *, + cast_to: type[_T], + response: httpx.Response, + client: BeeperDesktop, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._decoder = client._make_sse_decoder() + self._iterator = self.__stream__() + + def __next__(self) -> _T: + return self._iterator.__next__() + + def __iter__(self) -> Iterator[_T]: + for item in self._iterator: + yield item + + def _iter_events(self) -> Iterator[ServerSentEvent]: + yield from self._decoder.iter_bytes(self.response.iter_bytes()) + + def __stream__(self) -> Iterator[_T]: + cast_to = cast(Any, self._cast_to) + response = self.response + process_data = self._client._process_response_data + iterator = self._iter_events() + + for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + + # Ensure the entire stream is consumed + for _sse in iterator: + ... + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def close(self) -> None: + """ + Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + self.response.close() + + +class AsyncStream(Generic[_T]): + """Provides the core interface to iterate over an asynchronous stream response.""" + + response: httpx.Response + + _decoder: SSEDecoder | SSEBytesDecoder + + def __init__( + self, + *, + cast_to: type[_T], + response: httpx.Response, + client: AsyncBeeperDesktop, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._decoder = client._make_sse_decoder() + self._iterator = self.__stream__() + + async def __anext__(self) -> _T: + return await self._iterator.__anext__() + + async def __aiter__(self) -> AsyncIterator[_T]: + async for item in self._iterator: + yield item + + async def _iter_events(self) -> AsyncIterator[ServerSentEvent]: + async for sse in self._decoder.aiter_bytes(self.response.aiter_bytes()): + yield sse + + async def __stream__(self) -> AsyncIterator[_T]: + cast_to = cast(Any, self._cast_to) + response = self.response + process_data = self._client._process_response_data + iterator = self._iter_events() + + async for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + + # Ensure the entire stream is consumed + async for _sse in iterator: + ... + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() + + async def close(self) -> None: + """ + Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + await self.response.aclose() + + +class ServerSentEvent: + def __init__( + self, + *, + event: str | None = None, + data: str | None = None, + id: str | None = None, + retry: int | None = None, + ) -> None: + if data is None: + data = "" + + self._id = id + self._data = data + self._event = event or None + self._retry = retry + + @property + def event(self) -> str | None: + return self._event + + @property + def id(self) -> str | None: + return self._id + + @property + def retry(self) -> int | None: + return self._retry + + @property + def data(self) -> str: + return self._data + + def json(self) -> Any: + return json.loads(self.data) + + @override + def __repr__(self) -> str: + return f"ServerSentEvent(event={self.event}, data={self.data}, id={self.id}, retry={self.retry})" + + +class SSEDecoder: + _data: list[str] + _event: str | None + _retry: int | None + _last_event_id: str | None + + def __init__(self) -> None: + self._event = None + self._data = [] + self._last_event_id = None + self._retry = None + + def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + for chunk in self._iter_chunks(iterator): + # Split before decoding so splitlines() only uses \r and \n + for raw_line in chunk.splitlines(): + line = raw_line.decode("utf-8") + sse = self.decode(line) + if sse: + yield sse + + def _iter_chunks(self, iterator: Iterator[bytes]) -> Iterator[bytes]: + """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" + data = b"" + for chunk in iterator: + for line in chunk.splitlines(keepends=True): + data += line + if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): + yield data + data = b"" + if data: + yield data + + async def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + async for chunk in self._aiter_chunks(iterator): + # Split before decoding so splitlines() only uses \r and \n + for raw_line in chunk.splitlines(): + line = raw_line.decode("utf-8") + sse = self.decode(line) + if sse: + yield sse + + async def _aiter_chunks(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[bytes]: + """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" + data = b"" + async for chunk in iterator: + for line in chunk.splitlines(keepends=True): + data += line + if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): + yield data + data = b"" + if data: + yield data + + def decode(self, line: str) -> ServerSentEvent | None: + # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 + + if not line: + if not self._event and not self._data and not self._last_event_id and self._retry is None: + return None + + sse = ServerSentEvent( + event=self._event, + data="\n".join(self._data), + id=self._last_event_id, + retry=self._retry, + ) + + # NOTE: as per the SSE spec, do not reset last_event_id. + self._event = None + self._data = [] + self._retry = None + + return sse + + if line.startswith(":"): + return None + + fieldname, _, value = line.partition(":") + + if value.startswith(" "): + value = value[1:] + + if fieldname == "event": + self._event = value + elif fieldname == "data": + self._data.append(value) + elif fieldname == "id": + if "\0" in value: + pass + else: + self._last_event_id = value + elif fieldname == "retry": + try: + self._retry = int(value) + except (TypeError, ValueError): + pass + else: + pass # Field is ignored. + + return None + + +@runtime_checkable +class SSEBytesDecoder(Protocol): + def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + ... + + def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: + """Given an async iterator that yields raw binary data, iterate over it & yield every event encountered""" + ... + + +def is_stream_class_type(typ: type) -> TypeGuard[type[Stream[object]] | type[AsyncStream[object]]]: + """TypeGuard for determining whether or not the given type is a subclass of `Stream` / `AsyncStream`""" + origin = get_origin(typ) or typ + return inspect.isclass(origin) and issubclass(origin, (Stream, AsyncStream)) + + +def extract_stream_chunk_type( + stream_cls: type, + *, + failure_message: str | None = None, +) -> type: + """Given a type like `Stream[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyStream(Stream[bytes]): + ... + + extract_stream_chunk_type(MyStream) -> bytes + ``` + """ + from ._base_client import Stream, AsyncStream + + return extract_type_var_from_base( + stream_cls, + index=0, + generic_bases=cast("tuple[type, ...]", (Stream, AsyncStream)), + failure_message=failure_message, + ) diff --git a/src/beeper_desktop_api/_types.py b/src/beeper_desktop_api/_types.py new file mode 100644 index 0000000..d3c2e24 --- /dev/null +++ b/src/beeper_desktop_api/_types.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from os import PathLike +from typing import ( + IO, + TYPE_CHECKING, + Any, + Dict, + List, + Type, + Tuple, + Union, + Mapping, + TypeVar, + Callable, + Iterator, + Optional, + Sequence, +) +from typing_extensions import ( + Set, + Literal, + Protocol, + TypeAlias, + TypedDict, + SupportsIndex, + overload, + override, + runtime_checkable, +) + +import httpx +import pydantic +from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport + +if TYPE_CHECKING: + from ._models import BaseModel + from ._response import APIResponse, AsyncAPIResponse + +Transport = BaseTransport +AsyncTransport = AsyncBaseTransport +Query = Mapping[str, object] +Body = object +AnyMapping = Mapping[str, object] +ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) +_T = TypeVar("_T") + + +# Approximates httpx internal ProxiesTypes and RequestFiles types +# while adding support for `PathLike` instances +ProxiesDict = Dict["str | URL", Union[None, str, URL, Proxy]] +ProxiesTypes = Union[str, Proxy, ProxiesDict] +if TYPE_CHECKING: + Base64FileInput = Union[IO[bytes], PathLike[str]] + FileContent = Union[IO[bytes], bytes, PathLike[str]] +else: + Base64FileInput = Union[IO[bytes], PathLike] + FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. +FileTypes = Union[ + # file (or bytes) + FileContent, + # (filename, file (or bytes)) + Tuple[Optional[str], FileContent], + # (filename, file (or bytes), content_type) + Tuple[Optional[str], FileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], +] +RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] + +# duplicate of the above but without our custom file support +HttpxFileContent = Union[IO[bytes], bytes] +HttpxFileTypes = Union[ + # file (or bytes) + HttpxFileContent, + # (filename, file (or bytes)) + Tuple[Optional[str], HttpxFileContent], + # (filename, file (or bytes), content_type) + Tuple[Optional[str], HttpxFileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + Tuple[Optional[str], HttpxFileContent, Optional[str], Mapping[str, str]], +] +HttpxRequestFiles = Union[Mapping[str, HttpxFileTypes], Sequence[Tuple[str, HttpxFileTypes]]] + +# Workaround to support (cast_to: Type[ResponseT]) -> ResponseT +# where ResponseT includes `None`. In order to support directly +# passing `None`, overloads would have to be defined for every +# method that uses `ResponseT` which would lead to an unacceptable +# amount of code duplication and make it unreadable. See _base_client.py +# for example usage. +# +# This unfortunately means that you will either have +# to import this type and pass it explicitly: +# +# from beeper_desktop_api import NoneType +# client.get('/foo', cast_to=NoneType) +# +# or build it yourself: +# +# client.get('/foo', cast_to=type(None)) +if TYPE_CHECKING: + NoneType: Type[None] +else: + NoneType = type(None) + + +class RequestOptions(TypedDict, total=False): + headers: Headers + max_retries: int + timeout: float | Timeout | None + params: Query + extra_json: AnyMapping + idempotency_key: str + follow_redirects: bool + + +# Sentinel class used until PEP 0661 is accepted +class NotGiven: + """ + For parameters with a meaningful None value, we need to distinguish between + the user explicitly passing None, and the user not passing the parameter at + all. + + User code shouldn't need to use not_given directly. + + For example: + + ```py + def create(timeout: Timeout | None | NotGiven = not_given): ... + + + create(timeout=1) # 1s timeout + create(timeout=None) # No timeout + create() # Default timeout behavior + ``` + """ + + def __bool__(self) -> Literal[False]: + return False + + @override + def __repr__(self) -> str: + return "NOT_GIVEN" + + +not_given = NotGiven() +# for backwards compatibility: +NOT_GIVEN = NotGiven() + + +class Omit: + """ + To explicitly omit something from being sent in a request, use `omit`. + + ```py + # as the default `Content-Type` header is `application/json` that will be sent + client.post("/upload/files", files={"file": b"my raw file content"}) + + # you can't explicitly override the header as it has to be dynamically generated + # to look something like: 'multipart/form-data; boundary=0d8382fcf5f8c3be01ca2e11002d2983' + client.post(..., headers={"Content-Type": "multipart/form-data"}) + + # instead you can remove the default `application/json` header by passing omit + client.post(..., headers={"Content-Type": omit}) + ``` + """ + + def __bool__(self) -> Literal[False]: + return False + + +omit = Omit() + + +@runtime_checkable +class ModelBuilderProtocol(Protocol): + @classmethod + def build( + cls: type[_T], + *, + response: Response, + data: object, + ) -> _T: ... + + +Headers = Mapping[str, Union[str, Omit]] + + +class HeadersLikeProtocol(Protocol): + def get(self, __key: str) -> str | None: ... + + +HeadersLike = Union[Headers, HeadersLikeProtocol] + +ResponseT = TypeVar( + "ResponseT", + bound=Union[ + object, + str, + None, + "BaseModel", + List[Any], + Dict[str, Any], + Response, + ModelBuilderProtocol, + "APIResponse[Any]", + "AsyncAPIResponse[Any]", + ], +) + +StrBytesIntFloat = Union[str, bytes, int, float] + +# Note: copied from Pydantic +# https://github.com/pydantic/pydantic/blob/6f31f8f68ef011f84357330186f603ff295312fd/pydantic/main.py#L79 +IncEx: TypeAlias = Union[Set[int], Set[str], Mapping[int, Union["IncEx", bool]], Mapping[str, Union["IncEx", bool]]] + +PostParser = Callable[[Any], Any] + + +@runtime_checkable +class InheritsGeneric(Protocol): + """Represents a type that has inherited from `Generic` + + The `__orig_bases__` property can be used to determine the resolved + type variable for a given base class. + """ + + __orig_bases__: tuple[_GenericAlias] + + +class _GenericAlias(Protocol): + __origin__: type[object] + + +class HttpxSendArgs(TypedDict, total=False): + auth: httpx.Auth + follow_redirects: bool + + +_T_co = TypeVar("_T_co", covariant=True) + + +if TYPE_CHECKING: + # This works because str.__contains__ does not accept object (either in typeshed or at runtime) + # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + class SequenceNotStr(Protocol[_T_co]): + @overload + def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... + @overload + def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... + def __contains__(self, value: object, /) -> bool: ... + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[_T_co]: ... + def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... + def count(self, value: Any, /) -> int: ... + def __reversed__(self) -> Iterator[_T_co]: ... +else: + # just point this to a normal `Sequence` at runtime to avoid having to special case + # deserializing our custom sequence type + SequenceNotStr = Sequence diff --git a/src/beeper_desktop_api/_utils/__init__.py b/src/beeper_desktop_api/_utils/__init__.py new file mode 100644 index 0000000..dc64e29 --- /dev/null +++ b/src/beeper_desktop_api/_utils/__init__.py @@ -0,0 +1,64 @@ +from ._sync import asyncify as asyncify +from ._proxy import LazyProxy as LazyProxy +from ._utils import ( + flatten as flatten, + is_dict as is_dict, + is_list as is_list, + is_given as is_given, + is_tuple as is_tuple, + json_safe as json_safe, + lru_cache as lru_cache, + is_mapping as is_mapping, + is_tuple_t as is_tuple_t, + is_iterable as is_iterable, + is_sequence as is_sequence, + coerce_float as coerce_float, + is_mapping_t as is_mapping_t, + removeprefix as removeprefix, + removesuffix as removesuffix, + extract_files as extract_files, + is_sequence_t as is_sequence_t, + required_args as required_args, + coerce_boolean as coerce_boolean, + 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, + maybe_coerce_boolean as maybe_coerce_boolean, + maybe_coerce_integer as maybe_coerce_integer, +) +from ._compat import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, +) +from ._typing import ( + is_list_type as is_list_type, + is_union_type as is_union_type, + extract_type_arg as extract_type_arg, + is_iterable_type as is_iterable_type, + is_required_type as is_required_type, + is_sequence_type as is_sequence_type, + is_annotated_type as is_annotated_type, + is_type_alias_type as is_type_alias_type, + strip_annotated_type as strip_annotated_type, + extract_type_var_from_base as extract_type_var_from_base, +) +from ._streams import consume_sync_iterator as consume_sync_iterator, consume_async_iterator as consume_async_iterator +from ._transform import ( + PropertyInfo as PropertyInfo, + transform as transform, + async_transform as async_transform, + maybe_transform as maybe_transform, + async_maybe_transform as async_maybe_transform, +) +from ._reflection import ( + function_has_argument as function_has_argument, + assert_signatures_in_sync as assert_signatures_in_sync, +) +from ._datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime diff --git a/src/beeper_desktop_api/_utils/_compat.py b/src/beeper_desktop_api/_utils/_compat.py new file mode 100644 index 0000000..dd70323 --- /dev/null +++ b/src/beeper_desktop_api/_utils/_compat.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +import typing_extensions +from typing import Any, Type, Union, Literal, Optional +from datetime import date, datetime +from typing_extensions import get_args as _get_args, get_origin as _get_origin + +from .._types import StrBytesIntFloat +from ._datetime_parse import parse_date as _parse_date, parse_datetime as _parse_datetime + +_LITERAL_TYPES = {Literal, typing_extensions.Literal} + + +def get_args(tp: type[Any]) -> tuple[Any, ...]: + return _get_args(tp) + + +def get_origin(tp: type[Any]) -> type[Any] | None: + return _get_origin(tp) + + +def is_union(tp: Optional[Type[Any]]) -> bool: + if sys.version_info < (3, 10): + return tp is Union # type: ignore[comparison-overlap] + else: + import types + + return tp is Union or tp is types.UnionType + + +def is_typeddict(tp: Type[Any]) -> bool: + return typing_extensions.is_typeddict(tp) + + +def is_literal_type(tp: Type[Any]) -> bool: + return get_origin(tp) in _LITERAL_TYPES + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + return _parse_date(value) + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + return _parse_datetime(value) diff --git a/src/beeper_desktop_api/_utils/_datetime_parse.py b/src/beeper_desktop_api/_utils/_datetime_parse.py new file mode 100644 index 0000000..7cb9d9e --- /dev/null +++ b/src/beeper_desktop_api/_utils/_datetime_parse.py @@ -0,0 +1,136 @@ +""" +This file contains code from https://github.com/pydantic/pydantic/blob/main/pydantic/v1/datetime_parse.py +without the Pydantic v1 specific errors. +""" + +from __future__ import annotations + +import re +from typing import Dict, Union, Optional +from datetime import date, datetime, timezone, timedelta + +from .._types import StrBytesIntFloat + +date_expr = r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" +time_expr = ( + r"(?P\d{1,2}):(?P\d{1,2})" + r"(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?" + r"(?PZ|[+-]\d{2}(?::?\d{2})?)?$" +) + +date_re = re.compile(f"{date_expr}$") +datetime_re = re.compile(f"{date_expr}[T ]{time_expr}") + + +EPOCH = datetime(1970, 1, 1) +# if greater than this, the number is in ms, if less than or equal it's in seconds +# (in seconds this is 11th October 2603, in ms it's 20th August 1970) +MS_WATERSHED = int(2e10) +# slightly more than datetime.max in ns - (datetime.max - EPOCH).total_seconds() * 1e9 +MAX_NUMBER = int(3e20) + + +def _get_numeric(value: StrBytesIntFloat, native_expected_type: str) -> Union[None, int, float]: + if isinstance(value, (int, float)): + return value + try: + return float(value) + except ValueError: + return None + except TypeError: + raise TypeError(f"invalid type; expected {native_expected_type}, string, bytes, int or float") from None + + +def _from_unix_seconds(seconds: Union[int, float]) -> datetime: + if seconds > MAX_NUMBER: + return datetime.max + elif seconds < -MAX_NUMBER: + return datetime.min + + while abs(seconds) > MS_WATERSHED: + seconds /= 1000 + dt = EPOCH + timedelta(seconds=seconds) + return dt.replace(tzinfo=timezone.utc) + + +def _parse_timezone(value: Optional[str]) -> Union[None, int, timezone]: + if value == "Z": + return timezone.utc + elif value is not None: + offset_mins = int(value[-2:]) if len(value) > 3 else 0 + offset = 60 * int(value[1:3]) + offset_mins + if value[0] == "-": + offset = -offset + return timezone(timedelta(minutes=offset)) + else: + return None + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + """ + Parse a datetime/int/float/string and return a datetime.datetime. + + This function supports time zone offsets. When the input contains one, + the output uses a timezone with a fixed offset from UTC. + + Raise ValueError if the input is well formatted but not a valid datetime. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, datetime): + return value + + number = _get_numeric(value, "datetime") + if number is not None: + return _from_unix_seconds(number) + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + + match = datetime_re.match(value) + if match is None: + raise ValueError("invalid datetime format") + + kw = match.groupdict() + if kw["microsecond"]: + kw["microsecond"] = kw["microsecond"].ljust(6, "0") + + tzinfo = _parse_timezone(kw.pop("tzinfo")) + kw_: Dict[str, Union[None, int, timezone]] = {k: int(v) for k, v in kw.items() if v is not None} + kw_["tzinfo"] = tzinfo + + return datetime(**kw_) # type: ignore + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + """ + Parse a date/int/float/string and return a datetime.date. + + Raise ValueError if the input is well formatted but not a valid date. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, date): + if isinstance(value, datetime): + return value.date() + else: + return value + + number = _get_numeric(value, "date") + if number is not None: + return _from_unix_seconds(number).date() + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + match = date_re.match(value) + if match is None: + raise ValueError("invalid date format") + + kw = {k: int(v) for k, v in match.groupdict().items()} + + try: + return date(**kw) + except ValueError: + raise ValueError("invalid date format") from None diff --git a/src/beeper_desktop_api/_utils/_logs.py b/src/beeper_desktop_api/_utils/_logs.py new file mode 100644 index 0000000..da351d5 --- /dev/null +++ b/src/beeper_desktop_api/_utils/_logs.py @@ -0,0 +1,25 @@ +import os +import logging + +logger: logging.Logger = logging.getLogger("beeper_desktop_api") +httpx_logger: logging.Logger = logging.getLogger("httpx") + + +def _basic_config() -> None: + # e.g. [2023-10-05 14:12:26 - beeper_desktop_api._base_client:818 - DEBUG] HTTP Request: POST http://127.0.0.1:4010/foo/bar "200 OK" + logging.basicConfig( + format="[%(asctime)s - %(name)s:%(lineno)d - %(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def setup_logging() -> None: + env = os.environ.get("BEEPER_DESKTOP_LOG") + if env == "debug": + _basic_config() + logger.setLevel(logging.DEBUG) + httpx_logger.setLevel(logging.DEBUG) + elif env == "info": + _basic_config() + logger.setLevel(logging.INFO) + httpx_logger.setLevel(logging.INFO) diff --git a/src/beeper_desktop_api/_utils/_proxy.py b/src/beeper_desktop_api/_utils/_proxy.py new file mode 100644 index 0000000..0f239a3 --- /dev/null +++ b/src/beeper_desktop_api/_utils/_proxy.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Generic, TypeVar, Iterable, cast +from typing_extensions import override + +T = TypeVar("T") + + +class LazyProxy(Generic[T], ABC): + """Implements data methods to pretend that an instance is another instance. + + This includes forwarding attribute access and other methods. + """ + + # Note: we have to special case proxies that themselves return proxies + # to support using a proxy as a catch-all for any random access, e.g. `proxy.foo.bar.baz` + + def __getattr__(self, attr: str) -> object: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied # pyright: ignore + return getattr(proxied, attr) + + @override + def __repr__(self) -> str: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied.__class__.__name__ + return repr(self.__get_proxied__()) + + @override + def __str__(self) -> str: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied.__class__.__name__ + return str(proxied) + + @override + def __dir__(self) -> Iterable[str]: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return [] + return proxied.__dir__() + + @property # type: ignore + @override + def __class__(self) -> type: # pyright: ignore + try: + proxied = self.__get_proxied__() + except Exception: + return type(self) + if issubclass(type(proxied), LazyProxy): + return type(proxied) + return proxied.__class__ + + def __get_proxied__(self) -> T: + return self.__load__() + + def __as_proxied__(self) -> T: + """Helper method that returns the current proxy, typed as the loaded object""" + return cast(T, self) + + @abstractmethod + def __load__(self) -> T: ... diff --git a/src/beeper_desktop_api/_utils/_reflection.py b/src/beeper_desktop_api/_utils/_reflection.py new file mode 100644 index 0000000..89aa712 --- /dev/null +++ b/src/beeper_desktop_api/_utils/_reflection.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import inspect +from typing import Any, Callable + + +def function_has_argument(func: Callable[..., Any], arg_name: str) -> bool: + """Returns whether or not the given function has a specific parameter""" + sig = inspect.signature(func) + return arg_name in sig.parameters + + +def assert_signatures_in_sync( + source_func: Callable[..., Any], + check_func: Callable[..., Any], + *, + exclude_params: set[str] = set(), +) -> None: + """Ensure that the signature of the second function matches the first.""" + + check_sig = inspect.signature(check_func) + source_sig = inspect.signature(source_func) + + errors: list[str] = [] + + for name, source_param in source_sig.parameters.items(): + if name in exclude_params: + continue + + custom_param = check_sig.parameters.get(name) + if not custom_param: + errors.append(f"the `{name}` param is missing") + continue + + if custom_param.annotation != source_param.annotation: + errors.append( + f"types for the `{name}` param are do not match; source={repr(source_param.annotation)} checking={repr(custom_param.annotation)}" + ) + continue + + if errors: + raise AssertionError(f"{len(errors)} errors encountered when comparing signatures:\n\n" + "\n\n".join(errors)) diff --git a/src/beeper_desktop_api/_utils/_resources_proxy.py b/src/beeper_desktop_api/_utils/_resources_proxy.py new file mode 100644 index 0000000..531be88 --- /dev/null +++ b/src/beeper_desktop_api/_utils/_resources_proxy.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Any +from typing_extensions import override + +from ._proxy import LazyProxy + + +class ResourcesProxy(LazyProxy[Any]): + """A proxy for the `beeper_desktop_api.resources` module. + + This is used so that we can lazily import `beeper_desktop_api.resources` only when + needed *and* so that users can just import `beeper_desktop_api` and reference `beeper_desktop_api.resources` + """ + + @override + def __load__(self) -> Any: + import importlib + + mod = importlib.import_module("beeper_desktop_api.resources") + return mod + + +resources = ResourcesProxy().__as_proxied__() diff --git a/src/beeper_desktop_api/_utils/_streams.py b/src/beeper_desktop_api/_utils/_streams.py new file mode 100644 index 0000000..f4a0208 --- /dev/null +++ b/src/beeper_desktop_api/_utils/_streams.py @@ -0,0 +1,12 @@ +from typing import Any +from typing_extensions import Iterator, AsyncIterator + + +def consume_sync_iterator(iterator: Iterator[Any]) -> None: + for _ in iterator: + ... + + +async def consume_async_iterator(iterator: AsyncIterator[Any]) -> None: + async for _ in iterator: + ... diff --git a/src/beeper_desktop_api/_utils/_sync.py b/src/beeper_desktop_api/_utils/_sync.py new file mode 100644 index 0000000..ad7ec71 --- /dev/null +++ b/src/beeper_desktop_api/_utils/_sync.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import sys +import asyncio +import functools +import contextvars +from typing import Any, TypeVar, Callable, Awaitable +from typing_extensions import ParamSpec + +import anyio +import sniffio +import anyio.to_thread + +T_Retval = TypeVar("T_Retval") +T_ParamSpec = ParamSpec("T_ParamSpec") + + +if sys.version_info >= (3, 9): + _asyncio_to_thread = asyncio.to_thread +else: + # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread + # for Python 3.8 support + async def _asyncio_to_thread( + func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs + ) -> Any: + """Asynchronously run function *func* in a separate thread. + + Any *args and **kwargs supplied for this function are directly passed + to *func*. Also, the current :class:`contextvars.Context` is propagated, + allowing context variables from the main thread to be accessed in the + separate thread. + + Returns a coroutine that can be awaited to get the eventual result of *func*. + """ + loop = asyncio.events.get_running_loop() + ctx = contextvars.copy_context() + func_call = functools.partial(ctx.run, func, *args, **kwargs) + return await loop.run_in_executor(None, func_call) + + +async def to_thread( + func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs +) -> T_Retval: + if sniffio.current_async_library() == "asyncio": + return await _asyncio_to_thread(func, *args, **kwargs) + + return await anyio.to_thread.run_sync( + functools.partial(func, *args, **kwargs), + ) + + +# inspired by `asyncer`, https://github.com/tiangolo/asyncer +def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: + """ + Take a blocking function and create an async one that receives the same + positional and keyword arguments. For python version 3.9 and above, it uses + asyncio.to_thread to run the function in a separate thread. For python version + 3.8, it uses locally defined copy of the asyncio.to_thread function which was + introduced in python 3.9. + + Usage: + + ```python + def blocking_func(arg1, arg2, kwarg1=None): + # blocking code + return result + + + result = asyncify(blocking_function)(arg1, arg2, kwarg1=value1) + ``` + + ## Arguments + + `function`: a blocking regular callable (e.g. a function) + + ## Return + + An async function that takes the same positional and keyword arguments as the + original one, that when called runs the same original function in a thread worker + and returns the result. + """ + + async def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval: + return await to_thread(function, *args, **kwargs) + + return wrapper diff --git a/src/beeper_desktop_api/_utils/_transform.py b/src/beeper_desktop_api/_utils/_transform.py new file mode 100644 index 0000000..5207549 --- /dev/null +++ b/src/beeper_desktop_api/_utils/_transform.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import io +import base64 +import pathlib +from typing import Any, Mapping, TypeVar, cast +from datetime import date, datetime +from typing_extensions import Literal, get_args, override, get_type_hints as _get_type_hints + +import anyio +import pydantic + +from ._utils import ( + is_list, + is_given, + lru_cache, + is_mapping, + is_iterable, + is_sequence, +) +from .._files import is_base64_file_input +from ._compat import get_origin, is_typeddict +from ._typing import ( + is_list_type, + is_union_type, + extract_type_arg, + is_iterable_type, + is_required_type, + is_sequence_type, + is_annotated_type, + strip_annotated_type, +) + +_T = TypeVar("_T") + + +# TODO: support for drilling globals() and locals() +# TODO: ensure works correctly with forward references in all cases + + +PropertyFormat = Literal["iso8601", "base64", "custom"] + + +class PropertyInfo: + """Metadata class to be used in Annotated types to provide information about a given type. + + For example: + + class MyParams(TypedDict): + account_holder_name: Annotated[str, PropertyInfo(alias='accountHolderName')] + + This means that {'account_holder_name': 'Robert'} will be transformed to {'accountHolderName': 'Robert'} before being sent to the API. + """ + + alias: str | None + format: PropertyFormat | None + format_template: str | None + discriminator: str | None + + def __init__( + self, + *, + alias: str | None = None, + format: PropertyFormat | None = None, + format_template: str | None = None, + discriminator: str | None = None, + ) -> None: + self.alias = alias + self.format = format + self.format_template = format_template + self.discriminator = discriminator + + @override + def __repr__(self) -> str: + return f"{self.__class__.__name__}(alias='{self.alias}', format={self.format}, format_template='{self.format_template}', discriminator='{self.discriminator}')" + + +def maybe_transform( + data: object, + expected_type: object, +) -> Any | None: + """Wrapper over `transform()` that allows `None` to be passed. + + See `transform()` for more details. + """ + if data is None: + return None + return transform(data, expected_type) + + +# Wrapper over _transform_recursive providing fake types +def transform( + data: _T, + expected_type: object, +) -> _T: + """Transform dictionaries based off of type information from the given type, for example: + + ```py + class Params(TypedDict, total=False): + card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]] + + + transformed = transform({"card_id": ""}, Params) + # {'cardID': ''} + ``` + + Any keys / data that does not have type information given will be included as is. + + It should be noted that the transformations that this function does are not represented in the type system. + """ + transformed = _transform_recursive(data, annotation=cast(type, expected_type)) + return cast(_T, transformed) + + +@lru_cache(maxsize=8096) +def _get_annotated_type(type_: type) -> type | None: + """If the given type is an `Annotated` type then it is returned, if not `None` is returned. + + This also unwraps the type when applicable, e.g. `Required[Annotated[T, ...]]` + """ + if is_required_type(type_): + # Unwrap `Required[Annotated[T, ...]]` to `Annotated[T, ...]` + type_ = get_args(type_)[0] + + if is_annotated_type(type_): + return type_ + + return None + + +def _maybe_transform_key(key: str, type_: type) -> str: + """Transform the given `data` based on the annotations provided in `type_`. + + Note: this function only looks at `Annotated` types that contain `PropertyInfo` metadata. + """ + annotated_type = _get_annotated_type(type_) + if annotated_type is None: + # no `Annotated` definition for this type, no transformation needed + return key + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.alias is not None: + return annotation.alias + + return key + + +def _no_transform_needed(annotation: type) -> bool: + return annotation == float or annotation == int + + +def _transform_recursive( + data: object, + *, + annotation: type, + inner_type: type | None = None, +) -> object: + """Transform the given data against the expected type. + + Args: + annotation: The direct type annotation given to the particular piece of data. + This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc + + inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type + is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in + the list can be transformed using the metadata from the container type. + + Defaults to the same value as the `annotation` argument. + """ + from .._compat import model_dump + + if inner_type is None: + inner_type = annotation + + stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type + if is_typeddict(stripped_type) and is_mapping(data): + return _transform_typeddict(data, stripped_type) + + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + + if ( + # List[T] + (is_list_type(stripped_type) and is_list(data)) + # Iterable[T] + or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) + ): + # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually + # intended as an iterable, so we don't transform it. + if isinstance(data, dict): + return cast(object, data) + + inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + + return [_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] + + if is_union_type(stripped_type): + # For union types we run the transformation against all subtypes to ensure that everything is transformed. + # + # TODO: there may be edge cases where the same normalized field name will transform to two different names + # in different subtypes. + for subtype in get_args(stripped_type): + data = _transform_recursive(data, annotation=annotation, inner_type=subtype) + return data + + if isinstance(data, pydantic.BaseModel): + return model_dump(data, exclude_unset=True, mode="json") + + annotated_type = _get_annotated_type(annotation) + if annotated_type is None: + return data + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.format is not None: + return _format_data(data, annotation.format, annotation.format_template) + + return data + + +def _format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object: + if isinstance(data, (date, datetime)): + if format_ == "iso8601": + return data.isoformat() + + if format_ == "custom" and format_template is not None: + return data.strftime(format_template) + + if format_ == "base64" and is_base64_file_input(data): + binary: str | bytes | None = None + + if isinstance(data, pathlib.Path): + binary = data.read_bytes() + elif isinstance(data, io.IOBase): + binary = data.read() + + if isinstance(binary, str): # type: ignore[unreachable] + binary = binary.encode() + + if not isinstance(binary, bytes): + raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") + + return base64.b64encode(binary).decode("ascii") + + return data + + +def _transform_typeddict( + data: Mapping[str, object], + expected_type: type, +) -> Mapping[str, object]: + result: dict[str, object] = {} + annotations = get_type_hints(expected_type, include_extras=True) + for key, value in data.items(): + if not is_given(value): + # we don't need to include omitted values here as they'll + # be stripped out before the request is sent anyway + continue + + type_ = annotations.get(key) + if type_ is None: + # we do not have a type annotation for this field, leave it as is + result[key] = value + else: + result[_maybe_transform_key(key, type_)] = _transform_recursive(value, annotation=type_) + return result + + +async def async_maybe_transform( + data: object, + expected_type: object, +) -> Any | None: + """Wrapper over `async_transform()` that allows `None` to be passed. + + See `async_transform()` for more details. + """ + if data is None: + return None + return await async_transform(data, expected_type) + + +async def async_transform( + data: _T, + expected_type: object, +) -> _T: + """Transform dictionaries based off of type information from the given type, for example: + + ```py + class Params(TypedDict, total=False): + card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]] + + + transformed = transform({"card_id": ""}, Params) + # {'cardID': ''} + ``` + + Any keys / data that does not have type information given will be included as is. + + It should be noted that the transformations that this function does are not represented in the type system. + """ + transformed = await _async_transform_recursive(data, annotation=cast(type, expected_type)) + return cast(_T, transformed) + + +async def _async_transform_recursive( + data: object, + *, + annotation: type, + inner_type: type | None = None, +) -> object: + """Transform the given data against the expected type. + + Args: + annotation: The direct type annotation given to the particular piece of data. + This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc + + inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type + is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in + the list can be transformed using the metadata from the container type. + + Defaults to the same value as the `annotation` argument. + """ + from .._compat import model_dump + + if inner_type is None: + inner_type = annotation + + stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type + if is_typeddict(stripped_type) and is_mapping(data): + return await _async_transform_typeddict(data, stripped_type) + + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + + if ( + # List[T] + (is_list_type(stripped_type) and is_list(data)) + # Iterable[T] + or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) + ): + # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually + # intended as an iterable, so we don't transform it. + if isinstance(data, dict): + return cast(object, data) + + inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + + return [await _async_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] + + if is_union_type(stripped_type): + # For union types we run the transformation against all subtypes to ensure that everything is transformed. + # + # TODO: there may be edge cases where the same normalized field name will transform to two different names + # in different subtypes. + for subtype in get_args(stripped_type): + data = await _async_transform_recursive(data, annotation=annotation, inner_type=subtype) + return data + + if isinstance(data, pydantic.BaseModel): + return model_dump(data, exclude_unset=True, mode="json") + + annotated_type = _get_annotated_type(annotation) + if annotated_type is None: + return data + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.format is not None: + return await _async_format_data(data, annotation.format, annotation.format_template) + + return data + + +async def _async_format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object: + if isinstance(data, (date, datetime)): + if format_ == "iso8601": + return data.isoformat() + + if format_ == "custom" and format_template is not None: + return data.strftime(format_template) + + if format_ == "base64" and is_base64_file_input(data): + binary: str | bytes | None = None + + if isinstance(data, pathlib.Path): + binary = await anyio.Path(data).read_bytes() + elif isinstance(data, io.IOBase): + binary = data.read() + + if isinstance(binary, str): # type: ignore[unreachable] + binary = binary.encode() + + if not isinstance(binary, bytes): + raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") + + return base64.b64encode(binary).decode("ascii") + + return data + + +async def _async_transform_typeddict( + data: Mapping[str, object], + expected_type: type, +) -> Mapping[str, object]: + result: dict[str, object] = {} + annotations = get_type_hints(expected_type, include_extras=True) + for key, value in data.items(): + if not is_given(value): + # we don't need to include omitted values here as they'll + # be stripped out before the request is sent anyway + continue + + type_ = annotations.get(key) + if type_ is None: + # we do not have a type annotation for this field, leave it as is + result[key] = value + else: + result[_maybe_transform_key(key, type_)] = await _async_transform_recursive(value, annotation=type_) + return result + + +@lru_cache(maxsize=8096) +def get_type_hints( + obj: Any, + globalns: dict[str, Any] | None = None, + localns: Mapping[str, Any] | None = None, + include_extras: bool = False, +) -> dict[str, Any]: + return _get_type_hints(obj, globalns=globalns, localns=localns, include_extras=include_extras) diff --git a/src/beeper_desktop_api/_utils/_typing.py b/src/beeper_desktop_api/_utils/_typing.py new file mode 100644 index 0000000..193109f --- /dev/null +++ b/src/beeper_desktop_api/_utils/_typing.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import sys +import typing +import typing_extensions +from typing import Any, TypeVar, Iterable, cast +from collections import abc as _c_abc +from typing_extensions import ( + TypeIs, + Required, + Annotated, + get_args, + get_origin, +) + +from ._utils import lru_cache +from .._types import InheritsGeneric +from ._compat import is_union as _is_union + + +def is_annotated_type(typ: type) -> bool: + return get_origin(typ) == Annotated + + +def is_list_type(typ: type) -> bool: + return (get_origin(typ) or typ) == list + + +def is_sequence_type(typ: type) -> bool: + origin = get_origin(typ) or typ + return origin == typing_extensions.Sequence or origin == typing.Sequence or origin == _c_abc.Sequence + + +def is_iterable_type(typ: type) -> bool: + """If the given type is `typing.Iterable[T]`""" + origin = get_origin(typ) or typ + return origin == Iterable or origin == _c_abc.Iterable + + +def is_union_type(typ: type) -> bool: + return _is_union(get_origin(typ)) + + +def is_required_type(typ: type) -> bool: + return get_origin(typ) == Required + + +def is_typevar(typ: type) -> bool: + # type ignore is required because type checkers + # think this expression will always return False + return type(typ) == TypeVar # type: ignore + + +_TYPE_ALIAS_TYPES: tuple[type[typing_extensions.TypeAliasType], ...] = (typing_extensions.TypeAliasType,) +if sys.version_info >= (3, 12): + _TYPE_ALIAS_TYPES = (*_TYPE_ALIAS_TYPES, typing.TypeAliasType) + + +def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]: + """Return whether the provided argument is an instance of `TypeAliasType`. + + ```python + type Int = int + is_type_alias_type(Int) + # > True + Str = TypeAliasType("Str", str) + is_type_alias_type(Str) + # > True + ``` + """ + return isinstance(tp, _TYPE_ALIAS_TYPES) + + +# Extracts T from Annotated[T, ...] or from Required[Annotated[T, ...]] +@lru_cache(maxsize=8096) +def strip_annotated_type(typ: type) -> type: + if is_required_type(typ) or is_annotated_type(typ): + return strip_annotated_type(cast(type, get_args(typ)[0])) + + return typ + + +def extract_type_arg(typ: type, index: int) -> type: + args = get_args(typ) + try: + return cast(type, args[index]) + except IndexError as err: + raise RuntimeError(f"Expected type {typ} to have a type argument at index {index} but it did not") from err + + +def extract_type_var_from_base( + typ: type, + *, + generic_bases: tuple[type, ...], + index: int, + failure_message: str | None = None, +) -> type: + """Given a type like `Foo[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyResponse(Foo[bytes]): + ... + + extract_type_var(MyResponse, bases=(Foo,), index=0) -> bytes + ``` + + And where a generic subclass is given: + ```py + _T = TypeVar('_T') + class MyResponse(Foo[_T]): + ... + + extract_type_var(MyResponse[bytes], bases=(Foo,), index=0) -> bytes + ``` + """ + cls = cast(object, get_origin(typ) or typ) + if cls in generic_bases: # pyright: ignore[reportUnnecessaryContains] + # we're given the class directly + return extract_type_arg(typ, index) + + # if a subclass is given + # --- + # this is needed as __orig_bases__ is not present in the typeshed stubs + # because it is intended to be for internal use only, however there does + # not seem to be a way to resolve generic TypeVars for inherited subclasses + # without using it. + if isinstance(cls, InheritsGeneric): + target_base_class: Any | None = None + for base in cls.__orig_bases__: + if base.__origin__ in generic_bases: + target_base_class = base + break + + if target_base_class is None: + raise RuntimeError( + "Could not find the generic base class;\n" + "This should never happen;\n" + f"Does {cls} inherit from one of {generic_bases} ?" + ) + + extracted = extract_type_arg(target_base_class, index) + if is_typevar(extracted): + # If the extracted type argument is itself a type variable + # then that means the subclass itself is generic, so we have + # to resolve the type argument from the class itself, not + # the base class. + # + # Note: if there is more than 1 type argument, the subclass could + # change the ordering of the type arguments, this is not currently + # supported. + return extract_type_arg(typ, index) + + return extracted + + raise RuntimeError(failure_message or f"Could not resolve inner type variable at index {index} for {typ}") diff --git a/src/beeper_desktop_api/_utils/_utils.py b/src/beeper_desktop_api/_utils/_utils.py new file mode 100644 index 0000000..50d5926 --- /dev/null +++ b/src/beeper_desktop_api/_utils/_utils.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +import os +import re +import inspect +import functools +from typing import ( + Any, + Tuple, + Mapping, + TypeVar, + Callable, + Iterable, + Sequence, + cast, + overload, +) +from pathlib import Path +from datetime import date, datetime +from typing_extensions import TypeGuard + +import sniffio + +from .._types import Omit, NotGiven, FileTypes, HeadersLike + +_T = TypeVar("_T") +_TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) +_MappingT = TypeVar("_MappingT", bound=Mapping[str, object]) +_SequenceT = TypeVar("_SequenceT", bound=Sequence[object]) +CallableT = TypeVar("CallableT", bound=Callable[..., Any]) + + +def flatten(t: Iterable[Iterable[_T]]) -> list[_T]: + return [item for sublist in t for item in sublist] + + +def extract_files( + # TODO: this needs to take Dict but variance issues..... + # create protocol type ? + query: Mapping[str, object], + *, + paths: Sequence[Sequence[str]], +) -> list[tuple[str, FileTypes]]: + """Recursively extract files from the given dictionary based on specified paths. + + A path may look like this ['foo', 'files', '', 'data']. + + 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)) + return files + + +def _extract_items( + obj: object, + path: Sequence[str], + *, + index: int, + flattened_key: str | None, +) -> list[tuple[str, FileTypes]]: + try: + key = path[index] + except IndexError: + if not is_given(obj): + # no value was provided - we can safely ignore + return [] + + # cyclical import + from .._files import assert_is_file_content + + # We have exhausted the path, return the entry we found. + assert flattened_key is not None + + 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))) + return files + + assert_is_file_content(obj, key=flattened_key) + return [(flattened_key, cast(FileTypes, obj))] + + 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: + item = obj.pop(key) + else: + item = obj[key] + except KeyError: + # Key was not present in the dictionary, this is not indicative of an error + # as the given path may not point to a required field. We also do not want + # to enforce required fields as the API may differ from the spec in some cases. + return [] + if flattened_key is None: + flattened_key = key + else: + flattened_key += f"[{key}]" + return _extract_items( + item, + path, + index=index, + flattened_key=flattened_key, + ) + elif is_list(obj): + if key != "": + return [] + + return flatten( + [ + _extract_items( + item, + path, + index=index, + flattened_key=flattened_key + "[]" if flattened_key is not None else "[]", + ) + for item in obj + ] + ) + + # Something unexpected was passed, just ignore it. + return [] + + +def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: + return not isinstance(obj, NotGiven) and not isinstance(obj, Omit) + + +# Type safe methods for narrowing types with TypeVars. +# The default narrowing for isinstance(obj, dict) is dict[unknown, unknown], +# however this cause Pyright to rightfully report errors. As we know we don't +# care about the contained types we can safely use `object` in it's place. +# +# There are two separate functions defined, `is_*` and `is_*_t` for different use cases. +# `is_*` is for when you're dealing with an unknown input +# `is_*_t` is for when you're narrowing a known union type to a specific subset + + +def is_tuple(obj: object) -> TypeGuard[tuple[object, ...]]: + return isinstance(obj, tuple) + + +def is_tuple_t(obj: _TupleT | object) -> TypeGuard[_TupleT]: + return isinstance(obj, tuple) + + +def is_sequence(obj: object) -> TypeGuard[Sequence[object]]: + return isinstance(obj, Sequence) + + +def is_sequence_t(obj: _SequenceT | object) -> TypeGuard[_SequenceT]: + return isinstance(obj, Sequence) + + +def is_mapping(obj: object) -> TypeGuard[Mapping[str, object]]: + return isinstance(obj, Mapping) + + +def is_mapping_t(obj: _MappingT | object) -> TypeGuard[_MappingT]: + return isinstance(obj, Mapping) + + +def is_dict(obj: object) -> TypeGuard[dict[object, object]]: + return isinstance(obj, dict) + + +def is_list(obj: object) -> TypeGuard[list[object]]: + return isinstance(obj, list) + + +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) + if size == 0: + return "" + + if size == 1: + return seq[0] + + if size == 2: + return f"{seq[0]} {final} {seq[1]}" + + return delim.join(seq[:-1]) + f" {final} {seq[-1]}" + + +def quote(string: str) -> str: + """Add single quotation marks around the given string. Does *not* do any escaping.""" + return f"'{string}'" + + +def required_args(*variants: Sequence[str]) -> Callable[[CallableT], CallableT]: + """Decorator to enforce a given set of arguments or variants of arguments are passed to the decorated function. + + Useful for enforcing runtime validation of overloaded functions. + + Example usage: + ```py + @overload + def foo(*, a: str) -> str: ... + + + @overload + def foo(*, b: bool) -> str: ... + + + # This enforces the same constraints that a static type checker would + # i.e. that either a or b must be passed to the function + @required_args(["a"], ["b"]) + def foo(*, a: str | None = None, b: bool | None = None) -> str: ... + ``` + """ + + def inner(func: CallableT) -> CallableT: + params = inspect.signature(func).parameters + positional = [ + name + for name, param in params.items() + if param.kind + in { + param.POSITIONAL_ONLY, + param.POSITIONAL_OR_KEYWORD, + } + ] + + @functools.wraps(func) + def wrapper(*args: object, **kwargs: object) -> object: + given_params: set[str] = set() + for i, _ in enumerate(args): + try: + given_params.add(positional[i]) + except IndexError: + raise TypeError( + f"{func.__name__}() takes {len(positional)} argument(s) but {len(args)} were given" + ) from None + + for key in kwargs.keys(): + given_params.add(key) + + for variant in variants: + matches = all((param in given_params for param in variant)) + if matches: + break + else: # no break + if len(variants) > 1: + variations = human_join( + ["(" + human_join([quote(arg) for arg in variant], final="and") + ")" for variant in variants] + ) + msg = f"Missing required arguments; Expected either {variations} arguments to be given" + else: + assert len(variants) > 0 + + # TODO: this error message is not deterministic + missing = list(set(variants[0]) - given_params) + if len(missing) > 1: + msg = f"Missing required arguments: {human_join([quote(arg) for arg in missing])}" + else: + msg = f"Missing required argument: {quote(missing[0])}" + raise TypeError(msg) + return func(*args, **kwargs) + + return wrapper # type: ignore + + return inner + + +_K = TypeVar("_K") +_V = TypeVar("_V") + + +@overload +def strip_not_given(obj: None) -> None: ... + + +@overload +def strip_not_given(obj: Mapping[_K, _V | NotGiven]) -> dict[_K, _V]: ... + + +@overload +def strip_not_given(obj: object) -> object: ... + + +def strip_not_given(obj: object | None) -> object: + """Remove all top-level keys where their values are instances of `NotGiven`""" + if obj is None: + return None + + if not is_mapping(obj): + return obj + + return {key: value for key, value in obj.items() if not isinstance(value, NotGiven)} + + +def coerce_integer(val: str) -> int: + return int(val, base=10) + + +def coerce_float(val: str) -> float: + return float(val) + + +def coerce_boolean(val: str) -> bool: + return val == "true" or val == "1" or val == "on" + + +def maybe_coerce_integer(val: str | None) -> int | None: + if val is None: + return None + return coerce_integer(val) + + +def maybe_coerce_float(val: str | None) -> float | None: + if val is None: + return None + return coerce_float(val) + + +def maybe_coerce_boolean(val: str | None) -> bool | None: + if val is None: + return None + return coerce_boolean(val) + + +def removeprefix(string: str, prefix: str) -> str: + """Remove a prefix from a string. + + Backport of `str.removeprefix` for Python < 3.9 + """ + if string.startswith(prefix): + return string[len(prefix) :] + return string + + +def removesuffix(string: str, suffix: str) -> str: + """Remove a suffix from a string. + + Backport of `str.removesuffix` for Python < 3.9 + """ + if string.endswith(suffix): + return string[: -len(suffix)] + return string + + +def file_from_path(path: str) -> FileTypes: + contents = Path(path).read_bytes() + file_name = os.path.basename(path) + return (file_name, contents) + + +def get_required_header(headers: HeadersLike, header: str) -> str: + lower_header = header.lower() + if is_mapping_t(headers): + # mypy doesn't understand the type narrowing here + for k, v in headers.items(): # type: ignore + if k.lower() == lower_header and isinstance(v, str): + return v + + # to deal with the case where the header looks like Stainless-Event-Id + intercaps_header = re.sub(r"([^\w])(\w)", lambda pat: pat.group(1) + pat.group(2).upper(), header.capitalize()) + + for normalized_header in [header, lower_header, header.upper(), intercaps_header]: + value = headers.get(normalized_header) + if value: + return value + + raise ValueError(f"Could not find {header} header") + + +def get_async_library() -> str: + try: + return sniffio.current_async_library() + except Exception: + return "false" + + +def lru_cache(*, maxsize: int | None = 128) -> Callable[[CallableT], CallableT]: + """A version of functools.lru_cache that retains the type signature + for the wrapped function arguments. + """ + wrapper = functools.lru_cache( # noqa: TID251 + maxsize=maxsize, + ) + return cast(Any, wrapper) # type: ignore[no-any-return] + + +def json_safe(data: object) -> object: + """Translates a mapping / sequence recursively in the same fashion + as `pydantic` v2's `model_dump(mode="json")`. + """ + if is_mapping(data): + return {json_safe(key): json_safe(value) for key, value in data.items()} + + if is_iterable(data) and not isinstance(data, (str, bytes, bytearray)): + return [json_safe(item) for item in data] + + if isinstance(data, (datetime, date)): + return data.isoformat() + + return data diff --git a/src/beeper_desktop_api/_version.py b/src/beeper_desktop_api/_version.py new file mode 100644 index 0000000..72a9009 --- /dev/null +++ b/src/beeper_desktop_api/_version.py @@ -0,0 +1,4 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +__title__ = "beeper_desktop_api" +__version__ = "0.0.1" diff --git a/src/beeper_desktop_api/lib/.keep b/src/beeper_desktop_api/lib/.keep new file mode 100644 index 0000000..5e2c99f --- /dev/null +++ b/src/beeper_desktop_api/lib/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store custom files to expand the SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/src/beeper_desktop_api/pagination.py b/src/beeper_desktop_api/pagination.py new file mode 100644 index 0000000..4606312 --- /dev/null +++ b/src/beeper_desktop_api/pagination.py @@ -0,0 +1,72 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +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__ = ["SyncCursor", "AsyncCursor"] + +_T = TypeVar("_T") + + +class SyncCursor(BaseSyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) + oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) + newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", 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]: + oldest_cursor = self.oldest_cursor + if not oldest_cursor: + return None + + return PageInfo(params={"cursor": oldest_cursor}) + + +class AsyncCursor(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) + oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) + newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", 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]: + oldest_cursor = self.oldest_cursor + if not oldest_cursor: + return None + + return PageInfo(params={"cursor": oldest_cursor}) diff --git a/src/beeper_desktop_api/py.typed b/src/beeper_desktop_api/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/beeper_desktop_api/resources/__init__.py b/src/beeper_desktop_api/resources/__init__.py new file mode 100644 index 0000000..24ab242 --- /dev/null +++ b/src/beeper_desktop_api/resources/__init__.py @@ -0,0 +1,75 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .chats import ( + ChatsResource, + AsyncChatsResource, + ChatsResourceWithRawResponse, + AsyncChatsResourceWithRawResponse, + ChatsResourceWithStreamingResponse, + AsyncChatsResourceWithStreamingResponse, +) +from .token import ( + TokenResource, + AsyncTokenResource, + TokenResourceWithRawResponse, + AsyncTokenResourceWithRawResponse, + TokenResourceWithStreamingResponse, + AsyncTokenResourceWithStreamingResponse, +) +from .accounts import ( + AccountsResource, + AsyncAccountsResource, + AccountsResourceWithRawResponse, + AsyncAccountsResourceWithRawResponse, + AccountsResourceWithStreamingResponse, + AsyncAccountsResourceWithStreamingResponse, +) +from .contacts import ( + ContactsResource, + AsyncContactsResource, + ContactsResourceWithRawResponse, + AsyncContactsResourceWithRawResponse, + ContactsResourceWithStreamingResponse, + AsyncContactsResourceWithStreamingResponse, +) +from .messages import ( + MessagesResource, + AsyncMessagesResource, + MessagesResourceWithRawResponse, + AsyncMessagesResourceWithRawResponse, + MessagesResourceWithStreamingResponse, + AsyncMessagesResourceWithStreamingResponse, +) + +__all__ = [ + "AccountsResource", + "AsyncAccountsResource", + "AccountsResourceWithRawResponse", + "AsyncAccountsResourceWithRawResponse", + "AccountsResourceWithStreamingResponse", + "AsyncAccountsResourceWithStreamingResponse", + "ContactsResource", + "AsyncContactsResource", + "ContactsResourceWithRawResponse", + "AsyncContactsResourceWithRawResponse", + "ContactsResourceWithStreamingResponse", + "AsyncContactsResourceWithStreamingResponse", + "ChatsResource", + "AsyncChatsResource", + "ChatsResourceWithRawResponse", + "AsyncChatsResourceWithRawResponse", + "ChatsResourceWithStreamingResponse", + "AsyncChatsResourceWithStreamingResponse", + "MessagesResource", + "AsyncMessagesResource", + "MessagesResourceWithRawResponse", + "AsyncMessagesResourceWithRawResponse", + "MessagesResourceWithStreamingResponse", + "AsyncMessagesResourceWithStreamingResponse", + "TokenResource", + "AsyncTokenResource", + "TokenResourceWithRawResponse", + "AsyncTokenResourceWithRawResponse", + "TokenResourceWithStreamingResponse", + "AsyncTokenResourceWithStreamingResponse", +] diff --git a/src/beeper_desktop_api/resources/accounts.py b/src/beeper_desktop_api/resources/accounts.py new file mode 100644 index 0000000..8a74cd9 --- /dev/null +++ b/src/beeper_desktop_api/resources/accounts.py @@ -0,0 +1,139 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from .._types import Body, Query, Headers, NotGiven, not_given +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.account_list_response import AccountListResponse + +__all__ = ["AccountsResource", "AsyncAccountsResource"] + + +class AccountsResource(SyncAPIResource): + """Accounts operations""" + + @cached_property + def with_raw_response(self) -> AccountsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AccountsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AccountsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + """ + return AccountsResourceWithStreamingResponse(self) + + def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AccountListResponse: + """List connected Beeper accounts available on this device""" + return self._get( + "/v0/get-accounts", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AccountListResponse, + ) + + +class AsyncAccountsResource(AsyncAPIResource): + """Accounts operations""" + + @cached_property + def with_raw_response(self) -> AsyncAccountsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncAccountsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAccountsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + """ + return AsyncAccountsResourceWithStreamingResponse(self) + + async def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AccountListResponse: + """List connected Beeper accounts available on this device""" + return await self._get( + "/v0/get-accounts", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AccountListResponse, + ) + + +class AccountsResourceWithRawResponse: + def __init__(self, accounts: AccountsResource) -> None: + self._accounts = accounts + + self.list = to_raw_response_wrapper( + accounts.list, + ) + + +class AsyncAccountsResourceWithRawResponse: + def __init__(self, accounts: AsyncAccountsResource) -> None: + self._accounts = accounts + + self.list = async_to_raw_response_wrapper( + accounts.list, + ) + + +class AccountsResourceWithStreamingResponse: + def __init__(self, accounts: AccountsResource) -> None: + self._accounts = accounts + + self.list = to_streamed_response_wrapper( + accounts.list, + ) + + +class AsyncAccountsResourceWithStreamingResponse: + def __init__(self, accounts: AsyncAccountsResource) -> None: + self._accounts = accounts + + self.list = async_to_streamed_response_wrapper( + accounts.list, + ) diff --git a/src/beeper_desktop_api/resources/chats/__init__.py b/src/beeper_desktop_api/resources/chats/__init__.py new file mode 100644 index 0000000..e26ae7f --- /dev/null +++ b/src/beeper_desktop_api/resources/chats/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .chats import ( + ChatsResource, + AsyncChatsResource, + ChatsResourceWithRawResponse, + AsyncChatsResourceWithRawResponse, + ChatsResourceWithStreamingResponse, + AsyncChatsResourceWithStreamingResponse, +) +from .reminders import ( + RemindersResource, + AsyncRemindersResource, + RemindersResourceWithRawResponse, + AsyncRemindersResourceWithRawResponse, + RemindersResourceWithStreamingResponse, + AsyncRemindersResourceWithStreamingResponse, +) + +__all__ = [ + "RemindersResource", + "AsyncRemindersResource", + "RemindersResourceWithRawResponse", + "AsyncRemindersResourceWithRawResponse", + "RemindersResourceWithStreamingResponse", + "AsyncRemindersResourceWithStreamingResponse", + "ChatsResource", + "AsyncChatsResource", + "ChatsResourceWithRawResponse", + "AsyncChatsResourceWithRawResponse", + "ChatsResourceWithStreamingResponse", + "AsyncChatsResourceWithStreamingResponse", +] diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py new file mode 100644 index 0000000..a400387 --- /dev/null +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -0,0 +1,680 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union, Optional +from datetime import datetime +from typing_extensions import Literal + +import httpx + +from ...types import chat_create_params, chat_search_params, chat_archive_params, chat_retrieve_params +from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from .reminders import ( + RemindersResource, + AsyncRemindersResource, + RemindersResourceWithRawResponse, + AsyncRemindersResourceWithRawResponse, + RemindersResourceWithStreamingResponse, + AsyncRemindersResourceWithStreamingResponse, +) +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ...pagination import SyncCursor, AsyncCursor +from ...types.chat import Chat +from ..._base_client import AsyncPaginator, make_request_options +from ...types.chat_create_response import ChatCreateResponse +from ...types.shared.base_response import BaseResponse + +__all__ = ["ChatsResource", "AsyncChatsResource"] + + +class ChatsResource(SyncAPIResource): + """Chats operations""" + + @cached_property + def reminders(self) -> RemindersResource: + """Reminders operations""" + return RemindersResource(self._client) + + @cached_property + def with_raw_response(self) -> ChatsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + """ + return ChatsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ChatsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + """ + return ChatsResourceWithStreamingResponse(self) + + def create( + self, + *, + account_id: str, + participant_ids: SequenceNotStr[str], + type: Literal["single", "group"], + message_text: str | Omit = omit, + title: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ChatCreateResponse: + """ + Create a single or group chat on a specific account using participant IDs and + optional title. + + Args: + account_id: Account to create the chat on. + + participant_ids: User IDs to include in the new chat. + + type: Chat type to create: 'single' requires exactly one participantID; 'group' + supports multiple participants and optional title. + + message_text: Optional first message content if the platform requires it to create the chat. + + title: Optional title for group chats; ignored for single chats on most platforms. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v0/create-chat", + body=maybe_transform( + { + "account_id": account_id, + "participant_ids": participant_ids, + "type": type, + "message_text": message_text, + "title": title, + }, + chat_create_params.ChatCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ChatCreateResponse, + ) + + def retrieve( + self, + *, + chat_id: str, + max_participant_count: Optional[int] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Chat: + """ + Retrieve chat details including metadata, participants, and latest message + + Args: + chat_id: Unique identifier of the chat to retrieve. Not available for iMessage chats. + Participants are limited by 'maxParticipantCount'. + + max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0–500. + Defaults to 20. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/v0/get-chat", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "chat_id": chat_id, + "max_participant_count": max_participant_count, + }, + chat_retrieve_params.ChatRetrieveParams, + ), + ), + cast_to=Chat, + ) + + def archive( + self, + *, + chat_id: str, + archived: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BaseResponse: + """Archive or unarchive a chat. + + Set archived=true to move to archive, + archived=false to move back to inbox + + Args: + chat_id: The identifier of the chat to archive or unarchive (accepts both chatID and + local chat ID) + + archived: True to archive, false to unarchive + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v0/archive-chat", + body=maybe_transform( + { + "chat_id": chat_id, + "archived": archived, + }, + chat_archive_params.ChatArchiveParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BaseResponse, + ) + + def search( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, + include_muted: Optional[bool] | Omit = omit, + last_activity_after: Union[str, datetime] | Omit = omit, + last_activity_before: Union[str, datetime] | Omit = omit, + limit: int | Omit = omit, + query: str | Omit = omit, + scope: Literal["titles", "participants"] | Omit = omit, + type: Literal["single", "group", "any"] | Omit = omit, + unread_only: Optional[bool] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SyncCursor[Chat]: + """ + Search chats by title/network or participants using Beeper Desktop's renderer + algorithm. + + Args: + account_ids: Provide an array of account IDs to filter chats from specific messaging accounts + only + + cursor: Pagination cursor from previous response. Use with direction to navigate results + + direction: Pagination direction: "after" for newer page, "before" for older page. Defaults + to "before" when only cursor is provided. + + inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), + "low-priority", or "archive". If not specified, shows all chats. + + include_muted: Include chats marked as Muted by the user, which are usually less important. + Default: true. Set to false if the user wants a more refined search. + + last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after + this time + + last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before + this time + + limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 + + query: Literal token search (non-semantic). Use single words users type (e.g., + "dinner"). When multiple words provided, ALL must match. Case-insensitive. + + scope: Search scope: 'titles' matches title + network; 'participants' matches + participant names. + + type: Specify the type of chats to retrieve: use "single" for direct messages, "group" + for group chats, or "any" to get all types + + unread_only: Set to true to only retrieve chats that have unread messages + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v0/search-chats", + page=SyncCursor[Chat], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "cursor": cursor, + "direction": direction, + "inbox": inbox, + "include_muted": include_muted, + "last_activity_after": last_activity_after, + "last_activity_before": last_activity_before, + "limit": limit, + "query": query, + "scope": scope, + "type": type, + "unread_only": unread_only, + }, + chat_search_params.ChatSearchParams, + ), + ), + model=Chat, + ) + + +class AsyncChatsResource(AsyncAPIResource): + """Chats operations""" + + @cached_property + def reminders(self) -> AsyncRemindersResource: + """Reminders operations""" + return AsyncRemindersResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncChatsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncChatsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncChatsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + """ + return AsyncChatsResourceWithStreamingResponse(self) + + async def create( + self, + *, + account_id: str, + participant_ids: SequenceNotStr[str], + type: Literal["single", "group"], + message_text: str | Omit = omit, + title: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ChatCreateResponse: + """ + Create a single or group chat on a specific account using participant IDs and + optional title. + + Args: + account_id: Account to create the chat on. + + participant_ids: User IDs to include in the new chat. + + type: Chat type to create: 'single' requires exactly one participantID; 'group' + supports multiple participants and optional title. + + message_text: Optional first message content if the platform requires it to create the chat. + + title: Optional title for group chats; ignored for single chats on most platforms. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v0/create-chat", + body=await async_maybe_transform( + { + "account_id": account_id, + "participant_ids": participant_ids, + "type": type, + "message_text": message_text, + "title": title, + }, + chat_create_params.ChatCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ChatCreateResponse, + ) + + async def retrieve( + self, + *, + chat_id: str, + max_participant_count: Optional[int] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Chat: + """ + Retrieve chat details including metadata, participants, and latest message + + Args: + chat_id: Unique identifier of the chat to retrieve. Not available for iMessage chats. + Participants are limited by 'maxParticipantCount'. + + max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0–500. + Defaults to 20. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/v0/get-chat", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "chat_id": chat_id, + "max_participant_count": max_participant_count, + }, + chat_retrieve_params.ChatRetrieveParams, + ), + ), + cast_to=Chat, + ) + + async def archive( + self, + *, + chat_id: str, + archived: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BaseResponse: + """Archive or unarchive a chat. + + Set archived=true to move to archive, + archived=false to move back to inbox + + Args: + chat_id: The identifier of the chat to archive or unarchive (accepts both chatID and + local chat ID) + + archived: True to archive, false to unarchive + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v0/archive-chat", + body=await async_maybe_transform( + { + "chat_id": chat_id, + "archived": archived, + }, + chat_archive_params.ChatArchiveParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BaseResponse, + ) + + def search( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, + include_muted: Optional[bool] | Omit = omit, + last_activity_after: Union[str, datetime] | Omit = omit, + last_activity_before: Union[str, datetime] | Omit = omit, + limit: int | Omit = omit, + query: str | Omit = omit, + scope: Literal["titles", "participants"] | Omit = omit, + type: Literal["single", "group", "any"] | Omit = omit, + unread_only: Optional[bool] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncPaginator[Chat, AsyncCursor[Chat]]: + """ + Search chats by title/network or participants using Beeper Desktop's renderer + algorithm. + + Args: + account_ids: Provide an array of account IDs to filter chats from specific messaging accounts + only + + cursor: Pagination cursor from previous response. Use with direction to navigate results + + direction: Pagination direction: "after" for newer page, "before" for older page. Defaults + to "before" when only cursor is provided. + + inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), + "low-priority", or "archive". If not specified, shows all chats. + + include_muted: Include chats marked as Muted by the user, which are usually less important. + Default: true. Set to false if the user wants a more refined search. + + last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after + this time + + last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before + this time + + limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 + + query: Literal token search (non-semantic). Use single words users type (e.g., + "dinner"). When multiple words provided, ALL must match. Case-insensitive. + + scope: Search scope: 'titles' matches title + network; 'participants' matches + participant names. + + type: Specify the type of chats to retrieve: use "single" for direct messages, "group" + for group chats, or "any" to get all types + + unread_only: Set to true to only retrieve chats that have unread messages + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v0/search-chats", + page=AsyncCursor[Chat], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "cursor": cursor, + "direction": direction, + "inbox": inbox, + "include_muted": include_muted, + "last_activity_after": last_activity_after, + "last_activity_before": last_activity_before, + "limit": limit, + "query": query, + "scope": scope, + "type": type, + "unread_only": unread_only, + }, + chat_search_params.ChatSearchParams, + ), + ), + model=Chat, + ) + + +class ChatsResourceWithRawResponse: + def __init__(self, chats: ChatsResource) -> None: + self._chats = chats + + self.create = to_raw_response_wrapper( + chats.create, + ) + self.retrieve = to_raw_response_wrapper( + chats.retrieve, + ) + self.archive = to_raw_response_wrapper( + chats.archive, + ) + self.search = to_raw_response_wrapper( + chats.search, + ) + + @cached_property + def reminders(self) -> RemindersResourceWithRawResponse: + """Reminders operations""" + return RemindersResourceWithRawResponse(self._chats.reminders) + + +class AsyncChatsResourceWithRawResponse: + def __init__(self, chats: AsyncChatsResource) -> None: + self._chats = chats + + self.create = async_to_raw_response_wrapper( + chats.create, + ) + self.retrieve = async_to_raw_response_wrapper( + chats.retrieve, + ) + self.archive = async_to_raw_response_wrapper( + chats.archive, + ) + self.search = async_to_raw_response_wrapper( + chats.search, + ) + + @cached_property + def reminders(self) -> AsyncRemindersResourceWithRawResponse: + """Reminders operations""" + return AsyncRemindersResourceWithRawResponse(self._chats.reminders) + + +class ChatsResourceWithStreamingResponse: + def __init__(self, chats: ChatsResource) -> None: + self._chats = chats + + self.create = to_streamed_response_wrapper( + chats.create, + ) + self.retrieve = to_streamed_response_wrapper( + chats.retrieve, + ) + self.archive = to_streamed_response_wrapper( + chats.archive, + ) + self.search = to_streamed_response_wrapper( + chats.search, + ) + + @cached_property + def reminders(self) -> RemindersResourceWithStreamingResponse: + """Reminders operations""" + return RemindersResourceWithStreamingResponse(self._chats.reminders) + + +class AsyncChatsResourceWithStreamingResponse: + def __init__(self, chats: AsyncChatsResource) -> None: + self._chats = chats + + self.create = async_to_streamed_response_wrapper( + chats.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + chats.retrieve, + ) + self.archive = async_to_streamed_response_wrapper( + chats.archive, + ) + self.search = async_to_streamed_response_wrapper( + chats.search, + ) + + @cached_property + def reminders(self) -> AsyncRemindersResourceWithStreamingResponse: + """Reminders operations""" + return AsyncRemindersResourceWithStreamingResponse(self._chats.reminders) diff --git a/src/beeper_desktop_api/resources/chats/reminders.py b/src/beeper_desktop_api/resources/chats/reminders.py new file mode 100644 index 0000000..61cfc5a --- /dev/null +++ b/src/beeper_desktop_api/resources/chats/reminders.py @@ -0,0 +1,273 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ...types.chats import reminder_create_params, reminder_delete_params +from ..._base_client import make_request_options +from ...types.shared.base_response import BaseResponse + +__all__ = ["RemindersResource", "AsyncRemindersResource"] + + +class RemindersResource(SyncAPIResource): + """Reminders operations""" + + @cached_property + def with_raw_response(self) -> RemindersResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + """ + return RemindersResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> RemindersResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + """ + return RemindersResourceWithStreamingResponse(self) + + def create( + self, + *, + chat_id: str, + reminder: reminder_create_params.Reminder, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BaseResponse: + """ + Set a reminder for a chat at a specific time + + Args: + chat_id: The identifier of the chat to set reminder for (accepts both chatID and local + chat ID) + + reminder: Reminder configuration + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v0/set-chat-reminder", + body=maybe_transform( + { + "chat_id": chat_id, + "reminder": reminder, + }, + reminder_create_params.ReminderCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BaseResponse, + ) + + def delete( + self, + *, + chat_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BaseResponse: + """ + Clear an existing reminder from a chat + + Args: + chat_id: The identifier of the chat to clear reminder from (accepts both chatID and local + chat ID) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v0/clear-chat-reminder", + body=maybe_transform({"chat_id": chat_id}, reminder_delete_params.ReminderDeleteParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BaseResponse, + ) + + +class AsyncRemindersResource(AsyncAPIResource): + """Reminders operations""" + + @cached_property + def with_raw_response(self) -> AsyncRemindersResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncRemindersResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncRemindersResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + """ + return AsyncRemindersResourceWithStreamingResponse(self) + + async def create( + self, + *, + chat_id: str, + reminder: reminder_create_params.Reminder, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BaseResponse: + """ + Set a reminder for a chat at a specific time + + Args: + chat_id: The identifier of the chat to set reminder for (accepts both chatID and local + chat ID) + + reminder: Reminder configuration + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v0/set-chat-reminder", + body=await async_maybe_transform( + { + "chat_id": chat_id, + "reminder": reminder, + }, + reminder_create_params.ReminderCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BaseResponse, + ) + + async def delete( + self, + *, + chat_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BaseResponse: + """ + Clear an existing reminder from a chat + + Args: + chat_id: The identifier of the chat to clear reminder from (accepts both chatID and local + chat ID) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v0/clear-chat-reminder", + body=await async_maybe_transform({"chat_id": chat_id}, reminder_delete_params.ReminderDeleteParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BaseResponse, + ) + + +class RemindersResourceWithRawResponse: + def __init__(self, reminders: RemindersResource) -> None: + self._reminders = reminders + + self.create = to_raw_response_wrapper( + reminders.create, + ) + self.delete = to_raw_response_wrapper( + reminders.delete, + ) + + +class AsyncRemindersResourceWithRawResponse: + def __init__(self, reminders: AsyncRemindersResource) -> None: + self._reminders = reminders + + self.create = async_to_raw_response_wrapper( + reminders.create, + ) + self.delete = async_to_raw_response_wrapper( + reminders.delete, + ) + + +class RemindersResourceWithStreamingResponse: + def __init__(self, reminders: RemindersResource) -> None: + self._reminders = reminders + + self.create = to_streamed_response_wrapper( + reminders.create, + ) + self.delete = to_streamed_response_wrapper( + reminders.delete, + ) + + +class AsyncRemindersResourceWithStreamingResponse: + def __init__(self, reminders: AsyncRemindersResource) -> None: + self._reminders = reminders + + self.create = async_to_streamed_response_wrapper( + reminders.create, + ) + self.delete = async_to_streamed_response_wrapper( + reminders.delete, + ) diff --git a/src/beeper_desktop_api/resources/contacts.py b/src/beeper_desktop_api/resources/contacts.py new file mode 100644 index 0000000..bdcd990 --- /dev/null +++ b/src/beeper_desktop_api/resources/contacts.py @@ -0,0 +1,199 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import contact_search_params +from .._types import Body, Query, Headers, NotGiven, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.contact_search_response import ContactSearchResponse + +__all__ = ["ContactsResource", "AsyncContactsResource"] + + +class ContactsResource(SyncAPIResource): + """Contacts operations""" + + @cached_property + def with_raw_response(self) -> ContactsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + """ + return ContactsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ContactsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + """ + return ContactsResourceWithStreamingResponse(self) + + def search( + self, + *, + account_id: str, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ContactSearchResponse: + """Search users across on a specific account using the network's search API. + + Only + use for creating new chats. + + Args: + account_id: Beeper account ID this resource belongs to. + + query: Text to search users by. Network-specific behavior. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/v0/search-users", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_id": account_id, + "query": query, + }, + contact_search_params.ContactSearchParams, + ), + ), + cast_to=ContactSearchResponse, + ) + + +class AsyncContactsResource(AsyncAPIResource): + """Contacts operations""" + + @cached_property + def with_raw_response(self) -> AsyncContactsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncContactsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncContactsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + """ + return AsyncContactsResourceWithStreamingResponse(self) + + async def search( + self, + *, + account_id: str, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ContactSearchResponse: + """Search users across on a specific account using the network's search API. + + Only + use for creating new chats. + + Args: + account_id: Beeper account ID this resource belongs to. + + query: Text to search users by. Network-specific behavior. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/v0/search-users", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "account_id": account_id, + "query": query, + }, + contact_search_params.ContactSearchParams, + ), + ), + cast_to=ContactSearchResponse, + ) + + +class ContactsResourceWithRawResponse: + def __init__(self, contacts: ContactsResource) -> None: + self._contacts = contacts + + self.search = to_raw_response_wrapper( + contacts.search, + ) + + +class AsyncContactsResourceWithRawResponse: + def __init__(self, contacts: AsyncContactsResource) -> None: + self._contacts = contacts + + self.search = async_to_raw_response_wrapper( + contacts.search, + ) + + +class ContactsResourceWithStreamingResponse: + def __init__(self, contacts: ContactsResource) -> None: + self._contacts = contacts + + self.search = to_streamed_response_wrapper( + contacts.search, + ) + + +class AsyncContactsResourceWithStreamingResponse: + def __init__(self, contacts: AsyncContactsResource) -> None: + self._contacts = contacts + + self.search = async_to_streamed_response_wrapper( + contacts.search, + ) diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py new file mode 100644 index 0000000..1cdefc8 --- /dev/null +++ b/src/beeper_desktop_api/resources/messages.py @@ -0,0 +1,423 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Union, Optional +from datetime import datetime +from typing_extensions import Literal + +import httpx + +from ..types import message_send_params, message_search_params +from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..pagination import SyncCursor, AsyncCursor +from .._base_client import AsyncPaginator, make_request_options +from ..types.shared.message import Message +from ..types.message_send_response import MessageSendResponse + +__all__ = ["MessagesResource", "AsyncMessagesResource"] + + +class MessagesResource(SyncAPIResource): + """Messages operations""" + + @cached_property + def with_raw_response(self) -> MessagesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + """ + return MessagesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> MessagesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + """ + return MessagesResourceWithStreamingResponse(self) + + def search( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + chat_ids: SequenceNotStr[str] | Omit = omit, + chat_type: Literal["group", "single"] | Omit = omit, + cursor: str | Omit = omit, + date_after: Union[str, datetime] | Omit = omit, + date_before: Union[str, datetime] | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + exclude_low_priority: Optional[bool] | Omit = omit, + include_muted: Optional[bool] | Omit = omit, + limit: int | Omit = omit, + media_types: List[Literal["any", "video", "image", "link", "file"]] | Omit = omit, + query: str | Omit = omit, + sender: Union[Literal["me", "others"], str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SyncCursor[Message]: + """ + Search messages across chats using Beeper's message index + + Args: + account_ids: Limit search to specific Beeper account IDs (bridge instances). + + chat_ids: Limit search to specific Beeper chat IDs. + + chat_type: Filter by chat type: 'group' for group chats, 'single' for 1:1 chats. + + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. + + date_after: Only include messages with timestamp strictly after this ISO 8601 datetime + (e.g., '2024-07-01T00:00:00Z' or '2024-07-01T00:00:00+02:00'). + + date_before: Only include messages with timestamp strictly before this ISO 8601 datetime + (e.g., '2024-07-31T23:59:59Z' or '2024-07-31T23:59:59+02:00'). + + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + + exclude_low_priority: Exclude messages marked Low Priority by the user. Default: true. Set to false to + include all. + + include_muted: Include messages in chats marked as Muted by the user, which are usually less + important. Default: true. Set to false if the user wants a more refined search. + + limit: Maximum number of messages to return (1–500). Defaults to 20. The current + implementation caps each page at 20 items even if a higher limit is requested. + + media_types: Filter messages by media types. Use ['any'] for any media type, or specify exact + types like ['video', 'image']. Omit for no media filtering. + + query: Literal word search (NOT semantic). Finds messages containing these EXACT words + in any order. Use single words users actually type, not concepts or phrases. + Example: use "dinner" not "dinner plans", use "sick" not "health issues". If + omitted, returns results filtered only by other parameters. + + sender: Filter by sender: 'me' (messages sent by the authenticated user), 'others' + (messages sent by others), or a specific user ID string (user.id). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v0/search-messages", + page=SyncCursor[Message], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "chat_ids": chat_ids, + "chat_type": chat_type, + "cursor": cursor, + "date_after": date_after, + "date_before": date_before, + "direction": direction, + "exclude_low_priority": exclude_low_priority, + "include_muted": include_muted, + "limit": limit, + "media_types": media_types, + "query": query, + "sender": sender, + }, + message_search_params.MessageSearchParams, + ), + ), + model=Message, + ) + + def send( + self, + *, + chat_id: str, + reply_to_message_id: str | Omit = omit, + text: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> MessageSendResponse: + """Send a text message to a specific chat. + + Supports replying to existing messages. + Returns the sent message ID. + + Args: + chat_id: Unique identifier of the chat (a.k.a. room or thread). + + reply_to_message_id: Provide a message ID to send this as a reply to an existing message + + text: Text content of the message you want to send. You may use markdown. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v0/send-message", + body=maybe_transform( + { + "chat_id": chat_id, + "reply_to_message_id": reply_to_message_id, + "text": text, + }, + message_send_params.MessageSendParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=MessageSendResponse, + ) + + +class AsyncMessagesResource(AsyncAPIResource): + """Messages operations""" + + @cached_property + def with_raw_response(self) -> AsyncMessagesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncMessagesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncMessagesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + """ + return AsyncMessagesResourceWithStreamingResponse(self) + + def search( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + chat_ids: SequenceNotStr[str] | Omit = omit, + chat_type: Literal["group", "single"] | Omit = omit, + cursor: str | Omit = omit, + date_after: Union[str, datetime] | Omit = omit, + date_before: Union[str, datetime] | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + exclude_low_priority: Optional[bool] | Omit = omit, + include_muted: Optional[bool] | Omit = omit, + limit: int | Omit = omit, + media_types: List[Literal["any", "video", "image", "link", "file"]] | Omit = omit, + query: str | Omit = omit, + sender: Union[Literal["me", "others"], str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncPaginator[Message, AsyncCursor[Message]]: + """ + Search messages across chats using Beeper's message index + + Args: + account_ids: Limit search to specific Beeper account IDs (bridge instances). + + chat_ids: Limit search to specific Beeper chat IDs. + + chat_type: Filter by chat type: 'group' for group chats, 'single' for 1:1 chats. + + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. + + date_after: Only include messages with timestamp strictly after this ISO 8601 datetime + (e.g., '2024-07-01T00:00:00Z' or '2024-07-01T00:00:00+02:00'). + + date_before: Only include messages with timestamp strictly before this ISO 8601 datetime + (e.g., '2024-07-31T23:59:59Z' or '2024-07-31T23:59:59+02:00'). + + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + + exclude_low_priority: Exclude messages marked Low Priority by the user. Default: true. Set to false to + include all. + + include_muted: Include messages in chats marked as Muted by the user, which are usually less + important. Default: true. Set to false if the user wants a more refined search. + + limit: Maximum number of messages to return (1–500). Defaults to 20. The current + implementation caps each page at 20 items even if a higher limit is requested. + + media_types: Filter messages by media types. Use ['any'] for any media type, or specify exact + types like ['video', 'image']. Omit for no media filtering. + + query: Literal word search (NOT semantic). Finds messages containing these EXACT words + in any order. Use single words users actually type, not concepts or phrases. + Example: use "dinner" not "dinner plans", use "sick" not "health issues". If + omitted, returns results filtered only by other parameters. + + sender: Filter by sender: 'me' (messages sent by the authenticated user), 'others' + (messages sent by others), or a specific user ID string (user.id). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v0/search-messages", + page=AsyncCursor[Message], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "chat_ids": chat_ids, + "chat_type": chat_type, + "cursor": cursor, + "date_after": date_after, + "date_before": date_before, + "direction": direction, + "exclude_low_priority": exclude_low_priority, + "include_muted": include_muted, + "limit": limit, + "media_types": media_types, + "query": query, + "sender": sender, + }, + message_search_params.MessageSearchParams, + ), + ), + model=Message, + ) + + async def send( + self, + *, + chat_id: str, + reply_to_message_id: str | Omit = omit, + text: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> MessageSendResponse: + """Send a text message to a specific chat. + + Supports replying to existing messages. + Returns the sent message ID. + + Args: + chat_id: Unique identifier of the chat (a.k.a. room or thread). + + reply_to_message_id: Provide a message ID to send this as a reply to an existing message + + text: Text content of the message you want to send. You may use markdown. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v0/send-message", + body=await async_maybe_transform( + { + "chat_id": chat_id, + "reply_to_message_id": reply_to_message_id, + "text": text, + }, + message_send_params.MessageSendParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=MessageSendResponse, + ) + + +class MessagesResourceWithRawResponse: + def __init__(self, messages: MessagesResource) -> None: + self._messages = messages + + self.search = to_raw_response_wrapper( + messages.search, + ) + self.send = to_raw_response_wrapper( + messages.send, + ) + + +class AsyncMessagesResourceWithRawResponse: + def __init__(self, messages: AsyncMessagesResource) -> None: + self._messages = messages + + self.search = async_to_raw_response_wrapper( + messages.search, + ) + self.send = async_to_raw_response_wrapper( + messages.send, + ) + + +class MessagesResourceWithStreamingResponse: + def __init__(self, messages: MessagesResource) -> None: + self._messages = messages + + self.search = to_streamed_response_wrapper( + messages.search, + ) + self.send = to_streamed_response_wrapper( + messages.send, + ) + + +class AsyncMessagesResourceWithStreamingResponse: + def __init__(self, messages: AsyncMessagesResource) -> None: + self._messages = messages + + self.search = async_to_streamed_response_wrapper( + messages.search, + ) + self.send = async_to_streamed_response_wrapper( + messages.send, + ) diff --git a/src/beeper_desktop_api/resources/token.py b/src/beeper_desktop_api/resources/token.py new file mode 100644 index 0000000..fbf0425 --- /dev/null +++ b/src/beeper_desktop_api/resources/token.py @@ -0,0 +1,139 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from .._types import Body, Query, Headers, NotGiven, not_given +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.user_info import UserInfo + +__all__ = ["TokenResource", "AsyncTokenResource"] + + +class TokenResource(SyncAPIResource): + """Operations related to the current access token""" + + @cached_property + def with_raw_response(self) -> TokenResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + """ + return TokenResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> TokenResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + """ + return TokenResourceWithStreamingResponse(self) + + def info( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> UserInfo: + """Returns information about the authenticated user/token""" + return self._get( + "/oauth/userinfo", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=UserInfo, + ) + + +class AsyncTokenResource(AsyncAPIResource): + """Operations related to the current access token""" + + @cached_property + def with_raw_response(self) -> AsyncTokenResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncTokenResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncTokenResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + """ + return AsyncTokenResourceWithStreamingResponse(self) + + async def info( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> UserInfo: + """Returns information about the authenticated user/token""" + return await self._get( + "/oauth/userinfo", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=UserInfo, + ) + + +class TokenResourceWithRawResponse: + def __init__(self, token: TokenResource) -> None: + self._token = token + + self.info = to_raw_response_wrapper( + token.info, + ) + + +class AsyncTokenResourceWithRawResponse: + def __init__(self, token: AsyncTokenResource) -> None: + self._token = token + + self.info = async_to_raw_response_wrapper( + token.info, + ) + + +class TokenResourceWithStreamingResponse: + def __init__(self, token: TokenResource) -> None: + self._token = token + + self.info = to_streamed_response_wrapper( + token.info, + ) + + +class AsyncTokenResourceWithStreamingResponse: + def __init__(self, token: AsyncTokenResource) -> None: + self._token = token + + self.info = async_to_streamed_response_wrapper( + token.info, + ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py new file mode 100644 index 0000000..5bede4c --- /dev/null +++ b/src/beeper_desktop_api/types/__init__.py @@ -0,0 +1,32 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .chat import Chat as Chat +from .shared import ( + User as User, + Error as Error, + Message as Message, + Reaction as Reaction, + Attachment as Attachment, + BaseResponse as BaseResponse, +) +from .account import Account as Account +from .user_info import UserInfo as UserInfo +from .open_response import OpenResponse as OpenResponse +from .search_response import SearchResponse as SearchResponse +from .chat_create_params import ChatCreateParams as ChatCreateParams +from .chat_search_params import ChatSearchParams as ChatSearchParams +from .client_open_params import ClientOpenParams as ClientOpenParams +from .chat_archive_params import ChatArchiveParams as ChatArchiveParams +from .message_send_params import MessageSendParams as MessageSendParams +from .chat_create_response import ChatCreateResponse as ChatCreateResponse +from .chat_retrieve_params import ChatRetrieveParams as ChatRetrieveParams +from .client_search_params import ClientSearchParams as ClientSearchParams +from .account_list_response import AccountListResponse as AccountListResponse +from .contact_search_params import ContactSearchParams as ContactSearchParams +from .message_search_params import MessageSearchParams as MessageSearchParams +from .message_send_response import MessageSendResponse as MessageSendResponse +from .contact_search_response import ContactSearchResponse as ContactSearchResponse +from .download_asset_response import DownloadAssetResponse as DownloadAssetResponse +from .client_download_asset_params import ClientDownloadAssetParams as ClientDownloadAssetParams diff --git a/src/beeper_desktop_api/types/account.py b/src/beeper_desktop_api/types/account.py new file mode 100644 index 0000000..54c2c36 --- /dev/null +++ b/src/beeper_desktop_api/types/account.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from pydantic import Field as FieldInfo + +from .._models import BaseModel +from .shared.user import User + +__all__ = ["Account"] + + +class Account(BaseModel): + account_id: str = FieldInfo(alias="accountID") + """Chat account added to Beeper. Use this to route account-scoped actions.""" + + network: str + """Display-only human-readable network name (e.g., 'WhatsApp', 'Messenger'). + + You MUST use 'accountID' to perform actions. + """ + + user: User + """A person on or reachable through Beeper. + + Values are best-effort and can vary by network. + """ diff --git a/src/beeper_desktop_api/types/account_list_response.py b/src/beeper_desktop_api/types/account_list_response.py new file mode 100644 index 0000000..8268843 --- /dev/null +++ b/src/beeper_desktop_api/types/account_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .account import Account + +__all__ = ["AccountListResponse"] + +AccountListResponse: TypeAlias = List[Account] diff --git a/src/beeper_desktop_api/types/chat.py b/src/beeper_desktop_api/types/chat.py new file mode 100644 index 0000000..493585a --- /dev/null +++ b/src/beeper_desktop_api/types/chat.py @@ -0,0 +1,70 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from datetime import datetime +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel +from .shared.user import User + +__all__ = ["Chat", "Participants"] + + +class Participants(BaseModel): + has_more: bool = FieldInfo(alias="hasMore") + """True if there are more participants than included in items.""" + + items: List[User] + """Participants returned for this chat (limited by the request; may be a subset).""" + + total: int + """Total number of participants in the chat.""" + + +class Chat(BaseModel): + id: str + """Unique identifier of the chat (room/thread ID, same as id) across Beeper.""" + + account_id: str = FieldInfo(alias="accountID") + """Beeper account ID this chat belongs to.""" + + network: str + """Display-only human-readable network name (e.g., 'WhatsApp', 'Messenger'). + + You MUST use 'accountID' to perform actions. + """ + + participants: Participants + """Chat participants information.""" + + title: str + """Display title of the chat as computed by the client/server.""" + + type: Literal["single", "group"] + """Chat type: 'single' for direct messages, 'group' for group chats.""" + + unread_count: int = FieldInfo(alias="unreadCount") + """Number of unread messages.""" + + is_archived: Optional[bool] = FieldInfo(alias="isArchived", default=None) + """True if chat is archived.""" + + is_muted: Optional[bool] = FieldInfo(alias="isMuted", default=None) + """True if chat notifications are muted.""" + + is_pinned: Optional[bool] = FieldInfo(alias="isPinned", default=None) + """True if chat is pinned.""" + + last_activity: Optional[datetime] = FieldInfo(alias="lastActivity", default=None) + """Timestamp of last activity. + + Chats with more recent activity are often more important. + """ + + last_read_message_sort_key: Union[int, str, None] = FieldInfo(alias="lastReadMessageSortKey", default=None) + """Last read message sortKey (hsOrder). Used to compute 'isUnread'.""" + + local_chat_id: Optional[str] = FieldInfo(alias="localChatID", default=None) + """Local chat ID specific to this Beeper Desktop installation.""" diff --git a/src/beeper_desktop_api/types/chat_archive_params.py b/src/beeper_desktop_api/types/chat_archive_params.py new file mode 100644 index 0000000..35a3124 --- /dev/null +++ b/src/beeper_desktop_api/types/chat_archive_params.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["ChatArchiveParams"] + + +class ChatArchiveParams(TypedDict, total=False): + chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] + """ + The identifier of the chat to archive or unarchive (accepts both chatID and + local chat ID) + """ + + archived: bool + """True to archive, false to unarchive""" diff --git a/src/beeper_desktop_api/types/chat_create_params.py b/src/beeper_desktop_api/types/chat_create_params.py new file mode 100644 index 0000000..686bfaa --- /dev/null +++ b/src/beeper_desktop_api/types/chat_create_params.py @@ -0,0 +1,30 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo + +__all__ = ["ChatCreateParams"] + + +class ChatCreateParams(TypedDict, total=False): + account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] + """Account to create the chat on.""" + + participant_ids: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")]] + """User IDs to include in the new chat.""" + + type: Required[Literal["single", "group"]] + """ + Chat type to create: 'single' requires exactly one participantID; 'group' + supports multiple participants and optional title. + """ + + message_text: Annotated[str, PropertyInfo(alias="messageText")] + """Optional first message content if the platform requires it to create the chat.""" + + title: str + """Optional title for group chats; ignored for single chats on most platforms.""" diff --git a/src/beeper_desktop_api/types/chat_create_response.py b/src/beeper_desktop_api/types/chat_create_response.py new file mode 100644 index 0000000..64b6981 --- /dev/null +++ b/src/beeper_desktop_api/types/chat_create_response.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from .shared.base_response import BaseResponse + +__all__ = ["ChatCreateResponse"] + + +class ChatCreateResponse(BaseResponse): + chat_id: Optional[str] = FieldInfo(alias="chatID", default=None) + """Newly created chat if available.""" diff --git a/src/beeper_desktop_api/types/chat_retrieve_params.py b/src/beeper_desktop_api/types/chat_retrieve_params.py new file mode 100644 index 0000000..7d0d0ff --- /dev/null +++ b/src/beeper_desktop_api/types/chat_retrieve_params.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["ChatRetrieveParams"] + + +class ChatRetrieveParams(TypedDict, total=False): + chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] + """Unique identifier of the chat to retrieve. + + Not available for iMessage chats. Participants are limited by + 'maxParticipantCount'. + """ + + max_participant_count: Annotated[Optional[int], PropertyInfo(alias="maxParticipantCount")] + """Maximum number of participants to return. + + Use -1 for all; otherwise 0–500. Defaults to 20. + """ diff --git a/src/beeper_desktop_api/types/chat_search_params.py b/src/beeper_desktop_api/types/chat_search_params.py new file mode 100644 index 0000000..de94b8d --- /dev/null +++ b/src/beeper_desktop_api/types/chat_search_params.py @@ -0,0 +1,81 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union, Optional +from datetime import datetime +from typing_extensions import Literal, Annotated, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo + +__all__ = ["ChatSearchParams"] + + +class ChatSearchParams(TypedDict, total=False): + account_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="accountIDs")] + """ + Provide an array of account IDs to filter chats from specific messaging accounts + only + """ + + cursor: str + """Pagination cursor from previous response. + + Use with direction to navigate results + """ + + direction: Literal["after", "before"] + """Pagination direction: "after" for newer page, "before" for older page. + + Defaults to "before" when only cursor is provided. + """ + + inbox: Literal["primary", "low-priority", "archive"] + """ + Filter by inbox type: "primary" (non-archived, non-low-priority), + "low-priority", or "archive". If not specified, shows all chats. + """ + + include_muted: Annotated[Optional[bool], PropertyInfo(alias="includeMuted")] + """Include chats marked as Muted by the user, which are usually less important. + + Default: true. Set to false if the user wants a more refined search. + """ + + last_activity_after: Annotated[Union[str, datetime], PropertyInfo(alias="lastActivityAfter", format="iso8601")] + """ + Provide an ISO datetime string to only retrieve chats with last activity after + this time + """ + + last_activity_before: Annotated[Union[str, datetime], PropertyInfo(alias="lastActivityBefore", format="iso8601")] + """ + Provide an ISO datetime string to only retrieve chats with last activity before + this time + """ + + limit: int + """Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50""" + + query: str + """Literal token search (non-semantic). + + Use single words users type (e.g., "dinner"). When multiple words provided, ALL + must match. Case-insensitive. + """ + + scope: Literal["titles", "participants"] + """ + Search scope: 'titles' matches title + network; 'participants' matches + participant names. + """ + + type: Literal["single", "group", "any"] + """ + Specify the type of chats to retrieve: use "single" for direct messages, "group" + for group chats, or "any" to get all types + """ + + unread_only: Annotated[Optional[bool], PropertyInfo(alias="unreadOnly")] + """Set to true to only retrieve chats that have unread messages""" diff --git a/src/beeper_desktop_api/types/chats/__init__.py b/src/beeper_desktop_api/types/chats/__init__.py new file mode 100644 index 0000000..87b79ef --- /dev/null +++ b/src/beeper_desktop_api/types/chats/__init__.py @@ -0,0 +1,6 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .reminder_create_params import ReminderCreateParams as ReminderCreateParams +from .reminder_delete_params import ReminderDeleteParams as ReminderDeleteParams diff --git a/src/beeper_desktop_api/types/chats/reminder_create_params.py b/src/beeper_desktop_api/types/chats/reminder_create_params.py new file mode 100644 index 0000000..62bbbda --- /dev/null +++ b/src/beeper_desktop_api/types/chats/reminder_create_params.py @@ -0,0 +1,28 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from ..._utils import PropertyInfo + +__all__ = ["ReminderCreateParams", "Reminder"] + + +class ReminderCreateParams(TypedDict, total=False): + chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] + """ + The identifier of the chat to set reminder for (accepts both chatID and local + chat ID) + """ + + reminder: Required[Reminder] + """Reminder configuration""" + + +class Reminder(TypedDict, total=False): + remind_at_ms: Required[Annotated[float, PropertyInfo(alias="remindAtMs")]] + """Unix timestamp in milliseconds when reminder should trigger""" + + dismiss_on_incoming_message: Annotated[bool, PropertyInfo(alias="dismissOnIncomingMessage")] + """Cancel reminder if someone messages in the chat""" diff --git a/src/beeper_desktop_api/types/chats/reminder_delete_params.py b/src/beeper_desktop_api/types/chats/reminder_delete_params.py new file mode 100644 index 0000000..6a8eb99 --- /dev/null +++ b/src/beeper_desktop_api/types/chats/reminder_delete_params.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from ..._utils import PropertyInfo + +__all__ = ["ReminderDeleteParams"] + + +class ReminderDeleteParams(TypedDict, total=False): + chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] + """ + The identifier of the chat to clear reminder from (accepts both chatID and local + chat ID) + """ diff --git a/src/beeper_desktop_api/types/client_download_asset_params.py b/src/beeper_desktop_api/types/client_download_asset_params.py new file mode 100644 index 0000000..fe824e0 --- /dev/null +++ b/src/beeper_desktop_api/types/client_download_asset_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["ClientDownloadAssetParams"] + + +class ClientDownloadAssetParams(TypedDict, total=False): + url: Required[str] + """Matrix content URL (mxc:// or localmxc://) for the asset to download.""" diff --git a/src/beeper_desktop_api/types/client_open_params.py b/src/beeper_desktop_api/types/client_open_params.py new file mode 100644 index 0000000..84dea5f --- /dev/null +++ b/src/beeper_desktop_api/types/client_open_params.py @@ -0,0 +1,26 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["ClientOpenParams"] + + +class ClientOpenParams(TypedDict, total=False): + chat_id: Annotated[str, PropertyInfo(alias="chatID")] + """Optional Beeper chat ID (or local chat ID) to focus after opening the app. + + If omitted, only opens/focuses the app. + """ + + draft_attachment_path: Annotated[str, PropertyInfo(alias="draftAttachmentPath")] + """Optional draft attachment path to populate in the message input field.""" + + draft_text: Annotated[str, PropertyInfo(alias="draftText")] + """Optional draft text to populate in the message input field.""" + + message_id: Annotated[str, PropertyInfo(alias="messageID")] + """Optional message ID. Jumps to that message in the chat when opening.""" diff --git a/src/beeper_desktop_api/types/client_search_params.py b/src/beeper_desktop_api/types/client_search_params.py new file mode 100644 index 0000000..06d58e4 --- /dev/null +++ b/src/beeper_desktop_api/types/client_search_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["ClientSearchParams"] + + +class ClientSearchParams(TypedDict, total=False): + query: Required[str] + """User-typed search text. Literal word matching (NOT semantic).""" diff --git a/src/beeper_desktop_api/types/contact_search_params.py b/src/beeper_desktop_api/types/contact_search_params.py new file mode 100644 index 0000000..6808003 --- /dev/null +++ b/src/beeper_desktop_api/types/contact_search_params.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["ContactSearchParams"] + + +class ContactSearchParams(TypedDict, total=False): + account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] + """Beeper account ID this resource belongs to.""" + + query: Required[str] + """Text to search users by. Network-specific behavior.""" diff --git a/src/beeper_desktop_api/types/contact_search_response.py b/src/beeper_desktop_api/types/contact_search_response.py new file mode 100644 index 0000000..71c609e --- /dev/null +++ b/src/beeper_desktop_api/types/contact_search_response.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from .._models import BaseModel +from .shared.user import User + +__all__ = ["ContactSearchResponse"] + + +class ContactSearchResponse(BaseModel): + items: List[User] diff --git a/src/beeper_desktop_api/types/download_asset_response.py b/src/beeper_desktop_api/types/download_asset_response.py new file mode 100644 index 0000000..47bc22e --- /dev/null +++ b/src/beeper_desktop_api/types/download_asset_response.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["DownloadAssetResponse"] + + +class DownloadAssetResponse(BaseModel): + error: Optional[str] = None + """Error message if the download failed.""" + + src_url: Optional[str] = FieldInfo(alias="srcURL", default=None) + """Local file URL to the downloaded asset.""" diff --git a/src/beeper_desktop_api/types/message_search_params.py b/src/beeper_desktop_api/types/message_search_params.py new file mode 100644 index 0000000..4aadb17 --- /dev/null +++ b/src/beeper_desktop_api/types/message_search_params.py @@ -0,0 +1,85 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Union, Optional +from datetime import datetime +from typing_extensions import Literal, Annotated, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo + +__all__ = ["MessageSearchParams"] + + +class MessageSearchParams(TypedDict, total=False): + account_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="accountIDs")] + """Limit search to specific Beeper account IDs (bridge instances).""" + + chat_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="chatIDs")] + """Limit search to specific Beeper chat IDs.""" + + chat_type: Annotated[Literal["group", "single"], PropertyInfo(alias="chatType")] + """Filter by chat type: 'group' for group chats, 'single' for 1:1 chats.""" + + cursor: str + """Opaque pagination cursor; do not inspect. Use together with 'direction'.""" + + date_after: Annotated[Union[str, datetime], PropertyInfo(alias="dateAfter", format="iso8601")] + """ + Only include messages with timestamp strictly after this ISO 8601 datetime + (e.g., '2024-07-01T00:00:00Z' or '2024-07-01T00:00:00+02:00'). + """ + + date_before: Annotated[Union[str, datetime], PropertyInfo(alias="dateBefore", format="iso8601")] + """ + Only include messages with timestamp strictly before this ISO 8601 datetime + (e.g., '2024-07-31T23:59:59Z' or '2024-07-31T23:59:59+02:00'). + """ + + direction: Literal["after", "before"] + """ + Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + """ + + exclude_low_priority: Annotated[Optional[bool], PropertyInfo(alias="excludeLowPriority")] + """Exclude messages marked Low Priority by the user. + + Default: true. Set to false to include all. + """ + + include_muted: Annotated[Optional[bool], PropertyInfo(alias="includeMuted")] + """ + Include messages in chats marked as Muted by the user, which are usually less + important. Default: true. Set to false if the user wants a more refined search. + """ + + limit: int + """Maximum number of messages to return (1–500). + + Defaults to 20. The current implementation caps each page at 20 items even if a + higher limit is requested. + """ + + media_types: Annotated[List[Literal["any", "video", "image", "link", "file"]], PropertyInfo(alias="mediaTypes")] + """Filter messages by media types. + + Use ['any'] for any media type, or specify exact types like ['video', 'image']. + Omit for no media filtering. + """ + + query: str + """Literal word search (NOT semantic). + + Finds messages containing these EXACT words in any order. Use single words users + actually type, not concepts or phrases. Example: use "dinner" not "dinner + plans", use "sick" not "health issues". If omitted, returns results filtered + only by other parameters. + """ + + sender: Union[Literal["me", "others"], str] + """ + Filter by sender: 'me' (messages sent by the authenticated user), 'others' + (messages sent by others), or a specific user ID string (user.id). + """ diff --git a/src/beeper_desktop_api/types/message_send_params.py b/src/beeper_desktop_api/types/message_send_params.py new file mode 100644 index 0000000..c0bacd7 --- /dev/null +++ b/src/beeper_desktop_api/types/message_send_params.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["MessageSendParams"] + + +class MessageSendParams(TypedDict, total=False): + chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] + """Unique identifier of the chat (a.k.a. room or thread).""" + + reply_to_message_id: Annotated[str, PropertyInfo(alias="replyToMessageID")] + """Provide a message ID to send this as a reply to an existing message""" + + text: str + """Text content of the message you want to send. You may use markdown.""" diff --git a/src/beeper_desktop_api/types/message_send_response.py b/src/beeper_desktop_api/types/message_send_response.py new file mode 100644 index 0000000..d1098af --- /dev/null +++ b/src/beeper_desktop_api/types/message_send_response.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from pydantic import Field as FieldInfo + +from .shared.base_response import BaseResponse + +__all__ = ["MessageSendResponse"] + + +class MessageSendResponse(BaseResponse): + chat_id: str = FieldInfo(alias="chatID") + """Unique identifier of the chat (a.k.a. room or thread).""" + + pending_message_id: str = FieldInfo(alias="pendingMessageID") + """Pending message ID""" diff --git a/src/beeper_desktop_api/types/open_response.py b/src/beeper_desktop_api/types/open_response.py new file mode 100644 index 0000000..970f2ba --- /dev/null +++ b/src/beeper_desktop_api/types/open_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["OpenResponse"] + + +class OpenResponse(BaseModel): + success: bool + """Whether the app was successfully opened/focused.""" diff --git a/src/beeper_desktop_api/types/search_response.py b/src/beeper_desktop_api/types/search_response.py new file mode 100644 index 0000000..fe5113c --- /dev/null +++ b/src/beeper_desktop_api/types/search_response.py @@ -0,0 +1,48 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional + +from pydantic import Field as FieldInfo + +from .chat import Chat +from .._models import BaseModel +from .shared.message import Message + +__all__ = ["SearchResponse", "Results", "ResultsMessages"] + + +class ResultsMessages(BaseModel): + chats: Dict[str, Chat] + """Map of chatID -> chat details for chats referenced in items.""" + + has_more: bool = FieldInfo(alias="hasMore") + """True if additional results can be fetched using the provided cursors.""" + + items: List[Message] + """Messages matching the query and filters.""" + + newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", default=None) + """Cursor for fetching newer results (use with direction='after'). + + Opaque string; do not inspect. + """ + + oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) + """Cursor for fetching older results (use with direction='before'). + + Opaque string; do not inspect. + """ + + +class Results(BaseModel): + chats: List[Chat] + """Top chat results.""" + + in_groups: List[Chat] + """Top group results by participant matches.""" + + messages: ResultsMessages + + +class SearchResponse(BaseModel): + results: Results diff --git a/src/beeper_desktop_api/types/shared/__init__.py b/src/beeper_desktop_api/types/shared/__init__.py new file mode 100644 index 0000000..752eee2 --- /dev/null +++ b/src/beeper_desktop_api/types/shared/__init__.py @@ -0,0 +1,8 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .user import User as User +from .error import Error as Error +from .message import Message as Message +from .reaction import Reaction as Reaction +from .attachment import Attachment as Attachment +from .base_response import BaseResponse as BaseResponse diff --git a/src/beeper_desktop_api/types/shared/attachment.py b/src/beeper_desktop_api/types/shared/attachment.py new file mode 100644 index 0000000..4964307 --- /dev/null +++ b/src/beeper_desktop_api/types/shared/attachment.py @@ -0,0 +1,59 @@ +# 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 + +from ..._models import BaseModel + +__all__ = ["Attachment", "Size"] + + +class Size(BaseModel): + height: Optional[float] = None + + width: Optional[float] = None + + +class Attachment(BaseModel): + type: Literal["unknown", "img", "video", "audio"] + """Attachment type.""" + + duration: Optional[float] = None + """Duration in seconds (audio/video).""" + + file_name: Optional[str] = FieldInfo(alias="fileName", default=None) + """Original filename if available.""" + + file_size: Optional[float] = FieldInfo(alias="fileSize", default=None) + """File size in bytes if known.""" + + is_gif: Optional[bool] = FieldInfo(alias="isGif", default=None) + """True if the attachment is a GIF.""" + + is_sticker: Optional[bool] = FieldInfo(alias="isSticker", default=None) + """True if the attachment is a sticker.""" + + is_voice_note: Optional[bool] = FieldInfo(alias="isVoiceNote", default=None) + """True if the attachment is a voice note.""" + + mime_type: Optional[str] = FieldInfo(alias="mimeType", default=None) + """MIME type if known (e.g., 'image/png').""" + + poster_img: Optional[str] = FieldInfo(alias="posterImg", default=None) + """Preview image URL for video attachments (poster frame). + + May be temporary or local-only to this device; download promptly if durable + access is needed. + """ + + size: Optional[Size] = None + """Pixel dimensions of the attachment: width/height in px.""" + + src_url: Optional[str] = FieldInfo(alias="srcURL", default=None) + """Public URL or local file path to fetch the asset. + + May be temporary or local-only to this device; download promptly if durable + access is needed. + """ diff --git a/src/beeper_desktop_api/types/shared/base_response.py b/src/beeper_desktop_api/types/shared/base_response.py new file mode 100644 index 0000000..9b8876d --- /dev/null +++ b/src/beeper_desktop_api/types/shared/base_response.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BaseResponse"] + + +class BaseResponse(BaseModel): + success: bool + + error: Optional[str] = None diff --git a/src/beeper_desktop_api/types/shared/error.py b/src/beeper_desktop_api/types/shared/error.py new file mode 100644 index 0000000..1f82efd --- /dev/null +++ b/src/beeper_desktop_api/types/shared/error.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional + +from ..._models import BaseModel + +__all__ = ["Error"] + + +class Error(BaseModel): + error: str + """Error message""" + + code: Optional[str] = None + """Error code""" + + details: Optional[Dict[str, str]] = None + """Additional error details""" diff --git a/src/beeper_desktop_api/types/shared/message.py b/src/beeper_desktop_api/types/shared/message.py new file mode 100644 index 0000000..b9d70ff --- /dev/null +++ b/src/beeper_desktop_api/types/shared/message.py @@ -0,0 +1,58 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from datetime import datetime + +from pydantic import Field as FieldInfo + +from .reaction import Reaction +from ..._models import BaseModel +from .attachment import Attachment + +__all__ = ["Message"] + + +class Message(BaseModel): + id: str + """Stable message ID for cursor pagination.""" + + account_id: str = FieldInfo(alias="accountID") + """Beeper account ID the message belongs to.""" + + chat_id: str = FieldInfo(alias="chatID") + """Beeper chat/thread/room ID.""" + + message_id: str = FieldInfo(alias="messageID") + """Stable message ID (same as id).""" + + sender_id: str = FieldInfo(alias="senderID") + """Sender user ID.""" + + sort_key: Union[str, float] = FieldInfo(alias="sortKey") + """A unique key used to sort messages""" + + timestamp: datetime + """Message timestamp.""" + + attachments: Optional[List[Attachment]] = None + """Attachments included with this message, if any.""" + + is_sender: Optional[bool] = FieldInfo(alias="isSender", default=None) + """True if the authenticated user sent the message.""" + + is_unread: Optional[bool] = FieldInfo(alias="isUnread", default=None) + """True if the message is unread for the authenticated user. May be omitted.""" + + reactions: Optional[List[Reaction]] = None + """Reactions to the message, if any.""" + + sender_name: Optional[str] = FieldInfo(alias="senderName", default=None) + """ + Resolved sender display name (impersonator/full name/username/participant name). + """ + + text: Optional[str] = None + """Plain-text body if present. + + May include a JSON fallback with text entities for rich messages. + """ diff --git a/src/beeper_desktop_api/types/shared/reaction.py b/src/beeper_desktop_api/types/shared/reaction.py new file mode 100644 index 0000000..6a64ebe --- /dev/null +++ b/src/beeper_desktop_api/types/shared/reaction.py @@ -0,0 +1,36 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["Reaction"] + + +class Reaction(BaseModel): + id: str + """ + Reaction ID, typically ${participantID}${reactionKey} if multiple reactions + allowed, or just participantID otherwise. + """ + + participant_id: str = FieldInfo(alias="participantID") + """User ID of the participant who reacted.""" + + reaction_key: str = FieldInfo(alias="reactionKey") + """ + The reaction key: an emoji (😄), a network-specific key, or a shortcode like + "smiling-face". + """ + + emoji: Optional[bool] = None + """True if the reactionKey is an emoji.""" + + img_url: Optional[str] = FieldInfo(alias="imgURL", default=None) + """URL to the reaction's image. + + May be temporary or local-only to this device; download promptly if durable + access is needed. + """ diff --git a/src/beeper_desktop_api/types/shared/user.py b/src/beeper_desktop_api/types/shared/user.py new file mode 100644 index 0000000..c05827b --- /dev/null +++ b/src/beeper_desktop_api/types/shared/user.py @@ -0,0 +1,45 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["User"] + + +class User(BaseModel): + id: str + """Stable Beeper user ID. Use as the primary key when referencing a person.""" + + cannot_message: Optional[bool] = FieldInfo(alias="cannotMessage", default=None) + """ + True if Beeper cannot initiate messages to this user (e.g., blocked, network + restriction, or no DM path). The user may still message you. + """ + + email: Optional[str] = None + """Email address if known. Not guaranteed verified.""" + + full_name: Optional[str] = FieldInfo(alias="fullName", default=None) + """Display name as shown in clients (e.g., 'Alice Example'). May include emojis.""" + + img_url: Optional[str] = FieldInfo(alias="imgURL", default=None) + """Avatar image URL if available. + + May be temporary or local-only to this device; download promptly if durable + access is needed. + """ + + is_self: Optional[bool] = FieldInfo(alias="isSelf", default=None) + """True if this user represents the authenticated account's own identity.""" + + phone_number: Optional[str] = FieldInfo(alias="phoneNumber", default=None) + """User's phone number in E.164 format (e.g., '+14155552671'). Omit if unknown.""" + + username: Optional[str] = None + """Human-readable handle if available (e.g., '@alice'). + + May be network-specific and not globally unique. + """ diff --git a/src/beeper_desktop_api/types/user_info.py b/src/beeper_desktop_api/types/user_info.py new file mode 100644 index 0000000..d023e31 --- /dev/null +++ b/src/beeper_desktop_api/types/user_info.py @@ -0,0 +1,31 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["UserInfo"] + + +class UserInfo(BaseModel): + iat: float + """Issued at timestamp (Unix epoch seconds)""" + + scope: str + """Granted scopes""" + + sub: str + """Subject identifier (token ID)""" + + token_use: Literal["access"] + """Token type""" + + aud: Optional[str] = None + """Audience (client ID)""" + + client_id: Optional[str] = None + """Client identifier""" + + exp: Optional[float] = None + """Expiration timestamp (Unix epoch seconds)""" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/__init__.py b/tests/api_resources/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/api_resources/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/chats/__init__.py b/tests/api_resources/chats/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/api_resources/chats/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/chats/test_reminders.py b/tests/api_resources/chats/test_reminders.py new file mode 100644 index 0000000..d11a216 --- /dev/null +++ b/tests/api_resources/chats/test_reminders.py @@ -0,0 +1,176 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types.shared import BaseResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestReminders: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_create(self, client: BeeperDesktop) -> None: + reminder = client.chats.reminders.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, + ) + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: + reminder = client.chats.reminders.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={ + "remind_at_ms": 0, + "dismiss_on_incoming_message": True, + }, + ) + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + def test_raw_response_create(self, client: BeeperDesktop) -> None: + response = client.chats.reminders.with_raw_response.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + reminder = response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + def test_streaming_response_create(self, client: BeeperDesktop) -> None: + with client.chats.reminders.with_streaming_response.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + reminder = response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_delete(self, client: BeeperDesktop) -> None: + reminder = client.chats.reminders.delete( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + def test_raw_response_delete(self, client: BeeperDesktop) -> None: + response = client.chats.reminders.with_raw_response.delete( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + reminder = response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + def test_streaming_response_delete(self, client: BeeperDesktop) -> None: + with client.chats.reminders.with_streaming_response.delete( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + reminder = response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncReminders: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: + reminder = await async_client.chats.reminders.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, + ) + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + reminder = await async_client.chats.reminders.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={ + "remind_at_ms": 0, + "dismiss_on_incoming_message": True, + }, + ) + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.reminders.with_raw_response.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + reminder = await response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.reminders.with_streaming_response.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + reminder = await response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_delete(self, async_client: AsyncBeeperDesktop) -> None: + reminder = await async_client.chats.reminders.delete( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + async def test_raw_response_delete(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.reminders.with_raw_response.delete( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + reminder = await response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.reminders.with_streaming_response.delete( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + reminder = await response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_accounts.py b/tests/api_resources/test_accounts.py new file mode 100644 index 0000000..46ac702 --- /dev/null +++ b/tests/api_resources/test_accounts.py @@ -0,0 +1,74 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import AccountListResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAccounts: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_list(self, client: BeeperDesktop) -> None: + account = client.accounts.list() + assert_matches_type(AccountListResponse, account, path=["response"]) + + @parametrize + def test_raw_response_list(self, client: BeeperDesktop) -> None: + response = client.accounts.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + account = response.parse() + assert_matches_type(AccountListResponse, account, path=["response"]) + + @parametrize + def test_streaming_response_list(self, client: BeeperDesktop) -> None: + with client.accounts.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + account = response.parse() + assert_matches_type(AccountListResponse, account, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncAccounts: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: + account = await async_client.accounts.list() + assert_matches_type(AccountListResponse, account, path=["response"]) + + @parametrize + async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.accounts.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + account = await response.parse() + assert_matches_type(AccountListResponse, account, path=["response"]) + + @parametrize + async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.accounts.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + account = await response.parse() + assert_matches_type(AccountListResponse, account, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py new file mode 100644 index 0000000..074879b --- /dev/null +++ b/tests/api_resources/test_chats.py @@ -0,0 +1,374 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import ( + Chat, + ChatCreateResponse, +) +from beeper_desktop_api._utils import parse_datetime +from beeper_desktop_api.pagination import SyncCursor, AsyncCursor +from beeper_desktop_api.types.shared import BaseResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestChats: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_create(self, client: BeeperDesktop) -> None: + chat = client.chats.create( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + ) + 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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + message_text="messageText", + title="title", + ) + 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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + @parametrize + def test_streaming_response_create(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.create( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_retrieve(self, client: BeeperDesktop) -> None: + chat = client.chats.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_method_retrieve_with_all_params(self, client: BeeperDesktop) -> None: + chat = client.chats.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + max_participant_count=50, + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_raw_response_retrieve(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_streaming_response_retrieve(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_archive(self, client: BeeperDesktop) -> None: + chat = client.chats.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(BaseResponse, chat, path=["response"]) + + @parametrize + def test_method_archive_with_all_params(self, client: BeeperDesktop) -> None: + chat = client.chats.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + archived=True, + ) + assert_matches_type(BaseResponse, chat, path=["response"]) + + @parametrize + def test_raw_response_archive(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(BaseResponse, chat, path=["response"]) + + @parametrize + def test_streaming_response_archive(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(BaseResponse, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_search(self, client: BeeperDesktop) -> None: + chat = client.chats.search() + assert_matches_type(SyncCursor[Chat], chat, path=["response"]) + + @parametrize + def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: + chat = client.chats.search( + account_ids=[ + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", + ], + cursor="eyJvZmZzZXQiOjE3MTk5OTk5OTl9", + direction="after", + inbox="primary", + include_muted=True, + last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), + last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), + limit=1, + query="x", + scope="titles", + type="single", + unread_only=True, + ) + assert_matches_type(SyncCursor[Chat], chat, path=["response"]) + + @parametrize + def test_raw_response_search(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.search() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(SyncCursor[Chat], chat, path=["response"]) + + @parametrize + def test_streaming_response_search(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.search() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(SyncCursor[Chat], chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncChats: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.create( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + ) + 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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + message_text="messageText", + title="title", + ) + 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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + @parametrize + async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.create( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_method_retrieve_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + max_participant_count=50, + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_archive(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(BaseResponse, chat, path=["response"]) + + @parametrize + async def test_method_archive_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + archived=True, + ) + assert_matches_type(BaseResponse, chat, path=["response"]) + + @parametrize + async def test_raw_response_archive(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(BaseResponse, chat, path=["response"]) + + @parametrize + async def test_streaming_response_archive(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(BaseResponse, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.search() + assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) + + @parametrize + async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.search( + account_ids=[ + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", + ], + cursor="eyJvZmZzZXQiOjE3MTk5OTk5OTl9", + direction="after", + inbox="primary", + include_muted=True, + last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), + last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), + limit=1, + query="x", + scope="titles", + type="single", + unread_only=True, + ) + assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) + + @parametrize + async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.search() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) + + @parametrize + async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.search() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_client.py b/tests/api_resources/test_client.py new file mode 100644 index 0000000..96ff0eb --- /dev/null +++ b/tests/api_resources/test_client.py @@ -0,0 +1,222 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import ( + OpenResponse, + SearchResponse, + DownloadAssetResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestClient: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_download_asset(self, client: BeeperDesktop) -> None: + client_ = client.download_asset( + url="x", + ) + assert_matches_type(DownloadAssetResponse, client_, path=["response"]) + + @parametrize + def test_raw_response_download_asset(self, client: BeeperDesktop) -> None: + response = client.with_raw_response.download_asset( + url="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client_ = response.parse() + assert_matches_type(DownloadAssetResponse, client_, path=["response"]) + + @parametrize + def test_streaming_response_download_asset(self, client: BeeperDesktop) -> None: + with client.with_streaming_response.download_asset( + url="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client_ = response.parse() + assert_matches_type(DownloadAssetResponse, client_, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_open(self, client: BeeperDesktop) -> None: + client_ = client.open() + assert_matches_type(OpenResponse, client_, path=["response"]) + + @parametrize + def test_method_open_with_all_params(self, client: BeeperDesktop) -> None: + client_ = client.open( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + draft_attachment_path="draftAttachmentPath", + draft_text="draftText", + message_id="messageID", + ) + assert_matches_type(OpenResponse, client_, path=["response"]) + + @parametrize + def test_raw_response_open(self, client: BeeperDesktop) -> None: + response = client.with_raw_response.open() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client_ = response.parse() + assert_matches_type(OpenResponse, client_, path=["response"]) + + @parametrize + def test_streaming_response_open(self, client: BeeperDesktop) -> None: + with client.with_streaming_response.open() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client_ = response.parse() + assert_matches_type(OpenResponse, client_, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_search(self, client: BeeperDesktop) -> None: + client_ = client.search( + query="x", + ) + assert_matches_type(SearchResponse, client_, path=["response"]) + + @parametrize + def test_raw_response_search(self, client: BeeperDesktop) -> None: + response = client.with_raw_response.search( + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client_ = response.parse() + assert_matches_type(SearchResponse, client_, path=["response"]) + + @parametrize + def test_streaming_response_search(self, client: BeeperDesktop) -> None: + with client.with_streaming_response.search( + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client_ = response.parse() + assert_matches_type(SearchResponse, client_, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncClient: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_download_asset(self, async_client: AsyncBeeperDesktop) -> None: + client = await async_client.download_asset( + url="x", + ) + assert_matches_type(DownloadAssetResponse, client, path=["response"]) + + @parametrize + async def test_raw_response_download_asset(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.with_raw_response.download_asset( + url="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client = await response.parse() + assert_matches_type(DownloadAssetResponse, client, path=["response"]) + + @parametrize + async def test_streaming_response_download_asset(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.with_streaming_response.download_asset( + url="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client = await response.parse() + assert_matches_type(DownloadAssetResponse, client, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_open(self, async_client: AsyncBeeperDesktop) -> None: + client = await async_client.open() + assert_matches_type(OpenResponse, client, path=["response"]) + + @parametrize + async def test_method_open_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + client = await async_client.open( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + draft_attachment_path="draftAttachmentPath", + draft_text="draftText", + message_id="messageID", + ) + assert_matches_type(OpenResponse, client, path=["response"]) + + @parametrize + async def test_raw_response_open(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.with_raw_response.open() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client = await response.parse() + assert_matches_type(OpenResponse, client, path=["response"]) + + @parametrize + async def test_streaming_response_open(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.with_streaming_response.open() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client = await response.parse() + assert_matches_type(OpenResponse, client, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: + client = await async_client.search( + query="x", + ) + assert_matches_type(SearchResponse, client, path=["response"]) + + @parametrize + async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.with_raw_response.search( + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client = await response.parse() + assert_matches_type(SearchResponse, client, path=["response"]) + + @parametrize + async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.with_streaming_response.search( + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client = await response.parse() + assert_matches_type(SearchResponse, client, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_contacts.py b/tests/api_resources/test_contacts.py new file mode 100644 index 0000000..6308d1f --- /dev/null +++ b/tests/api_resources/test_contacts.py @@ -0,0 +1,92 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import ContactSearchResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestContacts: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_search(self, client: BeeperDesktop) -> None: + contact = client.contacts.search( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + @parametrize + def test_raw_response_search(self, client: BeeperDesktop) -> None: + response = client.contacts.with_raw_response.search( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + contact = response.parse() + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + @parametrize + def test_streaming_response_search(self, client: BeeperDesktop) -> None: + with client.contacts.with_streaming_response.search( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + contact = response.parse() + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncContacts: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: + contact = await async_client.contacts.search( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + @parametrize + async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.contacts.with_raw_response.search( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + contact = await response.parse() + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + @parametrize + async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.contacts.with_streaming_response.search( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + contact = await response.parse() + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py new file mode 100644 index 0000000..594f1dc --- /dev/null +++ b/tests/api_resources/test_messages.py @@ -0,0 +1,201 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import MessageSendResponse +from beeper_desktop_api._utils import parse_datetime +from beeper_desktop_api.pagination import SyncCursor, AsyncCursor +from beeper_desktop_api.types.shared import Message + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestMessages: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_search(self, client: BeeperDesktop) -> None: + message = client.messages.search() + assert_matches_type(SyncCursor[Message], message, path=["response"]) + + @parametrize + def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: + message = client.messages.search( + account_ids=[ + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", + ], + chat_ids=["!NCdzlIaMjZUmvmvyHU:beeper.com", "1231073"], + chat_type="group", + cursor="1725489123456|c29tZUltc2dQYWdl", + date_after=parse_datetime("2025-08-01T00:00:00Z"), + date_before=parse_datetime("2025-08-31T23:59:59Z"), + direction="before", + exclude_low_priority=True, + include_muted=True, + limit=20, + media_types=["any"], + query="dinner", + sender="me", + ) + assert_matches_type(SyncCursor[Message], message, path=["response"]) + + @parametrize + def test_raw_response_search(self, client: BeeperDesktop) -> None: + response = client.messages.with_raw_response.search() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = response.parse() + assert_matches_type(SyncCursor[Message], message, path=["response"]) + + @parametrize + def test_streaming_response_search(self, client: BeeperDesktop) -> None: + with client.messages.with_streaming_response.search() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = response.parse() + assert_matches_type(SyncCursor[Message], message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_send(self, client: BeeperDesktop) -> None: + message = client.messages.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(MessageSendResponse, message, path=["response"]) + + @parametrize + def test_method_send_with_all_params(self, client: BeeperDesktop) -> None: + message = client.messages.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reply_to_message_id="replyToMessageID", + text="text", + ) + assert_matches_type(MessageSendResponse, message, path=["response"]) + + @parametrize + def test_raw_response_send(self, client: BeeperDesktop) -> None: + response = client.messages.with_raw_response.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = response.parse() + assert_matches_type(MessageSendResponse, message, path=["response"]) + + @parametrize + def test_streaming_response_send(self, client: BeeperDesktop) -> None: + with client.messages.with_streaming_response.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = response.parse() + assert_matches_type(MessageSendResponse, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncMessages: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.search() + assert_matches_type(AsyncCursor[Message], message, path=["response"]) + + @parametrize + async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.search( + account_ids=[ + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", + ], + chat_ids=["!NCdzlIaMjZUmvmvyHU:beeper.com", "1231073"], + chat_type="group", + cursor="1725489123456|c29tZUltc2dQYWdl", + date_after=parse_datetime("2025-08-01T00:00:00Z"), + date_before=parse_datetime("2025-08-31T23:59:59Z"), + direction="before", + exclude_low_priority=True, + include_muted=True, + limit=20, + media_types=["any"], + query="dinner", + sender="me", + ) + assert_matches_type(AsyncCursor[Message], message, path=["response"]) + + @parametrize + async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.messages.with_raw_response.search() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = await response.parse() + assert_matches_type(AsyncCursor[Message], message, path=["response"]) + + @parametrize + async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.messages.with_streaming_response.search() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = await response.parse() + assert_matches_type(AsyncCursor[Message], message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_send(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(MessageSendResponse, message, path=["response"]) + + @parametrize + async def test_method_send_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reply_to_message_id="replyToMessageID", + text="text", + ) + assert_matches_type(MessageSendResponse, message, path=["response"]) + + @parametrize + async def test_raw_response_send(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.messages.with_raw_response.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = await response.parse() + assert_matches_type(MessageSendResponse, message, path=["response"]) + + @parametrize + async def test_streaming_response_send(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.messages.with_streaming_response.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = await response.parse() + assert_matches_type(MessageSendResponse, message, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_token.py b/tests/api_resources/test_token.py new file mode 100644 index 0000000..538aa77 --- /dev/null +++ b/tests/api_resources/test_token.py @@ -0,0 +1,74 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import UserInfo + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestToken: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_info(self, client: BeeperDesktop) -> None: + token = client.token.info() + assert_matches_type(UserInfo, token, path=["response"]) + + @parametrize + def test_raw_response_info(self, client: BeeperDesktop) -> None: + response = client.token.with_raw_response.info() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + token = response.parse() + assert_matches_type(UserInfo, token, path=["response"]) + + @parametrize + def test_streaming_response_info(self, client: BeeperDesktop) -> None: + with client.token.with_streaming_response.info() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + token = response.parse() + assert_matches_type(UserInfo, token, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncToken: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_info(self, async_client: AsyncBeeperDesktop) -> None: + token = await async_client.token.info() + assert_matches_type(UserInfo, token, path=["response"]) + + @parametrize + async def test_raw_response_info(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.token.with_raw_response.info() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + token = await response.parse() + assert_matches_type(UserInfo, token, path=["response"]) + + @parametrize + async def test_streaming_response_info(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.token.with_streaming_response.info() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + token = await response.parse() + assert_matches_type(UserInfo, token, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5d1317a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,84 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +import logging +from typing import TYPE_CHECKING, Iterator, AsyncIterator + +import httpx +import pytest +from pytest_asyncio import is_async_test + +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop, DefaultAioHttpClient +from beeper_desktop_api._utils import is_dict + +if TYPE_CHECKING: + from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] + +pytest.register_assert_rewrite("tests.utils") + +logging.getLogger("beeper_desktop_api").setLevel(logging.DEBUG) + + +# automatically add `pytest.mark.asyncio()` to all of our async tests +# so we don't have to add that boilerplate everywhere +def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: + pytest_asyncio_tests = (item for item in items if is_async_test(item)) + session_scope_marker = pytest.mark.asyncio(loop_scope="session") + for async_test in pytest_asyncio_tests: + async_test.add_marker(session_scope_marker, append=False) + + # We skip tests that use both the aiohttp client and respx_mock as respx_mock + # doesn't support custom transports. + for item in items: + if "async_client" not in item.fixturenames or "respx_mock" not in item.fixturenames: + continue + + if not hasattr(item, "callspec"): + continue + + async_client_param = item.callspec.params.get("async_client") + if is_dict(async_client_param) and async_client_param.get("http_client") == "aiohttp": + item.add_marker(pytest.mark.skip(reason="aiohttp client is not compatible with respx_mock")) + + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + +access_token = "My Access Token" + + +@pytest.fixture(scope="session") +def client(request: FixtureRequest) -> Iterator[BeeperDesktop]: + strict = getattr(request, "param", True) + if not isinstance(strict, bool): + raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") + + with BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=strict) as client: + yield client + + +@pytest.fixture(scope="session") +async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncBeeperDesktop]: + param = getattr(request, "param", True) + + # defaults + strict = True + http_client: None | httpx.AsyncClient = None + + if isinstance(param, bool): + strict = param + elif is_dict(param): + strict = param.get("strict", True) + assert isinstance(strict, bool) + + http_client_type = param.get("http_client", "httpx") + if http_client_type == "aiohttp": + http_client = DefaultAioHttpClient() + else: + raise TypeError(f"Unexpected fixture parameter type {type(param)}, expected bool or dict") + + async with AsyncBeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=strict, http_client=http_client + ) as client: + yield client diff --git a/tests/sample_file.txt b/tests/sample_file.txt new file mode 100644 index 0000000..af5626b --- /dev/null +++ b/tests/sample_file.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..9ac5182 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,1736 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import gc +import os +import sys +import json +import asyncio +import inspect +import tracemalloc +from typing import Any, Union, cast +from unittest import mock +from typing_extensions import Literal + +import httpx +import pytest +from respx import MockRouter +from pydantic import ValidationError + +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop, APIResponseValidationError +from beeper_desktop_api._types import Omit +from beeper_desktop_api._utils import asyncify +from beeper_desktop_api._models import BaseModel, FinalRequestOptions +from beeper_desktop_api._exceptions import ( + APIStatusError, + APITimeoutError, + BeeperDesktopError, + APIResponseValidationError, +) +from beeper_desktop_api._base_client import ( + DEFAULT_TIMEOUT, + HTTPX_DEFAULT_TIMEOUT, + BaseClient, + OtherPlatform, + DefaultHttpxClient, + DefaultAsyncHttpxClient, + get_platform, + make_request_options, +) + +from .utils import update_env + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +access_token = "My Access Token" + + +def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + return dict(url.params) + + +def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: + return 0.1 + + +def _get_open_connections(client: BeeperDesktop | AsyncBeeperDesktop) -> int: + transport = client._client._transport + assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) + + pool = transport._pool + return len(pool._requests) + + +class TestBeeperDesktop: + client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + + @pytest.mark.respx(base_url=base_url) + def test_raw_response(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + @pytest.mark.respx(base_url=base_url) + def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock( + return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') + ) + + response = self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + def test_copy(self) -> None: + copied = self.client.copy() + assert id(copied) != id(self.client) + + copied = self.client.copy(access_token="another My Access Token") + assert copied.access_token == "another My Access Token" + assert self.client.access_token == "My Access Token" + + def test_copy_default_options(self) -> None: + # options that have a default are overridden correctly + copied = self.client.copy(max_retries=7) + assert copied.max_retries == 7 + assert self.client.max_retries == 2 + + copied2 = copied.copy(max_retries=6) + assert copied2.max_retries == 6 + assert copied.max_retries == 7 + + # timeout + assert isinstance(self.client.timeout, httpx.Timeout) + copied = self.client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(self.client.timeout, httpx.Timeout) + + def test_copy_default_headers(self) -> None: + client = BeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + default_headers={"X-Foo": "bar"}, + ) + assert client.default_headers["X-Foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert copied.default_headers["X-Foo"] == "bar" + + # merges already given headers + copied = client.copy(default_headers={"X-Bar": "stainless"}) + assert copied.default_headers["X-Foo"] == "bar" + assert copied.default_headers["X-Bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_headers={"X-Foo": "stainless"}) + assert copied.default_headers["X-Foo"] == "stainless" + + # set_default_headers + + # completely overrides already set values + copied = client.copy(set_default_headers={}) + assert copied.default_headers.get("X-Foo") is None + + copied = client.copy(set_default_headers={"X-Bar": "Robert"}) + assert copied.default_headers["X-Bar"] == "Robert" + + with pytest.raises( + ValueError, + match="`default_headers` and `set_default_headers` arguments are mutually exclusive", + ): + client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + + def test_copy_default_query(self) -> None: + client = BeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=True, default_query={"foo": "bar"} + ) + assert _get_params(client)["foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert _get_params(copied)["foo"] == "bar" + + # merges already given params + copied = client.copy(default_query={"bar": "stainless"}) + params = _get_params(copied) + assert params["foo"] == "bar" + assert params["bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_query={"foo": "stainless"}) + assert _get_params(copied)["foo"] == "stainless" + + # set_default_query + + # completely overrides already set values + copied = client.copy(set_default_query={}) + assert _get_params(copied) == {} + + copied = client.copy(set_default_query={"bar": "Robert"}) + assert _get_params(copied)["bar"] == "Robert" + + with pytest.raises( + ValueError, + # TODO: update + match="`default_query` and `set_default_query` arguments are mutually exclusive", + ): + client.copy(set_default_query={}, default_query={"foo": "Bar"}) + + def test_copy_signature(self) -> None: + # ensure the same parameters that can be passed to the client are defined in the `.copy()` method + init_signature = inspect.signature( + # mypy doesn't like that we access the `__init__` property. + self.client.__init__, # type: ignore[misc] + ) + copy_signature = inspect.signature(self.client.copy) + exclude_params = {"transport", "proxies", "_strict_response_validation"} + + for name in init_signature.parameters.keys(): + if name in exclude_params: + continue + + copy_param = copy_signature.parameters.get(name) + assert copy_param is not None, f"copy() signature is missing the {name} param" + + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") + def test_copy_build_request(self) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client = self.client.copy() + client._build_request(options) + + # ensure that the machinery is warmed up before tracing starts. + build_request(options) + gc.collect() + + tracemalloc.start(1000) + + snapshot_before = tracemalloc.take_snapshot() + + ITERATIONS = 10 + for _ in range(ITERATIONS): + build_request(options) + + gc.collect() + snapshot_after = tracemalloc.take_snapshot() + + tracemalloc.stop() + + def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: + if diff.count == 0: + # Avoid false positives by considering only leaks (i.e. allocations that persist). + return + + if diff.count % ITERATIONS != 0: + # Avoid false positives by considering only leaks that appear per iteration. + return + + for frame in diff.traceback: + if any( + frame.filename.endswith(fragment) + for fragment in [ + # to_raw_response_wrapper leaks through the @functools.wraps() decorator. + # + # removing the decorator fixes the leak for reasons we don't understand. + "beeper_desktop_api/_legacy_response.py", + "beeper_desktop_api/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "beeper_desktop_api/_compat.py", + # Standard library leaks we don't care about. + "/logging/__init__.py", + ] + ): + return + + leaks.append(diff) + + leaks: list[tracemalloc.StatisticDiff] = [] + for diff in snapshot_after.compare_to(snapshot_before, "traceback"): + add_leak(leaks, diff) + if leaks: + for leak in leaks: + print("MEMORY LEAK:", leak) + for frame in leak.traceback: + print(frame) + raise AssertionError() + + def test_request_timeout(self) -> None: + request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + request = self.client._build_request( + FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) + ) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(100.0) + + def test_client_timeout_option(self) -> None: + client = BeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=True, timeout=httpx.Timeout(0) + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(0) + + def test_http_client_timeout_option(self) -> None: + # custom timeout given to the httpx client should be used + with httpx.Client(timeout=None) as http_client: + client = BeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(None) + + # no timeout given to the httpx client should not use the httpx default + with httpx.Client() as http_client: + client = BeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + # explicitly passing the default timeout currently results in it being ignored + with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: + client = BeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT # our default + + async def test_invalid_http_client(self) -> None: + with pytest.raises(TypeError, match="Invalid `http_client` arg"): + async with httpx.AsyncClient() as http_client: + BeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + http_client=cast(Any, http_client), + ) + + def test_default_headers_option(self) -> None: + client = BeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + default_headers={"X-Foo": "bar"}, + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "bar" + assert request.headers.get("x-stainless-lang") == "python" + + client2 = BeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + default_headers={ + "X-Foo": "stainless", + "X-Stainless-Lang": "my-overriding-header", + }, + ) + request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "stainless" + assert request.headers.get("x-stainless-lang") == "my-overriding-header" + + def test_validate_headers(self) -> None: + client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("Authorization") == f"Bearer {access_token}" + + with pytest.raises(BeeperDesktopError): + with update_env(**{"BEEPER_ACCESS_TOKEN": Omit()}): + client2 = BeeperDesktop(base_url=base_url, access_token=None, _strict_response_validation=True) + _ = client2 + + def test_default_query_option(self) -> None: + client = BeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + default_query={"query_param": "bar"}, + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + assert dict(url.params) == {"query_param": "bar"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo", + params={"foo": "baz", "query_param": "overridden"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} + + def test_request_extra_json(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": False} + + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"baz": False} + + # `extra_json` takes priority over `json_data` when keys clash + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar", "baz": True}, + extra_json={"baz": None}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": None} + + def test_request_extra_headers(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options(extra_headers={"X-Foo": "Foo"}), + ), + ) + assert request.headers.get("X-Foo") == "Foo" + + # `extra_headers` takes priority over `default_headers` when keys clash + request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_headers={"X-Bar": "false"}, + ), + ), + ) + assert request.headers.get("X-Bar") == "false" + + def test_request_extra_query(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_query={"my_query_param": "Foo"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"my_query_param": "Foo"} + + # if both `query` and `extra_query` are given, they are merged + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"bar": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"bar": "1", "foo": "2"} + + # `extra_query` takes priority over `query` when keys clash + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"foo": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"foo": "2"} + + def test_multipart_repeating_array(self, client: BeeperDesktop) -> None: + request = client._build_request( + FinalRequestOptions.construct( + method="post", + url="/foo", + headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, + json_data={"array": ["foo", "bar"]}, + files=[("foo.txt", b"hello world")], + ) + ) + + assert request.read().split(b"\r\n") == [ + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"foo", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"bar", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="foo.txt"; filename="upload"', + b"Content-Type: application/octet-stream", + b"", + b"hello world", + b"--6b7ba517decee4a450543ea6ae821c82--", + b"", + ] + + @pytest.mark.respx(base_url=base_url) + def test_basic_union_response(self, respx_mock: MockRouter) -> None: + class Model1(BaseModel): + name: str + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + @pytest.mark.respx(base_url=base_url) + def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + """Union of objects with the same field name using a different type""" + + class Model1(BaseModel): + foo: int + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) + + response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model1) + assert response.foo == 1 + + @pytest.mark.respx(base_url=base_url) + def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + """ + Response that sets Content-Type to something other than application/json but returns json data + """ + + class Model(BaseModel): + foo: int + + respx_mock.get("/foo").mock( + return_value=httpx.Response( + 200, + content=json.dumps({"foo": 2}), + headers={"Content-Type": "application/text"}, + ) + ) + + response = self.client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 + + def test_base_url_setter(self) -> None: + client = BeeperDesktop( + base_url="https://example.com/from_init", access_token=access_token, _strict_response_validation=True + ) + assert client.base_url == "https://example.com/from_init/" + + client.base_url = "https://example.com/from_setter" # type: ignore[assignment] + + assert client.base_url == "https://example.com/from_setter/" + + def test_base_url_env(self) -> None: + with update_env(BEEPER_DESKTOP_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/" + + @pytest.mark.parametrize( + "client", + [ + BeeperDesktop( + base_url="http://localhost:5000/custom/path/", + access_token=access_token, + _strict_response_validation=True, + ), + BeeperDesktop( + base_url="http://localhost:5000/custom/path/", + access_token=access_token, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_trailing_slash(self, client: BeeperDesktop) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + @pytest.mark.parametrize( + "client", + [ + BeeperDesktop( + base_url="http://localhost:5000/custom/path/", + access_token=access_token, + _strict_response_validation=True, + ), + BeeperDesktop( + base_url="http://localhost:5000/custom/path/", + access_token=access_token, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_no_trailing_slash(self, client: BeeperDesktop) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + @pytest.mark.parametrize( + "client", + [ + BeeperDesktop( + base_url="http://localhost:5000/custom/path/", + access_token=access_token, + _strict_response_validation=True, + ), + BeeperDesktop( + base_url="http://localhost:5000/custom/path/", + access_token=access_token, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_absolute_request_url(self, client: BeeperDesktop) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + + def test_copied_client_does_not_close_http(self) -> None: + client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + assert not client.is_closed() + + copied = client.copy() + assert copied is not client + + del copied + + assert not client.is_closed() + + def test_client_context_manager(self) -> None: + client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + with client as c2: + assert c2 is client + assert not c2.is_closed() + assert not client.is_closed() + assert client.is_closed() + + @pytest.mark.respx(base_url=base_url) + def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + self.client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + + def test_client_max_retries_validation(self) -> None: + with pytest.raises(TypeError, match=r"max_retries cannot be None"): + BeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + max_retries=cast(Any, None), + ) + + @pytest.mark.respx(base_url=base_url) + def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) + + strict_client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + + with pytest.raises(APIResponseValidationError): + strict_client.get("/foo", cast_to=Model) + + client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=False) + + response = client.get("/foo", cast_to=Model) + assert isinstance(response, str) # type: ignore[unreachable] + + @pytest.mark.parametrize( + "remaining_retries,retry_after,timeout", + [ + [3, "20", 20], + [3, "0", 0.5], + [3, "-10", 0.5], + [3, "60", 60], + [3, "61", 0.5], + [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], + [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], + [3, "99999999999999999999999999999999999", 0.5], + [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "", 0.5], + [2, "", 0.5 * 2.0], + [1, "", 0.5 * 4.0], + [-1100, "", 8], # test large number potentially overflowing + ], + ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) + def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: + client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + + headers = httpx.Headers({"retry-after": retry_after}) + options = FinalRequestOptions(method="get", url="/foo", max_retries=3) + calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + + @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: + respx_mock.get("/v0/get-accounts").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + client.accounts.with_streaming_response.list().__enter__() + + assert _get_open_connections(self.client) == 0 + + @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: + respx_mock.get("/v0/get-accounts").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + client.accounts.with_streaming_response.list().__enter__() + assert _get_open_connections(self.client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("failure_mode", ["status", "exception"]) + def test_retries_taken( + self, + client: BeeperDesktop, + failures_before_success: int, + failure_mode: Literal["status", "exception"], + respx_mock: MockRouter, + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + if failure_mode == "exception": + raise RuntimeError("oops") + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/v0/get-accounts").mock(side_effect=retry_handler) + + response = client.accounts.with_raw_response.list() + + assert response.retries_taken == failures_before_success + assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_omit_retry_count_header( + self, client: BeeperDesktop, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/v0/get-accounts").mock(side_effect=retry_handler) + + response = client.accounts.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()}) + + assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_overwrite_retry_count_header( + self, client: BeeperDesktop, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/v0/get-accounts").mock(side_effect=retry_handler) + + response = client.accounts.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"}) + + assert response.http_request.headers.get("x-stainless-retry-count") == "42" + + 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") + + client = DefaultHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" + + +class TestAsyncBeeperDesktop: + client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_raw_response(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock( + return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') + ) + + response = await self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + def test_copy(self) -> None: + copied = self.client.copy() + assert id(copied) != id(self.client) + + copied = self.client.copy(access_token="another My Access Token") + assert copied.access_token == "another My Access Token" + assert self.client.access_token == "My Access Token" + + def test_copy_default_options(self) -> None: + # options that have a default are overridden correctly + copied = self.client.copy(max_retries=7) + assert copied.max_retries == 7 + assert self.client.max_retries == 2 + + copied2 = copied.copy(max_retries=6) + assert copied2.max_retries == 6 + assert copied.max_retries == 7 + + # timeout + assert isinstance(self.client.timeout, httpx.Timeout) + copied = self.client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(self.client.timeout, httpx.Timeout) + + def test_copy_default_headers(self) -> None: + client = AsyncBeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + default_headers={"X-Foo": "bar"}, + ) + assert client.default_headers["X-Foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert copied.default_headers["X-Foo"] == "bar" + + # merges already given headers + copied = client.copy(default_headers={"X-Bar": "stainless"}) + assert copied.default_headers["X-Foo"] == "bar" + assert copied.default_headers["X-Bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_headers={"X-Foo": "stainless"}) + assert copied.default_headers["X-Foo"] == "stainless" + + # set_default_headers + + # completely overrides already set values + copied = client.copy(set_default_headers={}) + assert copied.default_headers.get("X-Foo") is None + + copied = client.copy(set_default_headers={"X-Bar": "Robert"}) + assert copied.default_headers["X-Bar"] == "Robert" + + with pytest.raises( + ValueError, + match="`default_headers` and `set_default_headers` arguments are mutually exclusive", + ): + client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + + def test_copy_default_query(self) -> None: + client = AsyncBeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=True, default_query={"foo": "bar"} + ) + assert _get_params(client)["foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert _get_params(copied)["foo"] == "bar" + + # merges already given params + copied = client.copy(default_query={"bar": "stainless"}) + params = _get_params(copied) + assert params["foo"] == "bar" + assert params["bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_query={"foo": "stainless"}) + assert _get_params(copied)["foo"] == "stainless" + + # set_default_query + + # completely overrides already set values + copied = client.copy(set_default_query={}) + assert _get_params(copied) == {} + + copied = client.copy(set_default_query={"bar": "Robert"}) + assert _get_params(copied)["bar"] == "Robert" + + with pytest.raises( + ValueError, + # TODO: update + match="`default_query` and `set_default_query` arguments are mutually exclusive", + ): + client.copy(set_default_query={}, default_query={"foo": "Bar"}) + + def test_copy_signature(self) -> None: + # ensure the same parameters that can be passed to the client are defined in the `.copy()` method + init_signature = inspect.signature( + # mypy doesn't like that we access the `__init__` property. + self.client.__init__, # type: ignore[misc] + ) + copy_signature = inspect.signature(self.client.copy) + exclude_params = {"transport", "proxies", "_strict_response_validation"} + + for name in init_signature.parameters.keys(): + if name in exclude_params: + continue + + copy_param = copy_signature.parameters.get(name) + assert copy_param is not None, f"copy() signature is missing the {name} param" + + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") + def test_copy_build_request(self) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client = self.client.copy() + client._build_request(options) + + # ensure that the machinery is warmed up before tracing starts. + build_request(options) + gc.collect() + + tracemalloc.start(1000) + + snapshot_before = tracemalloc.take_snapshot() + + ITERATIONS = 10 + for _ in range(ITERATIONS): + build_request(options) + + gc.collect() + snapshot_after = tracemalloc.take_snapshot() + + tracemalloc.stop() + + def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: + if diff.count == 0: + # Avoid false positives by considering only leaks (i.e. allocations that persist). + return + + if diff.count % ITERATIONS != 0: + # Avoid false positives by considering only leaks that appear per iteration. + return + + for frame in diff.traceback: + if any( + frame.filename.endswith(fragment) + for fragment in [ + # to_raw_response_wrapper leaks through the @functools.wraps() decorator. + # + # removing the decorator fixes the leak for reasons we don't understand. + "beeper_desktop_api/_legacy_response.py", + "beeper_desktop_api/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "beeper_desktop_api/_compat.py", + # Standard library leaks we don't care about. + "/logging/__init__.py", + ] + ): + return + + leaks.append(diff) + + leaks: list[tracemalloc.StatisticDiff] = [] + for diff in snapshot_after.compare_to(snapshot_before, "traceback"): + add_leak(leaks, diff) + if leaks: + for leak in leaks: + print("MEMORY LEAK:", leak) + for frame in leak.traceback: + print(frame) + raise AssertionError() + + async def test_request_timeout(self) -> None: + request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + request = self.client._build_request( + FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) + ) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(100.0) + + async def test_client_timeout_option(self) -> None: + client = AsyncBeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=True, timeout=httpx.Timeout(0) + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(0) + + async def test_http_client_timeout_option(self) -> None: + # custom timeout given to the httpx client should be used + async with httpx.AsyncClient(timeout=None) as http_client: + client = AsyncBeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(None) + + # no timeout given to the httpx client should not use the httpx default + async with httpx.AsyncClient() as http_client: + client = AsyncBeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + # explicitly passing the default timeout currently results in it being ignored + async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: + client = AsyncBeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT # our default + + def test_invalid_http_client(self) -> None: + with pytest.raises(TypeError, match="Invalid `http_client` arg"): + with httpx.Client() as http_client: + AsyncBeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + http_client=cast(Any, http_client), + ) + + def test_default_headers_option(self) -> None: + client = AsyncBeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + default_headers={"X-Foo": "bar"}, + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "bar" + assert request.headers.get("x-stainless-lang") == "python" + + client2 = AsyncBeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + default_headers={ + "X-Foo": "stainless", + "X-Stainless-Lang": "my-overriding-header", + }, + ) + request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "stainless" + assert request.headers.get("x-stainless-lang") == "my-overriding-header" + + def test_validate_headers(self) -> None: + client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("Authorization") == f"Bearer {access_token}" + + with pytest.raises(BeeperDesktopError): + with update_env(**{"BEEPER_ACCESS_TOKEN": Omit()}): + client2 = AsyncBeeperDesktop(base_url=base_url, access_token=None, _strict_response_validation=True) + _ = client2 + + def test_default_query_option(self) -> None: + client = AsyncBeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + default_query={"query_param": "bar"}, + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + assert dict(url.params) == {"query_param": "bar"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo", + params={"foo": "baz", "query_param": "overridden"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} + + def test_request_extra_json(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": False} + + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"baz": False} + + # `extra_json` takes priority over `json_data` when keys clash + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar", "baz": True}, + extra_json={"baz": None}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": None} + + def test_request_extra_headers(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options(extra_headers={"X-Foo": "Foo"}), + ), + ) + assert request.headers.get("X-Foo") == "Foo" + + # `extra_headers` takes priority over `default_headers` when keys clash + request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_headers={"X-Bar": "false"}, + ), + ), + ) + assert request.headers.get("X-Bar") == "false" + + def test_request_extra_query(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_query={"my_query_param": "Foo"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"my_query_param": "Foo"} + + # if both `query` and `extra_query` are given, they are merged + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"bar": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"bar": "1", "foo": "2"} + + # `extra_query` takes priority over `query` when keys clash + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"foo": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"foo": "2"} + + def test_multipart_repeating_array(self, async_client: AsyncBeeperDesktop) -> None: + request = async_client._build_request( + FinalRequestOptions.construct( + method="post", + url="/foo", + headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, + json_data={"array": ["foo", "bar"]}, + files=[("foo.txt", b"hello world")], + ) + ) + + assert request.read().split(b"\r\n") == [ + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"foo", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"bar", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="foo.txt"; filename="upload"', + b"Content-Type: application/octet-stream", + b"", + b"hello world", + b"--6b7ba517decee4a450543ea6ae821c82--", + b"", + ] + + @pytest.mark.respx(base_url=base_url) + async def test_basic_union_response(self, respx_mock: MockRouter) -> None: + class Model1(BaseModel): + name: str + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + @pytest.mark.respx(base_url=base_url) + async def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + """Union of objects with the same field name using a different type""" + + class Model1(BaseModel): + foo: int + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) + + response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model1) + assert response.foo == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + """ + Response that sets Content-Type to something other than application/json but returns json data + """ + + class Model(BaseModel): + foo: int + + respx_mock.get("/foo").mock( + return_value=httpx.Response( + 200, + content=json.dumps({"foo": 2}), + headers={"Content-Type": "application/text"}, + ) + ) + + response = await self.client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 + + def test_base_url_setter(self) -> None: + client = AsyncBeeperDesktop( + base_url="https://example.com/from_init", access_token=access_token, _strict_response_validation=True + ) + assert client.base_url == "https://example.com/from_init/" + + client.base_url = "https://example.com/from_setter" # type: ignore[assignment] + + assert client.base_url == "https://example.com/from_setter/" + + def test_base_url_env(self) -> None: + with update_env(BEEPER_DESKTOP_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/" + + @pytest.mark.parametrize( + "client", + [ + AsyncBeeperDesktop( + base_url="http://localhost:5000/custom/path/", + access_token=access_token, + _strict_response_validation=True, + ), + AsyncBeeperDesktop( + base_url="http://localhost:5000/custom/path/", + access_token=access_token, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_trailing_slash(self, client: AsyncBeeperDesktop) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + @pytest.mark.parametrize( + "client", + [ + AsyncBeeperDesktop( + base_url="http://localhost:5000/custom/path/", + access_token=access_token, + _strict_response_validation=True, + ), + AsyncBeeperDesktop( + base_url="http://localhost:5000/custom/path/", + access_token=access_token, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_no_trailing_slash(self, client: AsyncBeeperDesktop) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + @pytest.mark.parametrize( + "client", + [ + AsyncBeeperDesktop( + base_url="http://localhost:5000/custom/path/", + access_token=access_token, + _strict_response_validation=True, + ), + AsyncBeeperDesktop( + base_url="http://localhost:5000/custom/path/", + access_token=access_token, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_absolute_request_url(self, client: AsyncBeeperDesktop) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + + async def test_copied_client_does_not_close_http(self) -> None: + client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + assert not client.is_closed() + + copied = client.copy() + assert copied is not client + + del copied + + await asyncio.sleep(0.2) + assert not client.is_closed() + + async def test_client_context_manager(self) -> None: + client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + async with client as c2: + assert c2 is client + assert not c2.is_closed() + assert not client.is_closed() + assert client.is_closed() + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + await self.client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + + async def test_client_max_retries_validation(self) -> None: + with pytest.raises(TypeError, match=r"max_retries cannot be None"): + AsyncBeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + max_retries=cast(Any, None), + ) + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) + + strict_client = AsyncBeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=True + ) + + with pytest.raises(APIResponseValidationError): + await strict_client.get("/foo", cast_to=Model) + + client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=False) + + response = await client.get("/foo", cast_to=Model) + assert isinstance(response, str) # type: ignore[unreachable] + + @pytest.mark.parametrize( + "remaining_retries,retry_after,timeout", + [ + [3, "20", 20], + [3, "0", 0.5], + [3, "-10", 0.5], + [3, "60", 60], + [3, "61", 0.5], + [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], + [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], + [3, "99999999999999999999999999999999999", 0.5], + [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "", 0.5], + [2, "", 0.5 * 2.0], + [1, "", 0.5 * 4.0], + [-1100, "", 8], # test large number potentially overflowing + ], + ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) + @pytest.mark.asyncio + async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: + client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + + headers = httpx.Headers({"retry-after": retry_after}) + options = FinalRequestOptions(method="get", url="/foo", max_retries=3) + calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + + @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_retrying_timeout_errors_doesnt_leak( + self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop + ) -> None: + respx_mock.get("/v0/get-accounts").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + await async_client.accounts.with_streaming_response.list().__aenter__() + + assert _get_open_connections(self.client) == 0 + + @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_retrying_status_errors_doesnt_leak( + self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop + ) -> None: + respx_mock.get("/v0/get-accounts").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + await async_client.accounts.with_streaming_response.list().__aenter__() + assert _get_open_connections(self.client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + @pytest.mark.parametrize("failure_mode", ["status", "exception"]) + async def test_retries_taken( + self, + async_client: AsyncBeeperDesktop, + failures_before_success: int, + failure_mode: Literal["status", "exception"], + respx_mock: MockRouter, + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + if failure_mode == "exception": + raise RuntimeError("oops") + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/v0/get-accounts").mock(side_effect=retry_handler) + + response = await client.accounts.with_raw_response.list() + + assert response.retries_taken == failures_before_success + assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_omit_retry_count_header( + self, async_client: AsyncBeeperDesktop, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/v0/get-accounts").mock(side_effect=retry_handler) + + response = await client.accounts.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()}) + + assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_overwrite_retry_count_header( + self, async_client: AsyncBeeperDesktop, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/v0/get-accounts").mock(side_effect=retry_handler) + + response = await client.accounts.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"}) + + assert response.http_request.headers.get("x-stainless-retry-count") == "42" + + async def test_get_platform(self) -> None: + platform = await asyncify(get_platform)() + assert isinstance(platform, (str, OtherPlatform)) + + 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") + + client = DefaultAsyncHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + async def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultAsyncHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + await self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py new file mode 100644 index 0000000..6288bda --- /dev/null +++ b/tests/test_deepcopy.py @@ -0,0 +1,58 @@ +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_extract_files.py b/tests/test_extract_files.py new file mode 100644 index 0000000..497fb79 --- /dev/null +++ b/tests/test_extract_files.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Sequence + +import pytest + +from beeper_desktop_api._types import FileTypes +from beeper_desktop_api._utils import extract_files + + +def test_removes_files_from_input() -> None: + query = {"foo": "bar"} + assert extract_files(query, paths=[]) == [] + assert query == {"foo": "bar"} + + query2 = {"foo": b"Bar", "hello": "world"} + assert extract_files(query2, paths=[["foo"]]) == [("foo", b"Bar")] + assert query2 == {"hello": "world"} + + query3 = {"foo": {"foo": {"bar": b"Bar"}}, "hello": "world"} + assert extract_files(query3, paths=[["foo", "foo", "bar"]]) == [("foo[foo][bar]", b"Bar")] + assert query3 == {"foo": {"foo": {}}, "hello": "world"} + + query4 = {"foo": {"bar": b"Bar", "baz": "foo"}, "hello": "world"} + assert extract_files(query4, paths=[["foo", "bar"]]) == [("foo[bar]", b"Bar")] + assert query4 == {"hello": "world", "foo": {"baz": "foo"}} + + +def test_multiple_files() -> None: + query = {"documents": [{"file": b"My first file"}, {"file": b"My second file"}]} + assert extract_files(query, paths=[["documents", "", "file"]]) == [ + ("documents[][file]", b"My first file"), + ("documents[][file]", b"My second file"), + ] + assert query == {"documents": [{}, {}]} + + +@pytest.mark.parametrize( + "query,paths,expected", + [ + [ + {"foo": {"bar": "baz"}}, + [["foo", "", "bar"]], + [], + ], + [ + {"foo": ["bar", "baz"]}, + [["foo", "bar"]], + [], + ], + [ + {"foo": {"bar": "baz"}}, + [["foo", "foo"]], + [], + ], + ], + ids=["dict expecting array", "array expecting dict", "unknown keys"], +) +def test_ignores_incorrect_paths( + query: dict[str, object], + paths: Sequence[Sequence[str]], + expected: list[tuple[str, FileTypes]], +) -> None: + assert extract_files(query, paths=paths) == expected diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..76be5ce --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,51 @@ +from pathlib import Path + +import anyio +import pytest +from dirty_equals import IsDict, IsList, IsBytes, IsTuple + +from beeper_desktop_api._files import to_httpx_files, async_to_httpx_files + +readme_path = Path(__file__).parent.parent.joinpath("README.md") + + +def test_pathlib_includes_file_name() -> None: + result = to_httpx_files({"file": readme_path}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +def test_tuple_input() -> None: + result = to_httpx_files([("file", readme_path)]) + print(result) + assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) + + +@pytest.mark.asyncio +async def test_async_pathlib_includes_file_name() -> None: + result = await async_to_httpx_files({"file": readme_path}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +@pytest.mark.asyncio +async def test_async_supports_anyio_path() -> None: + result = await async_to_httpx_files({"file": anyio.Path(readme_path)}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +@pytest.mark.asyncio +async def test_async_tuple_input() -> None: + result = await async_to_httpx_files([("file", readme_path)]) + print(result) + assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) + + +def test_string_not_allowed() -> None: + with pytest.raises(TypeError, match="Expected file types input to be a FileContent type or to be a tuple"): + to_httpx_files( + { + "file": "foo", # type: ignore + } + ) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..b275b2c --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,963 @@ +import json +from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast +from datetime import datetime, timezone +from typing_extensions import Literal, Annotated, TypeAliasType + +import pytest +import pydantic +from pydantic import Field + +from beeper_desktop_api._utils import PropertyInfo +from beeper_desktop_api._compat import PYDANTIC_V1, parse_obj, model_dump, model_json +from beeper_desktop_api._models import BaseModel, construct_type + + +class BasicModel(BaseModel): + foo: str + + +@pytest.mark.parametrize("value", ["hello", 1], ids=["correct type", "mismatched"]) +def test_basic(value: object) -> None: + m = BasicModel.construct(foo=value) + assert m.foo == value + + +def test_directly_nested_model() -> None: + class NestedModel(BaseModel): + nested: BasicModel + + m = NestedModel.construct(nested={"foo": "Foo!"}) + assert m.nested.foo == "Foo!" + + # mismatched types + m = NestedModel.construct(nested="hello!") + assert cast(Any, m.nested) == "hello!" + + +def test_optional_nested_model() -> None: + class NestedModel(BaseModel): + nested: Optional[BasicModel] + + m1 = NestedModel.construct(nested=None) + assert m1.nested is None + + m2 = NestedModel.construct(nested={"foo": "bar"}) + assert m2.nested is not None + assert m2.nested.foo == "bar" + + # mismatched types + m3 = NestedModel.construct(nested={"foo"}) + assert isinstance(cast(Any, m3.nested), set) + assert cast(Any, m3.nested) == {"foo"} + + +def test_list_nested_model() -> None: + class NestedModel(BaseModel): + nested: List[BasicModel] + + m = NestedModel.construct(nested=[{"foo": "bar"}, {"foo": "2"}]) + assert m.nested is not None + assert isinstance(m.nested, list) + assert len(m.nested) == 2 + assert m.nested[0].foo == "bar" + assert m.nested[1].foo == "2" + + # mismatched types + m = NestedModel.construct(nested=True) + assert cast(Any, m.nested) is True + + m = NestedModel.construct(nested=[False]) + assert cast(Any, m.nested) == [False] + + +def test_optional_list_nested_model() -> None: + class NestedModel(BaseModel): + nested: Optional[List[BasicModel]] + + m1 = NestedModel.construct(nested=[{"foo": "bar"}, {"foo": "2"}]) + assert m1.nested is not None + assert isinstance(m1.nested, list) + assert len(m1.nested) == 2 + assert m1.nested[0].foo == "bar" + assert m1.nested[1].foo == "2" + + m2 = NestedModel.construct(nested=None) + assert m2.nested is None + + # mismatched types + m3 = NestedModel.construct(nested={1}) + assert cast(Any, m3.nested) == {1} + + m4 = NestedModel.construct(nested=[False]) + assert cast(Any, m4.nested) == [False] + + +def test_list_optional_items_nested_model() -> None: + class NestedModel(BaseModel): + nested: List[Optional[BasicModel]] + + m = NestedModel.construct(nested=[None, {"foo": "bar"}]) + assert m.nested is not None + assert isinstance(m.nested, list) + assert len(m.nested) == 2 + assert m.nested[0] is None + assert m.nested[1] is not None + assert m.nested[1].foo == "bar" + + # mismatched types + m3 = NestedModel.construct(nested="foo") + assert cast(Any, m3.nested) == "foo" + + m4 = NestedModel.construct(nested=[False]) + assert cast(Any, m4.nested) == [False] + + +def test_list_mismatched_type() -> None: + class NestedModel(BaseModel): + nested: List[str] + + m = NestedModel.construct(nested=False) + assert cast(Any, m.nested) is False + + +def test_raw_dictionary() -> None: + class NestedModel(BaseModel): + nested: Dict[str, str] + + m = NestedModel.construct(nested={"hello": "world"}) + assert m.nested == {"hello": "world"} + + # mismatched types + m = NestedModel.construct(nested=False) + assert cast(Any, m.nested) is False + + +def test_nested_dictionary_model() -> None: + class NestedModel(BaseModel): + nested: Dict[str, BasicModel] + + m = NestedModel.construct(nested={"hello": {"foo": "bar"}}) + assert isinstance(m.nested, dict) + assert m.nested["hello"].foo == "bar" + + # mismatched types + m = NestedModel.construct(nested={"hello": False}) + assert cast(Any, m.nested["hello"]) is False + + +def test_unknown_fields() -> None: + m1 = BasicModel.construct(foo="foo", unknown=1) + assert m1.foo == "foo" + assert cast(Any, m1).unknown == 1 + + m2 = BasicModel.construct(foo="foo", unknown={"foo_bar": True}) + assert m2.foo == "foo" + assert cast(Any, m2).unknown == {"foo_bar": True} + + assert model_dump(m2) == {"foo": "foo", "unknown": {"foo_bar": True}} + + +def test_strict_validation_unknown_fields() -> None: + class Model(BaseModel): + foo: str + + model = parse_obj(Model, dict(foo="hello!", user="Robert")) + assert model.foo == "hello!" + assert cast(Any, model).user == "Robert" + + assert model_dump(model) == {"foo": "hello!", "user": "Robert"} + + +def test_aliases() -> None: + class Model(BaseModel): + my_field: int = Field(alias="myField") + + m = Model.construct(myField=1) + assert m.my_field == 1 + + # mismatched types + m = Model.construct(myField={"hello": False}) + assert cast(Any, m.my_field) == {"hello": False} + + +def test_repr() -> None: + model = BasicModel(foo="bar") + assert str(model) == "BasicModel(foo='bar')" + assert repr(model) == "BasicModel(foo='bar')" + + +def test_repr_nested_model() -> None: + class Child(BaseModel): + name: str + age: int + + class Parent(BaseModel): + name: str + child: Child + + model = Parent(name="Robert", child=Child(name="Foo", age=5)) + assert str(model) == "Parent(name='Robert', child=Child(name='Foo', age=5))" + assert repr(model) == "Parent(name='Robert', child=Child(name='Foo', age=5))" + + +def test_optional_list() -> None: + class Submodel(BaseModel): + name: str + + class Model(BaseModel): + items: Optional[List[Submodel]] + + m = Model.construct(items=None) + assert m.items is None + + m = Model.construct(items=[]) + assert m.items == [] + + m = Model.construct(items=[{"name": "Robert"}]) + assert m.items is not None + assert len(m.items) == 1 + assert m.items[0].name == "Robert" + + +def test_nested_union_of_models() -> None: + class Submodel1(BaseModel): + bar: bool + + class Submodel2(BaseModel): + thing: str + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2] + + m = Model.construct(foo={"thing": "hello"}) + assert isinstance(m.foo, Submodel2) + assert m.foo.thing == "hello" + + +def test_nested_union_of_mixed_types() -> None: + class Submodel1(BaseModel): + bar: bool + + class Model(BaseModel): + foo: Union[Submodel1, Literal[True], Literal["CARD_HOLDER"]] + + m = Model.construct(foo=True) + assert m.foo is True + + m = Model.construct(foo="CARD_HOLDER") + assert m.foo == "CARD_HOLDER" + + m = Model.construct(foo={"bar": False}) + assert isinstance(m.foo, Submodel1) + assert m.foo.bar is False + + +def test_nested_union_multiple_variants() -> None: + class Submodel1(BaseModel): + bar: bool + + class Submodel2(BaseModel): + thing: str + + class Submodel3(BaseModel): + foo: int + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2, None, Submodel3] + + m = Model.construct(foo={"thing": "hello"}) + assert isinstance(m.foo, Submodel2) + assert m.foo.thing == "hello" + + m = Model.construct(foo=None) + assert m.foo is None + + m = Model.construct() + assert m.foo is None + + m = Model.construct(foo={"foo": "1"}) + assert isinstance(m.foo, Submodel3) + assert m.foo.foo == 1 + + +def test_nested_union_invalid_data() -> None: + class Submodel1(BaseModel): + level: int + + class Submodel2(BaseModel): + name: str + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2] + + m = Model.construct(foo=True) + assert cast(bool, m.foo) is True + + m = Model.construct(foo={"name": 3}) + if PYDANTIC_V1: + assert isinstance(m.foo, Submodel2) + assert m.foo.name == "3" + else: + assert isinstance(m.foo, Submodel1) + assert m.foo.name == 3 # type: ignore + + +def test_list_of_unions() -> None: + class Submodel1(BaseModel): + level: int + + class Submodel2(BaseModel): + name: str + + class Model(BaseModel): + items: List[Union[Submodel1, Submodel2]] + + m = Model.construct(items=[{"level": 1}, {"name": "Robert"}]) + assert len(m.items) == 2 + assert isinstance(m.items[0], Submodel1) + assert m.items[0].level == 1 + assert isinstance(m.items[1], Submodel2) + assert m.items[1].name == "Robert" + + m = Model.construct(items=[{"level": -1}, 156]) + assert len(m.items) == 2 + assert isinstance(m.items[0], Submodel1) + assert m.items[0].level == -1 + assert cast(Any, m.items[1]) == 156 + + +def test_union_of_lists() -> None: + class SubModel1(BaseModel): + level: int + + class SubModel2(BaseModel): + name: str + + class Model(BaseModel): + items: Union[List[SubModel1], List[SubModel2]] + + # with one valid entry + m = Model.construct(items=[{"name": "Robert"}]) + assert len(m.items) == 1 + assert isinstance(m.items[0], SubModel2) + assert m.items[0].name == "Robert" + + # with two entries pointing to different types + m = Model.construct(items=[{"level": 1}, {"name": "Robert"}]) + assert len(m.items) == 2 + assert isinstance(m.items[0], SubModel1) + assert m.items[0].level == 1 + assert isinstance(m.items[1], SubModel1) + assert cast(Any, m.items[1]).name == "Robert" + + # with two entries pointing to *completely* different types + m = Model.construct(items=[{"level": -1}, 156]) + assert len(m.items) == 2 + assert isinstance(m.items[0], SubModel1) + assert m.items[0].level == -1 + assert cast(Any, m.items[1]) == 156 + + +def test_dict_of_union() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + foo: str + + class Model(BaseModel): + data: Dict[str, Union[SubModel1, SubModel2]] + + m = Model.construct(data={"hello": {"name": "there"}, "foo": {"foo": "bar"}}) + assert len(list(m.data.keys())) == 2 + assert isinstance(m.data["hello"], SubModel1) + assert m.data["hello"].name == "there" + assert isinstance(m.data["foo"], SubModel2) + assert m.data["foo"].foo == "bar" + + # TODO: test mismatched type + + +def test_double_nested_union() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + bar: str + + class Model(BaseModel): + data: Dict[str, List[Union[SubModel1, SubModel2]]] + + m = Model.construct(data={"foo": [{"bar": "baz"}, {"name": "Robert"}]}) + assert len(m.data["foo"]) == 2 + + entry1 = m.data["foo"][0] + assert isinstance(entry1, SubModel2) + assert entry1.bar == "baz" + + entry2 = m.data["foo"][1] + assert isinstance(entry2, SubModel1) + assert entry2.name == "Robert" + + # TODO: test mismatched type + + +def test_union_of_dict() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + foo: str + + class Model(BaseModel): + data: Union[Dict[str, SubModel1], Dict[str, SubModel2]] + + m = Model.construct(data={"hello": {"name": "there"}, "foo": {"foo": "bar"}}) + assert len(list(m.data.keys())) == 2 + assert isinstance(m.data["hello"], SubModel1) + assert m.data["hello"].name == "there" + assert isinstance(m.data["foo"], SubModel1) + assert cast(Any, m.data["foo"]).foo == "bar" + + +def test_iso8601_datetime() -> None: + class Model(BaseModel): + created_at: datetime + + expected = datetime(2019, 12, 27, 18, 11, 19, 117000, tzinfo=timezone.utc) + + if PYDANTIC_V1: + expected_json = '{"created_at": "2019-12-27T18:11:19.117000+00:00"}' + else: + expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' + + model = Model.construct(created_at="2019-12-27T18:11:19.117Z") + assert model.created_at == expected + assert model_json(model) == expected_json + + model = parse_obj(Model, dict(created_at="2019-12-27T18:11:19.117Z")) + assert model.created_at == expected + assert model_json(model) == expected_json + + +def test_does_not_coerce_int() -> None: + class Model(BaseModel): + bar: int + + assert Model.construct(bar=1).bar == 1 + assert Model.construct(bar=10.9).bar == 10.9 + assert Model.construct(bar="19").bar == "19" # type: ignore[comparison-overlap] + assert Model.construct(bar=False).bar is False + + +def test_int_to_float_safe_conversion() -> None: + class Model(BaseModel): + float_field: float + + m = Model.construct(float_field=10) + assert m.float_field == 10.0 + assert isinstance(m.float_field, float) + + m = Model.construct(float_field=10.12) + assert m.float_field == 10.12 + assert isinstance(m.float_field, float) + + # number too big + m = Model.construct(float_field=2**53 + 1) + assert m.float_field == 2**53 + 1 + assert isinstance(m.float_field, int) + + +def test_deprecated_alias() -> None: + class Model(BaseModel): + resource_id: str = Field(alias="model_id") + + @property + def model_id(self) -> str: + return self.resource_id + + m = Model.construct(model_id="id") + assert m.model_id == "id" + assert m.resource_id == "id" + assert m.resource_id is m.model_id + + m = parse_obj(Model, {"model_id": "id"}) + assert m.model_id == "id" + assert m.resource_id == "id" + assert m.resource_id is m.model_id + + +def test_omitted_fields() -> None: + class Model(BaseModel): + resource_id: Optional[str] = None + + m = Model.construct() + assert m.resource_id is None + assert "resource_id" not in m.model_fields_set + + m = Model.construct(resource_id=None) + assert m.resource_id is None + assert "resource_id" in m.model_fields_set + + m = Model.construct(resource_id="foo") + assert m.resource_id == "foo" + assert "resource_id" in m.model_fields_set + + +def test_to_dict() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert m.to_dict() == {"FOO": "hello"} + assert m.to_dict(use_api_names=False) == {"foo": "hello"} + + m2 = Model() + assert m2.to_dict() == {} + assert m2.to_dict(exclude_unset=False) == {"FOO": None} + assert m2.to_dict(exclude_unset=False, exclude_none=True) == {} + assert m2.to_dict(exclude_unset=False, exclude_defaults=True) == {} + + m3 = Model(FOO=None) + assert m3.to_dict() == {"FOO": None} + assert m3.to_dict(exclude_none=True) == {} + assert m3.to_dict(exclude_defaults=True) == {} + + class Model2(BaseModel): + created_at: datetime + + time_str = "2024-03-21T11:39:01.275859" + m4 = Model2.construct(created_at=time_str) + assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)} + assert m4.to_dict(mode="json") == {"created_at": time_str} + + if PYDANTIC_V1: + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.to_dict(warnings=False) + + +def test_forwards_compat_model_dump_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert m.model_dump() == {"foo": "hello"} + assert m.model_dump(include={"bar"}) == {} + assert m.model_dump(exclude={"foo"}) == {} + assert m.model_dump(by_alias=True) == {"FOO": "hello"} + + m2 = Model() + assert m2.model_dump() == {"foo": None} + assert m2.model_dump(exclude_unset=True) == {} + assert m2.model_dump(exclude_none=True) == {} + assert m2.model_dump(exclude_defaults=True) == {} + + m3 = Model(FOO=None) + assert m3.model_dump() == {"foo": None} + assert m3.model_dump(exclude_none=True) == {} + + if PYDANTIC_V1: + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump(warnings=False) + + +def test_compat_method_no_error_for_warnings() -> None: + class Model(BaseModel): + foo: Optional[str] + + m = Model(foo="hello") + assert isinstance(model_dump(m, warnings=False), dict) + + +def test_to_json() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert json.loads(m.to_json()) == {"FOO": "hello"} + assert json.loads(m.to_json(use_api_names=False)) == {"foo": "hello"} + + if PYDANTIC_V1: + assert m.to_json(indent=None) == '{"FOO": "hello"}' + else: + assert m.to_json(indent=None) == '{"FOO":"hello"}' + + m2 = Model() + assert json.loads(m2.to_json()) == {} + assert json.loads(m2.to_json(exclude_unset=False)) == {"FOO": None} + assert json.loads(m2.to_json(exclude_unset=False, exclude_none=True)) == {} + assert json.loads(m2.to_json(exclude_unset=False, exclude_defaults=True)) == {} + + m3 = Model(FOO=None) + assert json.loads(m3.to_json()) == {"FOO": None} + assert json.loads(m3.to_json(exclude_none=True)) == {} + + if PYDANTIC_V1: + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.to_json(warnings=False) + + +def test_forwards_compat_model_dump_json_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert json.loads(m.model_dump_json()) == {"foo": "hello"} + assert json.loads(m.model_dump_json(include={"bar"})) == {} + assert json.loads(m.model_dump_json(include={"foo"})) == {"foo": "hello"} + assert json.loads(m.model_dump_json(by_alias=True)) == {"FOO": "hello"} + + assert m.model_dump_json(indent=2) == '{\n "foo": "hello"\n}' + + m2 = Model() + assert json.loads(m2.model_dump_json()) == {"foo": None} + assert json.loads(m2.model_dump_json(exclude_unset=True)) == {} + assert json.loads(m2.model_dump_json(exclude_none=True)) == {} + assert json.loads(m2.model_dump_json(exclude_defaults=True)) == {} + + m3 = Model(FOO=None) + assert json.loads(m3.model_dump_json()) == {"foo": None} + assert json.loads(m3.model_dump_json(exclude_none=True)) == {} + + if PYDANTIC_V1: + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump_json(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump_json(warnings=False) + + +def test_type_compat() -> None: + # our model type can be assigned to Pydantic's model type + + def takes_pydantic(model: pydantic.BaseModel) -> None: # noqa: ARG001 + ... + + class OurModel(BaseModel): + foo: Optional[str] = None + + takes_pydantic(OurModel()) + + +def test_annotated_types() -> None: + class Model(BaseModel): + value: str + + m = construct_type( + value={"value": "foo"}, + type_=cast(Any, Annotated[Model, "random metadata"]), + ) + assert isinstance(m, Model) + assert m.value == "foo" + + +def test_discriminated_unions_invalid_data() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "a", "data": 100}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, A) + assert m.type == "a" + if PYDANTIC_V1: + # pydantic v1 automatically converts inputs to strings + # if the expected type is a str + assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] + + +def test_discriminated_unions_unknown_variant() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + m = construct_type( + value={"type": "c", "data": None, "new_thing": "bar"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + + # just chooses the first variant + assert isinstance(m, A) + assert m.type == "c" # type: ignore[comparison-overlap] + assert m.data == None # type: ignore[unreachable] + assert m.new_thing == "bar" + + +def test_discriminated_unions_invalid_data_nested_unions() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + class C(BaseModel): + type: Literal["c"] + + data: bool + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[Union[A, B], C], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "c", "data": "foo"}, + type_=cast(Any, Annotated[Union[Union[A, B], C], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, C) + assert m.type == "c" + assert m.data == "foo" # type: ignore[comparison-overlap] + + +def test_discriminated_unions_with_aliases_invalid_data() -> None: + class A(BaseModel): + foo_type: Literal["a"] = Field(alias="type") + + data: str + + class B(BaseModel): + foo_type: Literal["b"] = Field(alias="type") + + data: int + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="foo_type")]), + ) + assert isinstance(m, B) + assert m.foo_type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "a", "data": 100}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="foo_type")]), + ) + assert isinstance(m, A) + assert m.foo_type == "a" + if PYDANTIC_V1: + # pydantic v1 automatically converts inputs to strings + # if the expected type is a str + assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] + + +def test_discriminated_unions_overlapping_discriminators_invalid_data() -> None: + class A(BaseModel): + type: Literal["a"] + + data: bool + + class B(BaseModel): + type: Literal["a"] + + data: int + + m = construct_type( + value={"type": "a", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "a" + assert m.data == "foo" # type: ignore[comparison-overlap] + + +def test_discriminated_unions_invalid_data_uses_cache() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + UnionType = cast(Any, Union[A, B]) + + assert not hasattr(UnionType, "__discriminator__") + + m = construct_type( + value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + discriminator = UnionType.__discriminator__ + assert discriminator is not None + + m = construct_type( + value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + # if the discriminator details object stays the same between invocations then + # we hit the cache + assert UnionType.__discriminator__ is discriminator + + +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") +def test_type_alias_type() -> None: + Alias = TypeAliasType("Alias", str) # pyright: ignore + + class Model(BaseModel): + alias: Alias + union: Union[int, Alias] + + m = construct_type(value={"alias": "foo", "union": "bar"}, type_=Model) + assert isinstance(m, Model) + assert isinstance(m.alias, str) + assert m.alias == "foo" + assert isinstance(m.union, str) + assert m.union == "bar" + + +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") +def test_field_named_cls() -> None: + class Model(BaseModel): + cls: str + + m = construct_type(value={"cls": "foo"}, type_=Model) + assert isinstance(m, Model) + assert isinstance(m.cls, str) + + +def test_discriminated_union_case() -> None: + class A(BaseModel): + type: Literal["a"] + + data: bool + + class B(BaseModel): + type: Literal["b"] + + data: List[Union[A, object]] + + class ModelA(BaseModel): + type: Literal["modelA"] + + data: int + + class ModelB(BaseModel): + type: Literal["modelB"] + + required: str + + data: Union[A, B] + + # when constructing ModelA | ModelB, value data doesn't match ModelB exactly - missing `required` + m = construct_type( + value={"type": "modelB", "data": {"type": "a", "data": True}}, + type_=cast(Any, Annotated[Union[ModelA, ModelB], PropertyInfo(discriminator="type")]), + ) + + assert isinstance(m, ModelB) + + +def test_nested_discriminated_union() -> None: + class InnerType1(BaseModel): + type: Literal["type_1"] + + class InnerModel(BaseModel): + inner_value: str + + class InnerType2(BaseModel): + type: Literal["type_2"] + some_inner_model: InnerModel + + class Type1(BaseModel): + base_type: Literal["base_type_1"] + value: Annotated[ + Union[ + InnerType1, + InnerType2, + ], + PropertyInfo(discriminator="type"), + ] + + class Type2(BaseModel): + base_type: Literal["base_type_2"] + + T = Annotated[ + Union[ + Type1, + Type2, + ], + PropertyInfo(discriminator="base_type"), + ] + + model = construct_type( + type_=T, + value={ + "base_type": "base_type_1", + "value": { + "type": "type_2", + }, + }, + ) + assert isinstance(model, Type1) + assert isinstance(model.value, InnerType2) + + +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2 for now") +def test_extra_properties() -> None: + class Item(BaseModel): + prop: int + + class Model(BaseModel): + __pydantic_extra__: Dict[str, Item] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + + other: str + + if TYPE_CHECKING: + + def __getattr__(self, attr: str) -> Item: ... + + model = construct_type( + type_=Model, + value={ + "a": {"prop": 1}, + "other": "foo", + }, + ) + assert isinstance(model, Model) + assert model.a.prop == 1 + assert isinstance(model.a, Item) + assert model.other == "foo" diff --git a/tests/test_qs.py b/tests/test_qs.py new file mode 100644 index 0000000..8587110 --- /dev/null +++ b/tests/test_qs.py @@ -0,0 +1,78 @@ +from typing import Any, cast +from functools import partial +from urllib.parse import unquote + +import pytest + +from beeper_desktop_api._qs import Querystring, stringify + + +def test_empty() -> None: + assert stringify({}) == "" + assert stringify({"a": {}}) == "" + assert stringify({"a": {"b": {"c": {}}}}) == "" + + +def test_basic() -> None: + assert stringify({"a": 1}) == "a=1" + assert stringify({"a": "b"}) == "a=b" + assert stringify({"a": True}) == "a=true" + assert stringify({"a": False}) == "a=false" + assert stringify({"a": 1.23456}) == "a=1.23456" + assert stringify({"a": None}) == "" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_nested_dotted(method: str) -> None: + if method == "class": + serialise = Querystring(nested_format="dots").stringify + else: + serialise = partial(stringify, nested_format="dots") + + assert unquote(serialise({"a": {"b": "c"}})) == "a.b=c" + assert unquote(serialise({"a": {"b": "c", "d": "e", "f": "g"}})) == "a.b=c&a.d=e&a.f=g" + assert unquote(serialise({"a": {"b": {"c": {"d": "e"}}}})) == "a.b.c.d=e" + assert unquote(serialise({"a": {"b": True}})) == "a.b=true" + + +def test_nested_brackets() -> None: + assert unquote(stringify({"a": {"b": "c"}})) == "a[b]=c" + assert unquote(stringify({"a": {"b": "c", "d": "e", "f": "g"}})) == "a[b]=c&a[d]=e&a[f]=g" + assert unquote(stringify({"a": {"b": {"c": {"d": "e"}}}})) == "a[b][c][d]=e" + assert unquote(stringify({"a": {"b": True}})) == "a[b]=true" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_array_comma(method: str) -> None: + if method == "class": + serialise = Querystring(array_format="comma").stringify + else: + serialise = partial(stringify, array_format="comma") + + assert unquote(serialise({"in": ["foo", "bar"]})) == "in=foo,bar" + assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b]=true,false" + assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b]=true,false,true" + + +def test_array_repeat() -> None: + assert unquote(stringify({"in": ["foo", "bar"]})) == "in=foo&in=bar" + assert unquote(stringify({"a": {"b": [True, False]}})) == "a[b]=true&a[b]=false" + assert unquote(stringify({"a": {"b": [True, False, None, True]}})) == "a[b]=true&a[b]=false&a[b]=true" + assert unquote(stringify({"in": ["foo", {"b": {"c": ["d", "e"]}}]})) == "in=foo&in[b][c]=d&in[b][c]=e" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_array_brackets(method: str) -> None: + if method == "class": + serialise = Querystring(array_format="brackets").stringify + else: + serialise = partial(stringify, array_format="brackets") + + assert unquote(serialise({"in": ["foo", "bar"]})) == "in[]=foo&in[]=bar" + assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b][]=true&a[b][]=false" + assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b][]=true&a[b][]=false&a[b][]=true" + + +def test_unknown_array_format() -> None: + with pytest.raises(NotImplementedError, match="Unknown array_format value: foo, choose from comma, repeat"): + stringify({"a": ["foo", "bar"]}, array_format=cast(Any, "foo")) diff --git a/tests/test_required_args.py b/tests/test_required_args.py new file mode 100644 index 0000000..71794c6 --- /dev/null +++ b/tests/test_required_args.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import pytest + +from beeper_desktop_api._utils import required_args + + +def test_too_many_positional_params() -> None: + @required_args(["a"]) + def foo(a: str | None = None) -> str | None: + return a + + with pytest.raises(TypeError, match=r"foo\(\) takes 1 argument\(s\) but 2 were given"): + foo("a", "b") # type: ignore + + +def test_positional_param() -> None: + @required_args(["a"]) + def foo(a: str | None = None) -> str | None: + return a + + assert foo("a") == "a" + assert foo(None) is None + assert foo(a="b") == "b" + + with pytest.raises(TypeError, match="Missing required argument: 'a'"): + foo() + + +def test_keyword_only_param() -> None: + @required_args(["a"]) + def foo(*, a: str | None = None) -> str | None: + return a + + assert foo(a="a") == "a" + assert foo(a=None) is None + assert foo(a="b") == "b" + + with pytest.raises(TypeError, match="Missing required argument: 'a'"): + foo() + + +def test_multiple_params() -> None: + @required_args(["a", "b", "c"]) + def foo(a: str = "", *, b: str = "", c: str = "") -> str | None: + return f"{a} {b} {c}" + + assert foo(a="a", b="b", c="c") == "a b c" + + error_message = r"Missing required arguments.*" + + with pytest.raises(TypeError, match=error_message): + foo() + + with pytest.raises(TypeError, match=error_message): + foo(a="a") + + with pytest.raises(TypeError, match=error_message): + foo(b="b") + + with pytest.raises(TypeError, match=error_message): + foo(c="c") + + with pytest.raises(TypeError, match=r"Missing required argument: 'a'"): + foo(b="a", c="c") + + with pytest.raises(TypeError, match=r"Missing required argument: 'b'"): + foo("a", c="c") + + +def test_multiple_variants() -> None: + @required_args(["a"], ["b"]) + def foo(*, a: str | None = None, b: str | None = None) -> str | None: + return a if a is not None else b + + assert foo(a="foo") == "foo" + assert foo(b="bar") == "bar" + assert foo(a=None) is None + assert foo(b=None) is None + + # TODO: this error message could probably be improved + with pytest.raises( + TypeError, + match=r"Missing required arguments; Expected either \('a'\) or \('b'\) arguments to be given", + ): + foo() + + +def test_multiple_params_multiple_variants() -> None: + @required_args(["a", "b"], ["c"]) + def foo(*, a: str | None = None, b: str | None = None, c: str | None = None) -> str | None: + if a is not None: + return a + if b is not None: + return b + return c + + error_message = r"Missing required arguments; Expected either \('a' and 'b'\) or \('c'\) arguments to be given" + + with pytest.raises(TypeError, match=error_message): + foo(a="foo") + + with pytest.raises(TypeError, match=error_message): + foo(b="bar") + + with pytest.raises(TypeError, match=error_message): + foo() + + assert foo(a=None, b="bar") == "bar" + assert foo(c=None) is None + assert foo(c="foo") == "foo" diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 0000000..c68d8cd --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,277 @@ +import json +from typing import Any, List, Union, cast +from typing_extensions import Annotated + +import httpx +import pytest +import pydantic + +from beeper_desktop_api import BaseModel, BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api._response import ( + APIResponse, + BaseAPIResponse, + AsyncAPIResponse, + BinaryAPIResponse, + AsyncBinaryAPIResponse, + extract_response_type, +) +from beeper_desktop_api._streaming import Stream +from beeper_desktop_api._base_client import FinalRequestOptions + + +class ConcreteBaseAPIResponse(APIResponse[bytes]): ... + + +class ConcreteAPIResponse(APIResponse[List[str]]): ... + + +class ConcreteAsyncAPIResponse(APIResponse[httpx.Response]): ... + + +def test_extract_response_type_direct_classes() -> None: + assert extract_response_type(BaseAPIResponse[str]) == str + assert extract_response_type(APIResponse[str]) == str + assert extract_response_type(AsyncAPIResponse[str]) == str + + +def test_extract_response_type_direct_class_missing_type_arg() -> None: + with pytest.raises( + RuntimeError, + match="Expected type to have a type argument at index 0 but it did not", + ): + extract_response_type(AsyncAPIResponse) + + +def test_extract_response_type_concrete_subclasses() -> None: + assert extract_response_type(ConcreteBaseAPIResponse) == bytes + assert extract_response_type(ConcreteAPIResponse) == List[str] + assert extract_response_type(ConcreteAsyncAPIResponse) == httpx.Response + + +def test_extract_response_type_binary_response() -> None: + assert extract_response_type(BinaryAPIResponse) == bytes + assert extract_response_type(AsyncBinaryAPIResponse) == bytes + + +class PydanticModel(pydantic.BaseModel): ... + + +def test_response_parse_mismatched_basemodel(client: BeeperDesktop) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo"), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + with pytest.raises( + TypeError, + match="Pydantic models must subclass our base model type, e.g. `from beeper_desktop_api import BaseModel`", + ): + response.parse(to=PydanticModel) + + +@pytest.mark.asyncio +async def test_async_response_parse_mismatched_basemodel(async_client: AsyncBeeperDesktop) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo"), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + with pytest.raises( + TypeError, + match="Pydantic models must subclass our base model type, e.g. `from beeper_desktop_api import BaseModel`", + ): + await response.parse(to=PydanticModel) + + +def test_response_parse_custom_stream(client: BeeperDesktop) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo"), + client=client, + stream=True, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + stream = response.parse(to=Stream[int]) + assert stream._cast_to == int + + +@pytest.mark.asyncio +async def test_async_response_parse_custom_stream(async_client: AsyncBeeperDesktop) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo"), + client=async_client, + stream=True, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + stream = await response.parse(to=Stream[int]) + assert stream._cast_to == int + + +class CustomModel(BaseModel): + foo: str + bar: int + + +def test_response_parse_custom_model(client: BeeperDesktop) -> None: + response = APIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse(to=CustomModel) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +@pytest.mark.asyncio +async def test_async_response_parse_custom_model(async_client: AsyncBeeperDesktop) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse(to=CustomModel) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +def test_response_parse_annotated_type(client: BeeperDesktop) -> None: + response = APIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse( + to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), + ) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +async def test_async_response_parse_annotated_type(async_client: AsyncBeeperDesktop) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse( + to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), + ) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +@pytest.mark.parametrize( + "content, expected", + [ + ("false", False), + ("true", True), + ("False", False), + ("True", True), + ("TrUe", True), + ("FalSe", False), + ], +) +def test_response_parse_bool(client: BeeperDesktop, content: str, expected: bool) -> None: + response = APIResponse( + raw=httpx.Response(200, content=content), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + result = response.parse(to=bool) + assert result is expected + + +@pytest.mark.parametrize( + "content, expected", + [ + ("false", False), + ("true", True), + ("False", False), + ("True", True), + ("TrUe", True), + ("FalSe", False), + ], +) +async def test_async_response_parse_bool(client: AsyncBeeperDesktop, content: str, expected: bool) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=content), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + result = await response.parse(to=bool) + assert result is expected + + +class OtherModel(BaseModel): + a: str + + +@pytest.mark.parametrize("client", [False], indirect=True) # loose validation +def test_response_parse_expect_model_union_non_json_content(client: BeeperDesktop) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse(to=cast(Any, Union[CustomModel, OtherModel])) + assert isinstance(obj, str) + assert obj == "foo" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("async_client", [False], indirect=True) # loose validation +async def test_async_response_parse_expect_model_union_non_json_content(async_client: AsyncBeeperDesktop) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse(to=cast(Any, Union[CustomModel, OtherModel])) + assert isinstance(obj, str) + assert obj == "foo" diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..892dba3 --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +from typing import Iterator, AsyncIterator + +import httpx +import pytest + +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api._streaming import Stream, AsyncStream, ServerSentEvent + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_basic(sync: bool, client: BeeperDesktop, async_client: AsyncBeeperDesktop) -> None: + def body() -> Iterator[bytes]: + yield b"event: completion\n" + yield b'data: {"foo":true}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_data_missing_event(sync: bool, client: BeeperDesktop, async_client: AsyncBeeperDesktop) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"foo":true}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_event_missing_data(sync: bool, client: BeeperDesktop, async_client: AsyncBeeperDesktop) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.data == "" + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_events(sync: bool, client: BeeperDesktop, async_client: AsyncBeeperDesktop) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"\n" + yield b"event: completion\n" + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.data == "" + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.data == "" + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_events_with_data(sync: bool, client: BeeperDesktop, async_client: AsyncBeeperDesktop) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b'data: {"foo":true}\n' + yield b"\n" + yield b"event: completion\n" + yield b'data: {"bar":false}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.json() == {"bar": False} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_data_lines_with_empty_line( + sync: bool, client: BeeperDesktop, async_client: AsyncBeeperDesktop +) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"data: {\n" + yield b'data: "foo":\n' + yield b"data: \n" + yield b"data:\n" + yield b"data: true}\n" + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + assert sse.data == '{\n"foo":\n\n\ntrue}' + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_data_json_escaped_double_new_line( + sync: bool, client: BeeperDesktop, async_client: AsyncBeeperDesktop +) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b'data: {"foo": "my long\\n\\ncontent"}' + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": "my long\n\ncontent"} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_data_lines(sync: bool, client: BeeperDesktop, async_client: AsyncBeeperDesktop) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"data: {\n" + yield b'data: "foo":\n' + yield b"data: true}\n" + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_special_new_line_character( + sync: bool, + client: BeeperDesktop, + async_client: AsyncBeeperDesktop, +) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"content":" culpa"}\n' + yield b"\n" + yield b'data: {"content":" \xe2\x80\xa8"}\n' + yield b"\n" + yield b'data: {"content":"foo"}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": " culpa"} + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": " 
"} + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": "foo"} + + await assert_empty_iter(iterator) + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multi_byte_character_multiple_chunks( + sync: bool, + client: BeeperDesktop, + async_client: AsyncBeeperDesktop, +) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"content":"' + # bytes taken from the string 'известни' and arbitrarily split + # so that some multi-byte characters span multiple chunks + yield b"\xd0" + yield b"\xb8\xd0\xb7\xd0" + yield b"\xb2\xd0\xb5\xd1\x81\xd1\x82\xd0\xbd\xd0\xb8" + yield b'"}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": "известни"} + + +async def to_aiter(iter: Iterator[bytes]) -> AsyncIterator[bytes]: + for chunk in iter: + yield chunk + + +async def iter_next(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> ServerSentEvent: + if isinstance(iter, AsyncIterator): + return await iter.__anext__() + + return next(iter) + + +async def assert_empty_iter(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> None: + with pytest.raises((StopAsyncIteration, RuntimeError)): + await iter_next(iter) + + +def make_event_iterator( + content: Iterator[bytes], + *, + sync: bool, + client: BeeperDesktop, + async_client: AsyncBeeperDesktop, +) -> Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]: + if sync: + return Stream(cast_to=object, client=client, response=httpx.Response(200, content=content))._iter_events() + + return AsyncStream( + cast_to=object, client=async_client, response=httpx.Response(200, content=to_aiter(content)) + )._iter_events() diff --git a/tests/test_transform.py b/tests/test_transform.py new file mode 100644 index 0000000..83d1090 --- /dev/null +++ b/tests/test_transform.py @@ -0,0 +1,460 @@ +from __future__ import annotations + +import io +import pathlib +from typing import Any, Dict, List, Union, TypeVar, Iterable, Optional, cast +from datetime import date, datetime +from typing_extensions import Required, Annotated, TypedDict + +import pytest + +from beeper_desktop_api._types import Base64FileInput, omit, not_given +from beeper_desktop_api._utils import ( + PropertyInfo, + transform as _transform, + parse_datetime, + async_transform as _async_transform, +) +from beeper_desktop_api._compat import PYDANTIC_V1 +from beeper_desktop_api._models import BaseModel + +_T = TypeVar("_T") + +SAMPLE_FILE_PATH = pathlib.Path(__file__).parent.joinpath("sample_file.txt") + + +async def transform( + data: _T, + expected_type: object, + use_async: bool, +) -> _T: + if use_async: + return await _async_transform(data, expected_type=expected_type) + + return _transform(data, expected_type=expected_type) + + +parametrize = pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) + + +class Foo1(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +@parametrize +@pytest.mark.asyncio +async def test_top_level_alias(use_async: bool) -> None: + assert await transform({"foo_bar": "hello"}, expected_type=Foo1, use_async=use_async) == {"fooBar": "hello"} + + +class Foo2(TypedDict): + bar: Bar2 + + +class Bar2(TypedDict): + this_thing: Annotated[int, PropertyInfo(alias="this__thing")] + baz: Annotated[Baz2, PropertyInfo(alias="Baz")] + + +class Baz2(TypedDict): + my_baz: Annotated[str, PropertyInfo(alias="myBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_recursive_typeddict(use_async: bool) -> None: + assert await transform({"bar": {"this_thing": 1}}, Foo2, use_async) == {"bar": {"this__thing": 1}} + assert await transform({"bar": {"baz": {"my_baz": "foo"}}}, Foo2, use_async) == {"bar": {"Baz": {"myBaz": "foo"}}} + + +class Foo3(TypedDict): + things: List[Bar3] + + +class Bar3(TypedDict): + my_field: Annotated[str, PropertyInfo(alias="myField")] + + +@parametrize +@pytest.mark.asyncio +async def test_list_of_typeddict(use_async: bool) -> None: + result = await transform({"things": [{"my_field": "foo"}, {"my_field": "foo2"}]}, Foo3, use_async) + assert result == {"things": [{"myField": "foo"}, {"myField": "foo2"}]} + + +class Foo4(TypedDict): + foo: Union[Bar4, Baz4] + + +class Bar4(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz4(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_union_of_typeddict(use_async: bool) -> None: + assert await transform({"foo": {"foo_bar": "bar"}}, Foo4, use_async) == {"foo": {"fooBar": "bar"}} + assert await transform({"foo": {"foo_baz": "baz"}}, Foo4, use_async) == {"foo": {"fooBaz": "baz"}} + assert await transform({"foo": {"foo_baz": "baz", "foo_bar": "bar"}}, Foo4, use_async) == { + "foo": {"fooBaz": "baz", "fooBar": "bar"} + } + + +class Foo5(TypedDict): + foo: Annotated[Union[Bar4, List[Baz4]], PropertyInfo(alias="FOO")] + + +class Bar5(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz5(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_union_of_list(use_async: bool) -> None: + assert await transform({"foo": {"foo_bar": "bar"}}, Foo5, use_async) == {"FOO": {"fooBar": "bar"}} + assert await transform( + { + "foo": [ + {"foo_baz": "baz"}, + {"foo_baz": "baz"}, + ] + }, + Foo5, + use_async, + ) == {"FOO": [{"fooBaz": "baz"}, {"fooBaz": "baz"}]} + + +class Foo6(TypedDict): + bar: Annotated[str, PropertyInfo(alias="Bar")] + + +@parametrize +@pytest.mark.asyncio +async def test_includes_unknown_keys(use_async: bool) -> None: + assert await transform({"bar": "bar", "baz_": {"FOO": 1}}, Foo6, use_async) == { + "Bar": "bar", + "baz_": {"FOO": 1}, + } + + +class Foo7(TypedDict): + bar: Annotated[List[Bar7], PropertyInfo(alias="bAr")] + foo: Bar7 + + +class Bar7(TypedDict): + foo: str + + +@parametrize +@pytest.mark.asyncio +async def test_ignores_invalid_input(use_async: bool) -> None: + assert await transform({"bar": ""}, Foo7, use_async) == {"bAr": ""} + assert await transform({"foo": ""}, Foo7, use_async) == {"foo": ""} + + +class DatetimeDict(TypedDict, total=False): + foo: Annotated[datetime, PropertyInfo(format="iso8601")] + + bar: Annotated[Optional[datetime], PropertyInfo(format="iso8601")] + + required: Required[Annotated[Optional[datetime], PropertyInfo(format="iso8601")]] + + list_: Required[Annotated[Optional[List[datetime]], PropertyInfo(format="iso8601")]] + + union: Annotated[Union[int, datetime], PropertyInfo(format="iso8601")] + + +class DateDict(TypedDict, total=False): + foo: Annotated[date, PropertyInfo(format="iso8601")] + + +class DatetimeModel(BaseModel): + foo: datetime + + +class DateModel(BaseModel): + foo: Optional[date] + + +@parametrize +@pytest.mark.asyncio +async def test_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + tz = "+00:00" if PYDANTIC_V1 else "Z" + assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692" + tz} # type: ignore[comparison-overlap] + + dt = dt.replace(tzinfo=None) + assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] + assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] + + assert await transform({"foo": None}, DateDict, use_async) == {"foo": None} # type: ignore[comparison-overlap] + assert await transform(DateModel(foo=None), Any, use_async) == {"foo": None} # type: ignore + assert await transform({"foo": date.fromisoformat("2023-02-23")}, DateDict, use_async) == {"foo": "2023-02-23"} # type: ignore[comparison-overlap] + assert await transform(DateModel(foo=date.fromisoformat("2023-02-23")), DateDict, use_async) == { + "foo": "2023-02-23" + } # type: ignore[comparison-overlap] + + +@parametrize +@pytest.mark.asyncio +async def test_optional_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"bar": dt}, DatetimeDict, use_async) == {"bar": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + + assert await transform({"bar": None}, DatetimeDict, use_async) == {"bar": None} + + +@parametrize +@pytest.mark.asyncio +async def test_required_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"required": dt}, DatetimeDict, use_async) == { + "required": "2023-02-23T14:16:36.337692+00:00" + } # type: ignore[comparison-overlap] + + assert await transform({"required": None}, DatetimeDict, use_async) == {"required": None} + + +@parametrize +@pytest.mark.asyncio +async def test_union_datetime(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"union": dt}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] + "union": "2023-02-23T14:16:36.337692+00:00" + } + + assert await transform({"union": "foo"}, DatetimeDict, use_async) == {"union": "foo"} + + +@parametrize +@pytest.mark.asyncio +async def test_nested_list_iso6801_format(use_async: bool) -> None: + dt1 = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + dt2 = parse_datetime("2022-01-15T06:34:23Z") + assert await transform({"list_": [dt1, dt2]}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] + "list_": ["2023-02-23T14:16:36.337692+00:00", "2022-01-15T06:34:23+00:00"] + } + + +@parametrize +@pytest.mark.asyncio +async def test_datetime_custom_format(use_async: bool) -> None: + dt = parse_datetime("2022-01-15T06:34:23Z") + + result = await transform(dt, Annotated[datetime, PropertyInfo(format="custom", format_template="%H")], use_async) + assert result == "06" # type: ignore[comparison-overlap] + + +class DateDictWithRequiredAlias(TypedDict, total=False): + required_prop: Required[Annotated[date, PropertyInfo(format="iso8601", alias="prop")]] + + +@parametrize +@pytest.mark.asyncio +async def test_datetime_with_alias(use_async: bool) -> None: + assert await transform({"required_prop": None}, DateDictWithRequiredAlias, use_async) == {"prop": None} # type: ignore[comparison-overlap] + assert await transform( + {"required_prop": date.fromisoformat("2023-02-23")}, DateDictWithRequiredAlias, use_async + ) == {"prop": "2023-02-23"} # type: ignore[comparison-overlap] + + +class MyModel(BaseModel): + foo: str + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_model_to_dictionary(use_async: bool) -> None: + assert cast(Any, await transform(MyModel(foo="hi!"), Any, use_async)) == {"foo": "hi!"} + assert cast(Any, await transform(MyModel.construct(foo="hi!"), Any, use_async)) == {"foo": "hi!"} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_empty_model(use_async: bool) -> None: + assert cast(Any, await transform(MyModel.construct(), Any, use_async)) == {} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_unknown_field(use_async: bool) -> None: + assert cast(Any, await transform(MyModel.construct(my_untyped_field=True), Any, use_async)) == { + "my_untyped_field": True + } + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_mismatched_types(use_async: bool) -> None: + model = MyModel.construct(foo=True) + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: + with pytest.warns(UserWarning): + params = await transform(model, Any, use_async) + assert cast(Any, params) == {"foo": True} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_mismatched_object_type(use_async: bool) -> None: + model = MyModel.construct(foo=MyModel.construct(hello="world")) + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: + with pytest.warns(UserWarning): + params = await transform(model, Any, use_async) + assert cast(Any, params) == {"foo": {"hello": "world"}} + + +class ModelNestedObjects(BaseModel): + nested: MyModel + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_nested_objects(use_async: bool) -> None: + model = ModelNestedObjects.construct(nested={"foo": "stainless"}) + assert isinstance(model.nested, MyModel) + assert cast(Any, await transform(model, Any, use_async)) == {"nested": {"foo": "stainless"}} + + +class ModelWithDefaultField(BaseModel): + foo: str + with_none_default: Union[str, None] = None + with_str_default: str = "foo" + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_default_field(use_async: bool) -> None: + # should be excluded when defaults are used + model = ModelWithDefaultField.construct() + assert model.with_none_default is None + assert model.with_str_default == "foo" + assert cast(Any, await transform(model, Any, use_async)) == {} + + # should be included when the default value is explicitly given + model = ModelWithDefaultField.construct(with_none_default=None, with_str_default="foo") + assert model.with_none_default is None + assert model.with_str_default == "foo" + assert cast(Any, await transform(model, Any, use_async)) == {"with_none_default": None, "with_str_default": "foo"} + + # should be included when a non-default value is explicitly given + model = ModelWithDefaultField.construct(with_none_default="bar", with_str_default="baz") + assert model.with_none_default == "bar" + assert model.with_str_default == "baz" + assert cast(Any, await transform(model, Any, use_async)) == {"with_none_default": "bar", "with_str_default": "baz"} + + +class TypedDictIterableUnion(TypedDict): + foo: Annotated[Union[Bar8, Iterable[Baz8]], PropertyInfo(alias="FOO")] + + +class Bar8(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz8(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_iterable_of_dictionaries(use_async: bool) -> None: + assert await transform({"foo": [{"foo_baz": "bar"}]}, TypedDictIterableUnion, use_async) == { + "FOO": [{"fooBaz": "bar"}] + } + assert cast(Any, await transform({"foo": ({"foo_baz": "bar"},)}, TypedDictIterableUnion, use_async)) == { + "FOO": [{"fooBaz": "bar"}] + } + + def my_iter() -> Iterable[Baz8]: + yield {"foo_baz": "hello"} + yield {"foo_baz": "world"} + + assert await transform({"foo": my_iter()}, TypedDictIterableUnion, use_async) == { + "FOO": [{"fooBaz": "hello"}, {"fooBaz": "world"}] + } + + +@parametrize +@pytest.mark.asyncio +async def test_dictionary_items(use_async: bool) -> None: + class DictItems(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + assert await transform({"foo": {"foo_baz": "bar"}}, Dict[str, DictItems], use_async) == {"foo": {"fooBaz": "bar"}} + + +class TypedDictIterableUnionStr(TypedDict): + foo: Annotated[Union[str, Iterable[Baz8]], PropertyInfo(alias="FOO")] + + +@parametrize +@pytest.mark.asyncio +async def test_iterable_union_str(use_async: bool) -> None: + assert await transform({"foo": "bar"}, TypedDictIterableUnionStr, use_async) == {"FOO": "bar"} + assert cast(Any, await transform(iter([{"foo_baz": "bar"}]), Union[str, Iterable[Baz8]], use_async)) == [ + {"fooBaz": "bar"} + ] + + +class TypedDictBase64Input(TypedDict): + foo: Annotated[Union[str, Base64FileInput], PropertyInfo(format="base64")] + + +@parametrize +@pytest.mark.asyncio +async def test_base64_file_input(use_async: bool) -> None: + # strings are left as-is + assert await transform({"foo": "bar"}, TypedDictBase64Input, use_async) == {"foo": "bar"} + + # pathlib.Path is automatically converted to base64 + assert await transform({"foo": SAMPLE_FILE_PATH}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQo=" + } # type: ignore[comparison-overlap] + + # io instances are automatically converted to base64 + assert await transform({"foo": io.StringIO("Hello, world!")}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQ==" + } # type: ignore[comparison-overlap] + assert await transform({"foo": io.BytesIO(b"Hello, world!")}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQ==" + } # type: ignore[comparison-overlap] + + +@parametrize +@pytest.mark.asyncio +async def test_transform_skipping(use_async: bool) -> None: + # lists of ints are left as-is + data = [1, 2, 3] + assert await transform(data, List[int], use_async) is data + + # iterables of ints are converted to a list + data = iter([1, 2, 3]) + assert await transform(data, Iterable[int], use_async) == [1, 2, 3] + + +@parametrize +@pytest.mark.asyncio +async def test_strips_notgiven(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": not_given}, Foo1, use_async) == {} + + +@parametrize +@pytest.mark.asyncio +async def test_strips_omit(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": omit}, Foo1, use_async) == {} diff --git a/tests/test_utils/test_datetime_parse.py b/tests/test_utils/test_datetime_parse.py new file mode 100644 index 0000000..e45c8ff --- /dev/null +++ b/tests/test_utils/test_datetime_parse.py @@ -0,0 +1,110 @@ +""" +Copied from https://github.com/pydantic/pydantic/blob/v1.10.22/tests/test_datetime_parse.py +with modifications so it works without pydantic v1 imports. +""" + +from typing import Type, Union +from datetime import date, datetime, timezone, timedelta + +import pytest + +from beeper_desktop_api._utils import parse_date, parse_datetime + + +def create_tz(minutes: int) -> timezone: + return timezone(timedelta(minutes=minutes)) + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + ("1494012444.883309", date(2017, 5, 5)), + (b"1494012444.883309", date(2017, 5, 5)), + (1_494_012_444.883_309, date(2017, 5, 5)), + ("1494012444", date(2017, 5, 5)), + (1_494_012_444, date(2017, 5, 5)), + (0, date(1970, 1, 1)), + ("2012-04-23", date(2012, 4, 23)), + (b"2012-04-23", date(2012, 4, 23)), + ("2012-4-9", date(2012, 4, 9)), + (date(2012, 4, 9), date(2012, 4, 9)), + (datetime(2012, 4, 9, 12, 15), date(2012, 4, 9)), + # Invalid inputs + ("x20120423", ValueError), + ("2012-04-56", ValueError), + (19_999_999_999, date(2603, 10, 11)), # just before watershed + (20_000_000_001, date(1970, 8, 20)), # just after watershed + (1_549_316_052, date(2019, 2, 4)), # nowish in s + (1_549_316_052_104, date(2019, 2, 4)), # nowish in ms + (1_549_316_052_104_324, date(2019, 2, 4)), # nowish in μs + (1_549_316_052_104_324_096, date(2019, 2, 4)), # nowish in ns + ("infinity", date(9999, 12, 31)), + ("inf", date(9999, 12, 31)), + (float("inf"), date(9999, 12, 31)), + ("infinity ", date(9999, 12, 31)), + (int("1" + "0" * 100), date(9999, 12, 31)), + (1e1000, date(9999, 12, 31)), + ("-infinity", date(1, 1, 1)), + ("-inf", date(1, 1, 1)), + ("nan", ValueError), + ], +) +def test_date_parsing(value: Union[str, bytes, int, float], result: Union[date, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_date(value) + else: + assert parse_date(value) == result + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + # values in seconds + ("1494012444.883309", datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + (1_494_012_444.883_309, datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + ("1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (b"1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (1_494_012_444, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + # values in ms + ("1494012444000.883309", datetime(2017, 5, 5, 19, 27, 24, 883, tzinfo=timezone.utc)), + ("-1494012444000.883309", datetime(1922, 8, 29, 4, 32, 35, 999117, tzinfo=timezone.utc)), + (1_494_012_444_000, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + ("2012-04-23T09:15:00", datetime(2012, 4, 23, 9, 15)), + ("2012-4-9 4:8:16", datetime(2012, 4, 9, 4, 8, 16)), + ("2012-04-23T09:15:00Z", datetime(2012, 4, 23, 9, 15, 0, 0, timezone.utc)), + ("2012-4-9 4:8:16-0320", datetime(2012, 4, 9, 4, 8, 16, 0, create_tz(-200))), + ("2012-04-23T10:20:30.400+02:30", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(150))), + ("2012-04-23T10:20:30.400+02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(120))), + ("2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (b"2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (datetime(2017, 5, 5), datetime(2017, 5, 5)), + (0, datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc)), + # Invalid inputs + ("x20120423091500", ValueError), + ("2012-04-56T09:15:90", ValueError), + ("2012-04-23T11:05:00-25:00", ValueError), + (19_999_999_999, datetime(2603, 10, 11, 11, 33, 19, tzinfo=timezone.utc)), # just before watershed + (20_000_000_001, datetime(1970, 8, 20, 11, 33, 20, 1000, tzinfo=timezone.utc)), # just after watershed + (1_549_316_052, datetime(2019, 2, 4, 21, 34, 12, 0, tzinfo=timezone.utc)), # nowish in s + (1_549_316_052_104, datetime(2019, 2, 4, 21, 34, 12, 104_000, tzinfo=timezone.utc)), # nowish in ms + (1_549_316_052_104_324, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in μs + (1_549_316_052_104_324_096, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in ns + ("infinity", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf ", datetime(9999, 12, 31, 23, 59, 59, 999999)), + (1e50, datetime(9999, 12, 31, 23, 59, 59, 999999)), + (float("inf"), datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("-infinity", datetime(1, 1, 1, 0, 0)), + ("-inf", datetime(1, 1, 1, 0, 0)), + ("nan", ValueError), + ], +) +def test_datetime_parsing(value: Union[str, bytes, int, float], result: Union[datetime, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_datetime(value) + else: + assert parse_datetime(value) == result diff --git a/tests/test_utils/test_proxy.py b/tests/test_utils/test_proxy.py new file mode 100644 index 0000000..4292729 --- /dev/null +++ b/tests/test_utils/test_proxy.py @@ -0,0 +1,34 @@ +import operator +from typing import Any +from typing_extensions import override + +from beeper_desktop_api._utils import LazyProxy + + +class RecursiveLazyProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + return self + + def __call__(self, *_args: Any, **_kwds: Any) -> Any: + raise RuntimeError("This should never be called!") + + +def test_recursive_proxy() -> None: + proxy = RecursiveLazyProxy() + assert repr(proxy) == "RecursiveLazyProxy" + assert str(proxy) == "RecursiveLazyProxy" + assert dir(proxy) == [] + assert type(proxy).__name__ == "RecursiveLazyProxy" + assert type(operator.attrgetter("name.foo.bar.baz")(proxy)).__name__ == "RecursiveLazyProxy" + + +def test_isinstance_does_not_error() -> None: + class AlwaysErrorProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + raise RuntimeError("Mocking missing dependency") + + proxy = AlwaysErrorProxy() + assert not isinstance(proxy, dict) + assert isinstance(proxy, LazyProxy) diff --git a/tests/test_utils/test_typing.py b/tests/test_utils/test_typing.py new file mode 100644 index 0000000..1f34442 --- /dev/null +++ b/tests/test_utils/test_typing.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Generic, TypeVar, cast + +from beeper_desktop_api._utils import extract_type_var_from_base + +_T = TypeVar("_T") +_T2 = TypeVar("_T2") +_T3 = TypeVar("_T3") + + +class BaseGeneric(Generic[_T]): ... + + +class SubclassGeneric(BaseGeneric[_T]): ... + + +class BaseGenericMultipleTypeArgs(Generic[_T, _T2, _T3]): ... + + +class SubclassGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T, _T2, _T3]): ... + + +class SubclassDifferentOrderGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T2, _T, _T3]): ... + + +def test_extract_type_var() -> None: + assert ( + extract_type_var_from_base( + BaseGeneric[int], + index=0, + generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), + ) + == int + ) + + +def test_extract_type_var_generic_subclass() -> None: + assert ( + extract_type_var_from_base( + SubclassGeneric[int], + index=0, + generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), + ) + == int + ) + + +def test_extract_type_var_multiple() -> None: + typ = BaseGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) + + +def test_extract_type_var_generic_subclass_multiple() -> None: + typ = SubclassGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) + + +def test_extract_type_var_generic_subclass_different_ordering_multiple() -> None: + typ = SubclassDifferentOrderGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..9aaac3c --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import os +import inspect +import traceback +import contextlib +from typing import Any, TypeVar, Iterator, Sequence, cast +from datetime import date, datetime +from typing_extensions import Literal, get_args, get_origin, assert_type + +from beeper_desktop_api._types import Omit, NoneType +from beeper_desktop_api._utils import ( + is_dict, + is_list, + is_list_type, + is_union_type, + extract_type_arg, + is_sequence_type, + is_annotated_type, + is_type_alias_type, +) +from beeper_desktop_api._compat import PYDANTIC_V1, field_outer_type, get_model_fields +from beeper_desktop_api._models import BaseModel + +BaseModelT = TypeVar("BaseModelT", bound=BaseModel) + + +def assert_matches_model(model: type[BaseModelT], value: BaseModelT, *, path: list[str]) -> bool: + for name, field in get_model_fields(model).items(): + field_value = getattr(value, name) + if PYDANTIC_V1: + # in v1 nullability was structured differently + # https://docs.pydantic.dev/2.0/migration/#required-optional-and-nullable-fields + allow_none = getattr(field, "allow_none", False) + else: + allow_none = False + + assert_matches_type( + field_outer_type(field), + field_value, + path=[*path, name], + allow_none=allow_none, + ) + + return True + + +# Note: the `path` argument is only used to improve error messages when `--showlocals` is used +def assert_matches_type( + type_: Any, + value: object, + *, + path: list[str], + allow_none: bool = False, +) -> None: + if is_type_alias_type(type_): + type_ = type_.__value__ + + # unwrap `Annotated[T, ...]` -> `T` + if is_annotated_type(type_): + type_ = extract_type_arg(type_, 0) + + if allow_none and value is None: + return + + if type_ is None or type_ is NoneType: + assert value is None + return + + origin = get_origin(type_) or type_ + + if is_list_type(type_): + return _assert_list_type(type_, value) + + if is_sequence_type(type_): + assert isinstance(value, Sequence) + inner_type = get_args(type_)[0] + for entry in value: # type: ignore + assert_type(inner_type, entry) # type: ignore + return + + if origin == str: + assert isinstance(value, str) + elif origin == int: + assert isinstance(value, int) + elif origin == bool: + assert isinstance(value, bool) + elif origin == float: + assert isinstance(value, float) + elif origin == bytes: + assert isinstance(value, bytes) + elif origin == datetime: + assert isinstance(value, datetime) + elif origin == date: + assert isinstance(value, date) + elif origin == object: + # nothing to do here, the expected type is unknown + pass + elif origin == Literal: + assert value in get_args(type_) + elif origin == dict: + assert is_dict(value) + + args = get_args(type_) + key_type = args[0] + items_type = args[1] + + for key, item in value.items(): + assert_matches_type(key_type, key, path=[*path, ""]) + assert_matches_type(items_type, item, path=[*path, ""]) + elif is_union_type(type_): + variants = get_args(type_) + + try: + none_index = variants.index(type(None)) + except ValueError: + pass + else: + # special case Optional[T] for better error messages + if len(variants) == 2: + if value is None: + # valid + return + + return assert_matches_type(type_=variants[not none_index], value=value, path=path) + + for i, variant in enumerate(variants): + try: + assert_matches_type(variant, value, path=[*path, f"variant {i}"]) + return + except AssertionError: + traceback.print_exc() + continue + + raise AssertionError("Did not match any variants") + elif issubclass(origin, BaseModel): + assert isinstance(value, type_) + assert assert_matches_model(type_, cast(Any, value), path=path) + elif inspect.isclass(origin) and origin.__name__ == "HttpxBinaryResponseContent": + assert value.__class__.__name__ == "HttpxBinaryResponseContent" + else: + assert None, f"Unhandled field type: {type_}" + + +def _assert_list_type(type_: type[object], value: object) -> None: + assert is_list(value) + + inner_type = get_args(type_)[0] + for entry in value: + assert_type(inner_type, entry) # type: ignore + + +@contextlib.contextmanager +def update_env(**new_env: str | Omit) -> Iterator[None]: + old = os.environ.copy() + + try: + for name, value in new_env.items(): + if isinstance(value, Omit): + os.environ.pop(name, None) + else: + os.environ[name] = value + + yield None + finally: + os.environ.clear() + os.environ.update(old) From 8a160c82a66ca334aa7867f7e4361aa6770d92ea Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:55:53 +0000 Subject: [PATCH 02/98] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 0cc6155..6c4baf0 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 14 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-8c712fe19f280b0b89ecc8a3ce61e9f6b165cee97ce33f66c66a7a5db339c755.yml openapi_spec_hash: 1ea71129cc1a1ccc3dc8a99566082311 -config_hash: 62c0fc38bf46dc8cddd3298b7ab75dba +config_hash: 64d1986aea34e825a4a842c9da1a0d21 From a8c403bbdc4e3f18ac9067fcaf9e7c3bed762851 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:57:28 +0000 Subject: [PATCH 03/98] chore: configure new SDK language --- .stats.yml | 4 +- README.md | 136 +--- api.md | 63 +- src/beeper_desktop_api/_client.py | 351 +-------- src/beeper_desktop_api/resources/__init__.py | 56 -- src/beeper_desktop_api/resources/accounts.py | 139 ---- .../resources/chats/__init__.py | 33 - .../resources/chats/chats.py | 680 ------------------ .../resources/chats/reminders.py | 273 ------- src/beeper_desktop_api/resources/contacts.py | 199 ----- src/beeper_desktop_api/resources/messages.py | 423 ----------- src/beeper_desktop_api/types/__init__.py | 19 - src/beeper_desktop_api/types/account.py | 25 - .../types/account_list_response.py | 10 - src/beeper_desktop_api/types/chat.py | 70 -- .../types/chat_archive_params.py | 20 - .../types/chat_create_params.py | 30 - .../types/chat_create_response.py | 14 - .../types/chat_retrieve_params.py | 25 - .../types/chat_search_params.py | 81 --- .../types/chats/__init__.py | 3 - .../types/chats/reminder_create_params.py | 28 - .../types/chats/reminder_delete_params.py | 17 - .../types/client_download_asset_params.py | 12 - .../types/client_open_params.py | 26 - .../types/client_search_params.py | 12 - .../types/contact_search_params.py | 17 - .../types/contact_search_response.py | 12 - .../types/download_asset_response.py | 17 - .../types/message_search_params.py | 85 --- .../types/message_send_params.py | 20 - .../types/message_send_response.py | 15 - src/beeper_desktop_api/types/open_response.py | 10 - .../types/search_response.py | 48 -- tests/api_resources/chats/__init__.py | 1 - tests/api_resources/chats/test_reminders.py | 176 ----- tests/api_resources/test_accounts.py | 74 -- tests/api_resources/test_chats.py | 374 ---------- tests/api_resources/test_client.py | 222 ------ tests/api_resources/test_contacts.py | 92 --- tests/api_resources/test_messages.py | 201 ------ tests/test_client.py | 40 +- 42 files changed, 40 insertions(+), 4113 deletions(-) delete mode 100644 src/beeper_desktop_api/resources/accounts.py delete mode 100644 src/beeper_desktop_api/resources/chats/__init__.py delete mode 100644 src/beeper_desktop_api/resources/chats/chats.py delete mode 100644 src/beeper_desktop_api/resources/chats/reminders.py delete mode 100644 src/beeper_desktop_api/resources/contacts.py delete mode 100644 src/beeper_desktop_api/resources/messages.py delete mode 100644 src/beeper_desktop_api/types/account.py delete mode 100644 src/beeper_desktop_api/types/account_list_response.py delete mode 100644 src/beeper_desktop_api/types/chat.py delete mode 100644 src/beeper_desktop_api/types/chat_archive_params.py delete mode 100644 src/beeper_desktop_api/types/chat_create_params.py delete mode 100644 src/beeper_desktop_api/types/chat_create_response.py delete mode 100644 src/beeper_desktop_api/types/chat_retrieve_params.py delete mode 100644 src/beeper_desktop_api/types/chat_search_params.py delete mode 100644 src/beeper_desktop_api/types/chats/reminder_create_params.py delete mode 100644 src/beeper_desktop_api/types/chats/reminder_delete_params.py delete mode 100644 src/beeper_desktop_api/types/client_download_asset_params.py delete mode 100644 src/beeper_desktop_api/types/client_open_params.py delete mode 100644 src/beeper_desktop_api/types/client_search_params.py delete mode 100644 src/beeper_desktop_api/types/contact_search_params.py delete mode 100644 src/beeper_desktop_api/types/contact_search_response.py delete mode 100644 src/beeper_desktop_api/types/download_asset_response.py delete mode 100644 src/beeper_desktop_api/types/message_search_params.py delete mode 100644 src/beeper_desktop_api/types/message_send_params.py delete mode 100644 src/beeper_desktop_api/types/message_send_response.py delete mode 100644 src/beeper_desktop_api/types/open_response.py delete mode 100644 src/beeper_desktop_api/types/search_response.py delete mode 100644 tests/api_resources/chats/__init__.py delete mode 100644 tests/api_resources/chats/test_reminders.py delete mode 100644 tests/api_resources/test_accounts.py delete mode 100644 tests/api_resources/test_chats.py delete mode 100644 tests/api_resources/test_client.py delete mode 100644 tests/api_resources/test_contacts.py delete mode 100644 tests/api_resources/test_messages.py diff --git a/.stats.yml b/.stats.yml index 6c4baf0..abfa216 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 14 +configured_endpoints: 1 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-8c712fe19f280b0b89ecc8a3ce61e9f6b165cee97ce33f66c66a7a5db339c755.yml openapi_spec_hash: 1ea71129cc1a1ccc3dc8a99566082311 -config_hash: 64d1986aea34e825a4a842c9da1a0d21 +config_hash: def03aa92de3408ec65438763617f5c7 diff --git a/README.md b/README.md index f6a25c5..c634600 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,8 @@ 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, - limit=3, - type="single", -) -print(page.items) +user_info = client.token.info() +print(user_info.sub) ``` While you can provide a `access_token` keyword argument, @@ -61,12 +57,8 @@ client = AsyncBeeperDesktop( async def main() -> None: - page = await client.chats.search( - include_muted=True, - limit=3, - type="single", - ) - print(page.items) + user_info = await client.token.info() + print(user_info.sub) asyncio.run(main()) @@ -98,12 +90,8 @@ async def main() -> None: access_token="My Access Token", http_client=DefaultAioHttpClient(), ) as client: - page = await client.chats.search( - include_muted=True, - limit=3, - type="single", - ) - print(page.items) + user_info = await client.token.info() + print(user_info.sub) asyncio.run(main()) @@ -118,101 +106,6 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. -## Pagination - -List methods in the Beeper Desktop API are paginated. - -This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually: - -```python -from beeper_desktop_api import BeeperDesktop - -client = BeeperDesktop() - -all_messages = [] -# Automatically fetches more pages as needed. -for message in client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], - limit=10, - query="deployment", -): - # Do something with message here - all_messages.append(message) -print(all_messages) -``` - -Or, asynchronously: - -```python -import asyncio -from beeper_desktop_api import AsyncBeeperDesktop - -client = AsyncBeeperDesktop() - - -async def main() -> None: - all_messages = [] - # Iterate through items across all pages, issuing requests as needed. - async for message in client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], - limit=10, - query="deployment", - ): - all_messages.append(message) - print(all_messages) - - -asyncio.run(main()) -``` - -Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages: - -```python -first_page = await client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], - limit=10, - query="deployment", -) -if first_page.has_next_page(): - print(f"will fetch next page using these details: {first_page.next_page_info()}") - next_page = await first_page.get_next_page() - print(f"number of items we just fetched: {len(next_page.items)}") - -# Remove `await` for non-async usage. -``` - -Or just work directly with the returned data: - -```python -first_page = await client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], - limit=10, - query="deployment", -) - -print(f"next page cursor: {first_page.oldest_cursor}") # => "next page cursor: ..." -for message in first_page.items: - print(message.id) - -# Remove `await` for non-async usage. -``` - -## Nested params - -Nested parameters are dictionaries, typed using `TypedDict`, for example: - -```python -from beeper_desktop_api import BeeperDesktop - -client = BeeperDesktop() - -base_response = client.chats.reminders.create( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, -) -print(base_response.reminder) -``` - ## Handling errors When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `beeper_desktop_api.APIConnectionError` is raised. @@ -229,10 +122,7 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() try: - client.messages.send( - chat_id="1229391", - text="Hello! Just checking in on the project status.", - ) + client.token.info() except beeper_desktop_api.APIConnectionError as e: print("The server could not be reached") print(e.__cause__) # an underlying Exception, likely raised within httpx. @@ -275,7 +165,7 @@ client = BeeperDesktop( ) # Or, configure per-request: -client.with_options(max_retries=5).accounts.list() +client.with_options(max_retries=5).token.info() ``` ### Timeouts @@ -298,7 +188,7 @@ client = BeeperDesktop( ) # Override per-request: -client.with_options(timeout=5.0).accounts.list() +client.with_options(timeout=5.0).token.info() ``` On timeout, an `APITimeoutError` is thrown. @@ -339,11 +229,11 @@ The "raw" Response object can be accessed by prefixing `.with_raw_response.` to from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() -response = client.accounts.with_raw_response.list() +response = client.token.with_raw_response.info() print(response.headers.get('X-My-Header')) -account = response.parse() # get the object that `accounts.list()` would have returned -print(account) +token = response.parse() # get the object that `token.info()` would have returned +print(token.sub) ``` These methods return an [`APIResponse`](https://github.com/stainless-sdks/beeper-desktop-api-python/tree/main/src/beeper_desktop_api/_response.py) object. @@ -357,7 +247,7 @@ The above interface eagerly reads the full response body when you make the reque To stream the response body, use `.with_streaming_response` instead, which requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. In the async client, these are async methods. ```python -with client.accounts.with_streaming_response.list() as response: +with client.token.with_streaming_response.info() as response: print(response.headers.get("X-My-Header")) for line in response.iter_lines(): diff --git a/api.md b/api.md index 45fb5c3..83f0189 100644 --- a/api.md +++ b/api.md @@ -4,85 +4,28 @@ from beeper_desktop_api.types import Attachment, BaseResponse, Error, Message, Reaction, User ``` -# BeeperDesktop - -Types: - -```python -from beeper_desktop_api.types import DownloadAssetResponse, OpenResponse, SearchResponse -``` - -Methods: - -- client.download_asset(\*\*params) -> DownloadAssetResponse -- client.open(\*\*params) -> OpenResponse -- client.search(\*\*params) -> SearchResponse - # Accounts Types: ```python -from beeper_desktop_api.types import Account, AccountListResponse +from beeper_desktop_api.types import Account ``` -Methods: - -- client.accounts.list() -> AccountListResponse - -# Contacts - -Types: - -```python -from beeper_desktop_api.types import ContactSearchResponse -``` - -Methods: - -- client.contacts.search(\*\*params) -> ContactSearchResponse - # Chats Types: ```python -from beeper_desktop_api.types import Chat, ChatCreateResponse -``` - -Methods: - -- client.chats.create(\*\*params) -> ChatCreateResponse -- client.chats.retrieve(\*\*params) -> Chat -- client.chats.archive(\*\*params) -> BaseResponse -- client.chats.search(\*\*params) -> SyncCursor[Chat] - -## Reminders - -Methods: - -- client.chats.reminders.create(\*\*params) -> BaseResponse -- client.chats.reminders.delete(\*\*params) -> BaseResponse - -# Messages - -Types: - -```python -from beeper_desktop_api.types import MessageSendResponse +from beeper_desktop_api.types import Chat ``` -Methods: - -- client.messages.search(\*\*params) -> SyncCursor[Message] -- client.messages.send(\*\*params) -> MessageSendResponse - # Token Types: ```python -from beeper_desktop_api.types import RevokeRequest, UserInfo +from beeper_desktop_api.types import UserInfo ``` Methods: diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index 309a7fa..44866d0 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -10,46 +10,25 @@ from . import _exceptions from ._qs import Querystring -from .types import client_open_params, client_search_params, client_download_asset_params from ._types import ( - Body, Omit, - Query, - Headers, Timeout, NotGiven, Transport, ProxiesTypes, RequestOptions, - omit, not_given, ) -from ._utils import ( - is_given, - maybe_transform, - get_async_library, - async_maybe_transform, -) +from ._utils import is_given, get_async_library from ._version import __version__ -from ._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from .resources import token, accounts, contacts, messages +from .resources import token from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError, BeeperDesktopError from ._base_client import ( DEFAULT_MAX_RETRIES, SyncAPIClient, AsyncAPIClient, - make_request_options, ) -from .resources.chats import chats -from .types.open_response import OpenResponse -from .types.search_response import SearchResponse -from .types.download_asset_response import DownloadAssetResponse __all__ = [ "Timeout", @@ -64,10 +43,6 @@ class BeeperDesktop(SyncAPIClient): - accounts: accounts.AccountsResource - contacts: contacts.ContactsResource - chats: chats.ChatsResource - messages: messages.MessagesResource token: token.TokenResource with_raw_response: BeeperDesktopWithRawResponse with_streaming_response: BeeperDesktopWithStreamedResponse @@ -126,10 +101,6 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.accounts = accounts.AccountsResource(self) - self.contacts = contacts.ContactsResource(self) - self.chats = chats.ChatsResource(self) - self.messages = messages.MessagesResource(self) self.token = token.TokenResource(self) self.with_raw_response = BeeperDesktopWithRawResponse(self) self.with_streaming_response = BeeperDesktopWithStreamedResponse(self) @@ -205,133 +176,6 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy - def download_asset( - self, - *, - url: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> DownloadAssetResponse: - """ - Download a Matrix asset using its mxc:// or localmxc:// URL and return the local - file URL. - - Args: - url: Matrix content URL (mxc:// or localmxc://) for the asset to download. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self.post( - "/v0/download-asset", - body=maybe_transform({"url": url}, client_download_asset_params.ClientDownloadAssetParams), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=DownloadAssetResponse, - ) - - def open( - self, - *, - chat_id: str | Omit = omit, - draft_attachment_path: str | Omit = omit, - draft_text: str | Omit = omit, - message_id: str | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> OpenResponse: - """ - Open Beeper Desktop and optionally navigate to a specific chat, message, or - pre-fill draft text and attachment. - - Args: - chat_id: Optional Beeper chat ID (or local chat ID) to focus after opening the app. If - omitted, only opens/focuses the app. - - draft_attachment_path: Optional draft attachment path to populate in the message input field. - - draft_text: Optional draft text to populate in the message input field. - - message_id: Optional message ID. Jumps to that message in the chat when opening. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self.post( - "/v0/open-app", - body=maybe_transform( - { - "chat_id": chat_id, - "draft_attachment_path": draft_attachment_path, - "draft_text": draft_text, - "message_id": message_id, - }, - client_open_params.ClientOpenParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=OpenResponse, - ) - - def search( - self, - *, - query: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SearchResponse: - """ - Returns matching chats, participant name matches in groups, and the first page - of messages in one call. Paginate messages via search-messages. Paginate chats - via search-chats. Uses the same sorting as the chat search in the app. - - Args: - query: User-typed search text. Literal word matching (NOT semantic). - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self.get( - "/v0/search", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform({"query": query}, client_search_params.ClientSearchParams), - ), - cast_to=SearchResponse, - ) - @override def _make_status_error( self, @@ -367,10 +211,6 @@ def _make_status_error( class AsyncBeeperDesktop(AsyncAPIClient): - accounts: accounts.AsyncAccountsResource - contacts: contacts.AsyncContactsResource - chats: chats.AsyncChatsResource - messages: messages.AsyncMessagesResource token: token.AsyncTokenResource with_raw_response: AsyncBeeperDesktopWithRawResponse with_streaming_response: AsyncBeeperDesktopWithStreamedResponse @@ -429,10 +269,6 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.accounts = accounts.AsyncAccountsResource(self) - self.contacts = contacts.AsyncContactsResource(self) - self.chats = chats.AsyncChatsResource(self) - self.messages = messages.AsyncMessagesResource(self) self.token = token.AsyncTokenResource(self) self.with_raw_response = AsyncBeeperDesktopWithRawResponse(self) self.with_streaming_response = AsyncBeeperDesktopWithStreamedResponse(self) @@ -508,133 +344,6 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy - async def download_asset( - self, - *, - url: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> DownloadAssetResponse: - """ - Download a Matrix asset using its mxc:// or localmxc:// URL and return the local - file URL. - - Args: - url: Matrix content URL (mxc:// or localmxc://) for the asset to download. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self.post( - "/v0/download-asset", - body=await async_maybe_transform({"url": url}, client_download_asset_params.ClientDownloadAssetParams), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=DownloadAssetResponse, - ) - - async def open( - self, - *, - chat_id: str | Omit = omit, - draft_attachment_path: str | Omit = omit, - draft_text: str | Omit = omit, - message_id: str | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> OpenResponse: - """ - Open Beeper Desktop and optionally navigate to a specific chat, message, or - pre-fill draft text and attachment. - - Args: - chat_id: Optional Beeper chat ID (or local chat ID) to focus after opening the app. If - omitted, only opens/focuses the app. - - draft_attachment_path: Optional draft attachment path to populate in the message input field. - - draft_text: Optional draft text to populate in the message input field. - - message_id: Optional message ID. Jumps to that message in the chat when opening. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self.post( - "/v0/open-app", - body=await async_maybe_transform( - { - "chat_id": chat_id, - "draft_attachment_path": draft_attachment_path, - "draft_text": draft_text, - "message_id": message_id, - }, - client_open_params.ClientOpenParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=OpenResponse, - ) - - async def search( - self, - *, - query: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SearchResponse: - """ - Returns matching chats, participant name matches in groups, and the first page - of messages in one call. Paginate messages via search-messages. Paginate chats - via search-chats. Uses the same sorting as the chat search in the app. - - Args: - query: User-typed search text. Literal word matching (NOT semantic). - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self.get( - "/v0/search", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=await async_maybe_transform({"query": query}, client_search_params.ClientSearchParams), - ), - cast_to=SearchResponse, - ) - @override def _make_status_error( self, @@ -671,79 +380,23 @@ def _make_status_error( class BeeperDesktopWithRawResponse: def __init__(self, client: BeeperDesktop) -> None: - self.accounts = accounts.AccountsResourceWithRawResponse(client.accounts) - self.contacts = contacts.ContactsResourceWithRawResponse(client.contacts) - self.chats = chats.ChatsResourceWithRawResponse(client.chats) - self.messages = messages.MessagesResourceWithRawResponse(client.messages) self.token = token.TokenResourceWithRawResponse(client.token) - self.download_asset = to_raw_response_wrapper( - client.download_asset, - ) - self.open = to_raw_response_wrapper( - client.open, - ) - self.search = to_raw_response_wrapper( - client.search, - ) - class AsyncBeeperDesktopWithRawResponse: def __init__(self, client: AsyncBeeperDesktop) -> None: - self.accounts = accounts.AsyncAccountsResourceWithRawResponse(client.accounts) - self.contacts = contacts.AsyncContactsResourceWithRawResponse(client.contacts) - self.chats = chats.AsyncChatsResourceWithRawResponse(client.chats) - self.messages = messages.AsyncMessagesResourceWithRawResponse(client.messages) self.token = token.AsyncTokenResourceWithRawResponse(client.token) - self.download_asset = async_to_raw_response_wrapper( - client.download_asset, - ) - self.open = async_to_raw_response_wrapper( - client.open, - ) - self.search = async_to_raw_response_wrapper( - client.search, - ) - class BeeperDesktopWithStreamedResponse: def __init__(self, client: BeeperDesktop) -> None: - self.accounts = accounts.AccountsResourceWithStreamingResponse(client.accounts) - self.contacts = contacts.ContactsResourceWithStreamingResponse(client.contacts) - self.chats = chats.ChatsResourceWithStreamingResponse(client.chats) - self.messages = messages.MessagesResourceWithStreamingResponse(client.messages) self.token = token.TokenResourceWithStreamingResponse(client.token) - self.download_asset = to_streamed_response_wrapper( - client.download_asset, - ) - self.open = to_streamed_response_wrapper( - client.open, - ) - self.search = to_streamed_response_wrapper( - client.search, - ) - class AsyncBeeperDesktopWithStreamedResponse: def __init__(self, client: AsyncBeeperDesktop) -> None: - self.accounts = accounts.AsyncAccountsResourceWithStreamingResponse(client.accounts) - self.contacts = contacts.AsyncContactsResourceWithStreamingResponse(client.contacts) - self.chats = chats.AsyncChatsResourceWithStreamingResponse(client.chats) - self.messages = messages.AsyncMessagesResourceWithStreamingResponse(client.messages) self.token = token.AsyncTokenResourceWithStreamingResponse(client.token) - self.download_asset = async_to_streamed_response_wrapper( - client.download_asset, - ) - self.open = async_to_streamed_response_wrapper( - client.open, - ) - self.search = async_to_streamed_response_wrapper( - client.search, - ) - Client = BeeperDesktop diff --git a/src/beeper_desktop_api/resources/__init__.py b/src/beeper_desktop_api/resources/__init__.py index 24ab242..7c3b25f 100644 --- a/src/beeper_desktop_api/resources/__init__.py +++ b/src/beeper_desktop_api/resources/__init__.py @@ -1,13 +1,5 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from .chats import ( - ChatsResource, - AsyncChatsResource, - ChatsResourceWithRawResponse, - AsyncChatsResourceWithRawResponse, - ChatsResourceWithStreamingResponse, - AsyncChatsResourceWithStreamingResponse, -) from .token import ( TokenResource, AsyncTokenResource, @@ -16,56 +8,8 @@ TokenResourceWithStreamingResponse, AsyncTokenResourceWithStreamingResponse, ) -from .accounts import ( - AccountsResource, - AsyncAccountsResource, - AccountsResourceWithRawResponse, - AsyncAccountsResourceWithRawResponse, - AccountsResourceWithStreamingResponse, - AsyncAccountsResourceWithStreamingResponse, -) -from .contacts import ( - ContactsResource, - AsyncContactsResource, - ContactsResourceWithRawResponse, - AsyncContactsResourceWithRawResponse, - ContactsResourceWithStreamingResponse, - AsyncContactsResourceWithStreamingResponse, -) -from .messages import ( - MessagesResource, - AsyncMessagesResource, - MessagesResourceWithRawResponse, - AsyncMessagesResourceWithRawResponse, - MessagesResourceWithStreamingResponse, - AsyncMessagesResourceWithStreamingResponse, -) __all__ = [ - "AccountsResource", - "AsyncAccountsResource", - "AccountsResourceWithRawResponse", - "AsyncAccountsResourceWithRawResponse", - "AccountsResourceWithStreamingResponse", - "AsyncAccountsResourceWithStreamingResponse", - "ContactsResource", - "AsyncContactsResource", - "ContactsResourceWithRawResponse", - "AsyncContactsResourceWithRawResponse", - "ContactsResourceWithStreamingResponse", - "AsyncContactsResourceWithStreamingResponse", - "ChatsResource", - "AsyncChatsResource", - "ChatsResourceWithRawResponse", - "AsyncChatsResourceWithRawResponse", - "ChatsResourceWithStreamingResponse", - "AsyncChatsResourceWithStreamingResponse", - "MessagesResource", - "AsyncMessagesResource", - "MessagesResourceWithRawResponse", - "AsyncMessagesResourceWithRawResponse", - "MessagesResourceWithStreamingResponse", - "AsyncMessagesResourceWithStreamingResponse", "TokenResource", "AsyncTokenResource", "TokenResourceWithRawResponse", diff --git a/src/beeper_desktop_api/resources/accounts.py b/src/beeper_desktop_api/resources/accounts.py deleted file mode 100644 index 8a74cd9..0000000 --- a/src/beeper_desktop_api/resources/accounts.py +++ /dev/null @@ -1,139 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import httpx - -from .._types import Body, Query, Headers, NotGiven, not_given -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from .._base_client import make_request_options -from ..types.account_list_response import AccountListResponse - -__all__ = ["AccountsResource", "AsyncAccountsResource"] - - -class AccountsResource(SyncAPIResource): - """Accounts operations""" - - @cached_property - def with_raw_response(self) -> AccountsResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers - """ - return AccountsResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AccountsResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response - """ - return AccountsResourceWithStreamingResponse(self) - - def list( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AccountListResponse: - """List connected Beeper accounts available on this device""" - return self._get( - "/v0/get-accounts", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=AccountListResponse, - ) - - -class AsyncAccountsResource(AsyncAPIResource): - """Accounts operations""" - - @cached_property - def with_raw_response(self) -> AsyncAccountsResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers - """ - return AsyncAccountsResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncAccountsResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response - """ - return AsyncAccountsResourceWithStreamingResponse(self) - - async def list( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AccountListResponse: - """List connected Beeper accounts available on this device""" - return await self._get( - "/v0/get-accounts", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=AccountListResponse, - ) - - -class AccountsResourceWithRawResponse: - def __init__(self, accounts: AccountsResource) -> None: - self._accounts = accounts - - self.list = to_raw_response_wrapper( - accounts.list, - ) - - -class AsyncAccountsResourceWithRawResponse: - def __init__(self, accounts: AsyncAccountsResource) -> None: - self._accounts = accounts - - self.list = async_to_raw_response_wrapper( - accounts.list, - ) - - -class AccountsResourceWithStreamingResponse: - def __init__(self, accounts: AccountsResource) -> None: - self._accounts = accounts - - self.list = to_streamed_response_wrapper( - accounts.list, - ) - - -class AsyncAccountsResourceWithStreamingResponse: - def __init__(self, accounts: AsyncAccountsResource) -> None: - self._accounts = accounts - - self.list = async_to_streamed_response_wrapper( - accounts.list, - ) diff --git a/src/beeper_desktop_api/resources/chats/__init__.py b/src/beeper_desktop_api/resources/chats/__init__.py deleted file mode 100644 index e26ae7f..0000000 --- a/src/beeper_desktop_api/resources/chats/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from .chats import ( - ChatsResource, - AsyncChatsResource, - ChatsResourceWithRawResponse, - AsyncChatsResourceWithRawResponse, - ChatsResourceWithStreamingResponse, - AsyncChatsResourceWithStreamingResponse, -) -from .reminders import ( - RemindersResource, - AsyncRemindersResource, - RemindersResourceWithRawResponse, - AsyncRemindersResourceWithRawResponse, - RemindersResourceWithStreamingResponse, - AsyncRemindersResourceWithStreamingResponse, -) - -__all__ = [ - "RemindersResource", - "AsyncRemindersResource", - "RemindersResourceWithRawResponse", - "AsyncRemindersResourceWithRawResponse", - "RemindersResourceWithStreamingResponse", - "AsyncRemindersResourceWithStreamingResponse", - "ChatsResource", - "AsyncChatsResource", - "ChatsResourceWithRawResponse", - "AsyncChatsResourceWithRawResponse", - "ChatsResourceWithStreamingResponse", - "AsyncChatsResourceWithStreamingResponse", -] diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py deleted file mode 100644 index a400387..0000000 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ /dev/null @@ -1,680 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import Union, Optional -from datetime import datetime -from typing_extensions import Literal - -import httpx - -from ...types import chat_create_params, chat_search_params, chat_archive_params, chat_retrieve_params -from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given -from ..._utils import maybe_transform, async_maybe_transform -from ..._compat import cached_property -from .reminders import ( - RemindersResource, - AsyncRemindersResource, - RemindersResourceWithRawResponse, - AsyncRemindersResourceWithRawResponse, - RemindersResourceWithStreamingResponse, - AsyncRemindersResourceWithStreamingResponse, -) -from ..._resource import SyncAPIResource, AsyncAPIResource -from ..._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from ...pagination import SyncCursor, AsyncCursor -from ...types.chat import Chat -from ..._base_client import AsyncPaginator, make_request_options -from ...types.chat_create_response import ChatCreateResponse -from ...types.shared.base_response import BaseResponse - -__all__ = ["ChatsResource", "AsyncChatsResource"] - - -class ChatsResource(SyncAPIResource): - """Chats operations""" - - @cached_property - def reminders(self) -> RemindersResource: - """Reminders operations""" - return RemindersResource(self._client) - - @cached_property - def with_raw_response(self) -> ChatsResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers - """ - return ChatsResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> ChatsResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response - """ - return ChatsResourceWithStreamingResponse(self) - - def create( - self, - *, - account_id: str, - participant_ids: SequenceNotStr[str], - type: Literal["single", "group"], - message_text: str | Omit = omit, - title: str | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ChatCreateResponse: - """ - Create a single or group chat on a specific account using participant IDs and - optional title. - - Args: - account_id: Account to create the chat on. - - participant_ids: User IDs to include in the new chat. - - type: Chat type to create: 'single' requires exactly one participantID; 'group' - supports multiple participants and optional title. - - message_text: Optional first message content if the platform requires it to create the chat. - - title: Optional title for group chats; ignored for single chats on most platforms. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._post( - "/v0/create-chat", - body=maybe_transform( - { - "account_id": account_id, - "participant_ids": participant_ids, - "type": type, - "message_text": message_text, - "title": title, - }, - chat_create_params.ChatCreateParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=ChatCreateResponse, - ) - - def retrieve( - self, - *, - chat_id: str, - max_participant_count: Optional[int] | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> Chat: - """ - Retrieve chat details including metadata, participants, and latest message - - Args: - chat_id: Unique identifier of the chat to retrieve. Not available for iMessage chats. - Participants are limited by 'maxParticipantCount'. - - max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0–500. - Defaults to 20. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get( - "/v0/get-chat", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform( - { - "chat_id": chat_id, - "max_participant_count": max_participant_count, - }, - chat_retrieve_params.ChatRetrieveParams, - ), - ), - cast_to=Chat, - ) - - def archive( - self, - *, - chat_id: str, - archived: bool | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BaseResponse: - """Archive or unarchive a chat. - - Set archived=true to move to archive, - archived=false to move back to inbox - - Args: - chat_id: The identifier of the chat to archive or unarchive (accepts both chatID and - local chat ID) - - archived: True to archive, false to unarchive - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._post( - "/v0/archive-chat", - body=maybe_transform( - { - "chat_id": chat_id, - "archived": archived, - }, - chat_archive_params.ChatArchiveParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=BaseResponse, - ) - - def search( - self, - *, - account_ids: SequenceNotStr[str] | Omit = omit, - cursor: str | Omit = omit, - direction: Literal["after", "before"] | Omit = omit, - inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, - include_muted: Optional[bool] | Omit = omit, - last_activity_after: Union[str, datetime] | Omit = omit, - last_activity_before: Union[str, datetime] | Omit = omit, - limit: int | Omit = omit, - query: str | Omit = omit, - scope: Literal["titles", "participants"] | Omit = omit, - type: Literal["single", "group", "any"] | Omit = omit, - unread_only: Optional[bool] | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursor[Chat]: - """ - Search chats by title/network or participants using Beeper Desktop's renderer - algorithm. - - Args: - account_ids: Provide an array of account IDs to filter chats from specific messaging accounts - only - - cursor: Pagination cursor from previous response. Use with direction to navigate results - - direction: Pagination direction: "after" for newer page, "before" for older page. Defaults - to "before" when only cursor is provided. - - inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), - "low-priority", or "archive". If not specified, shows all chats. - - include_muted: Include chats marked as Muted by the user, which are usually less important. - Default: true. Set to false if the user wants a more refined search. - - last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after - this time - - last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before - this time - - limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 - - query: Literal token search (non-semantic). Use single words users type (e.g., - "dinner"). When multiple words provided, ALL must match. Case-insensitive. - - scope: Search scope: 'titles' matches title + network; 'participants' matches - participant names. - - type: Specify the type of chats to retrieve: use "single" for direct messages, "group" - for group chats, or "any" to get all types - - unread_only: Set to true to only retrieve chats that have unread messages - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get_api_list( - "/v0/search-chats", - page=SyncCursor[Chat], - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform( - { - "account_ids": account_ids, - "cursor": cursor, - "direction": direction, - "inbox": inbox, - "include_muted": include_muted, - "last_activity_after": last_activity_after, - "last_activity_before": last_activity_before, - "limit": limit, - "query": query, - "scope": scope, - "type": type, - "unread_only": unread_only, - }, - chat_search_params.ChatSearchParams, - ), - ), - model=Chat, - ) - - -class AsyncChatsResource(AsyncAPIResource): - """Chats operations""" - - @cached_property - def reminders(self) -> AsyncRemindersResource: - """Reminders operations""" - return AsyncRemindersResource(self._client) - - @cached_property - def with_raw_response(self) -> AsyncChatsResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers - """ - return AsyncChatsResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncChatsResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response - """ - return AsyncChatsResourceWithStreamingResponse(self) - - async def create( - self, - *, - account_id: str, - participant_ids: SequenceNotStr[str], - type: Literal["single", "group"], - message_text: str | Omit = omit, - title: str | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ChatCreateResponse: - """ - Create a single or group chat on a specific account using participant IDs and - optional title. - - Args: - account_id: Account to create the chat on. - - participant_ids: User IDs to include in the new chat. - - type: Chat type to create: 'single' requires exactly one participantID; 'group' - supports multiple participants and optional title. - - message_text: Optional first message content if the platform requires it to create the chat. - - title: Optional title for group chats; ignored for single chats on most platforms. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._post( - "/v0/create-chat", - body=await async_maybe_transform( - { - "account_id": account_id, - "participant_ids": participant_ids, - "type": type, - "message_text": message_text, - "title": title, - }, - chat_create_params.ChatCreateParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=ChatCreateResponse, - ) - - async def retrieve( - self, - *, - chat_id: str, - max_participant_count: Optional[int] | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> Chat: - """ - Retrieve chat details including metadata, participants, and latest message - - Args: - chat_id: Unique identifier of the chat to retrieve. Not available for iMessage chats. - Participants are limited by 'maxParticipantCount'. - - max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0–500. - Defaults to 20. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._get( - "/v0/get-chat", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=await async_maybe_transform( - { - "chat_id": chat_id, - "max_participant_count": max_participant_count, - }, - chat_retrieve_params.ChatRetrieveParams, - ), - ), - cast_to=Chat, - ) - - async def archive( - self, - *, - chat_id: str, - archived: bool | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BaseResponse: - """Archive or unarchive a chat. - - Set archived=true to move to archive, - archived=false to move back to inbox - - Args: - chat_id: The identifier of the chat to archive or unarchive (accepts both chatID and - local chat ID) - - archived: True to archive, false to unarchive - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._post( - "/v0/archive-chat", - body=await async_maybe_transform( - { - "chat_id": chat_id, - "archived": archived, - }, - chat_archive_params.ChatArchiveParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=BaseResponse, - ) - - def search( - self, - *, - account_ids: SequenceNotStr[str] | Omit = omit, - cursor: str | Omit = omit, - direction: Literal["after", "before"] | Omit = omit, - inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, - include_muted: Optional[bool] | Omit = omit, - last_activity_after: Union[str, datetime] | Omit = omit, - last_activity_before: Union[str, datetime] | Omit = omit, - limit: int | Omit = omit, - query: str | Omit = omit, - scope: Literal["titles", "participants"] | Omit = omit, - type: Literal["single", "group", "any"] | Omit = omit, - unread_only: Optional[bool] | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[Chat, AsyncCursor[Chat]]: - """ - Search chats by title/network or participants using Beeper Desktop's renderer - algorithm. - - Args: - account_ids: Provide an array of account IDs to filter chats from specific messaging accounts - only - - cursor: Pagination cursor from previous response. Use with direction to navigate results - - direction: Pagination direction: "after" for newer page, "before" for older page. Defaults - to "before" when only cursor is provided. - - inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), - "low-priority", or "archive". If not specified, shows all chats. - - include_muted: Include chats marked as Muted by the user, which are usually less important. - Default: true. Set to false if the user wants a more refined search. - - last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after - this time - - last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before - this time - - limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 - - query: Literal token search (non-semantic). Use single words users type (e.g., - "dinner"). When multiple words provided, ALL must match. Case-insensitive. - - scope: Search scope: 'titles' matches title + network; 'participants' matches - participant names. - - type: Specify the type of chats to retrieve: use "single" for direct messages, "group" - for group chats, or "any" to get all types - - unread_only: Set to true to only retrieve chats that have unread messages - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get_api_list( - "/v0/search-chats", - page=AsyncCursor[Chat], - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform( - { - "account_ids": account_ids, - "cursor": cursor, - "direction": direction, - "inbox": inbox, - "include_muted": include_muted, - "last_activity_after": last_activity_after, - "last_activity_before": last_activity_before, - "limit": limit, - "query": query, - "scope": scope, - "type": type, - "unread_only": unread_only, - }, - chat_search_params.ChatSearchParams, - ), - ), - model=Chat, - ) - - -class ChatsResourceWithRawResponse: - def __init__(self, chats: ChatsResource) -> None: - self._chats = chats - - self.create = to_raw_response_wrapper( - chats.create, - ) - self.retrieve = to_raw_response_wrapper( - chats.retrieve, - ) - self.archive = to_raw_response_wrapper( - chats.archive, - ) - self.search = to_raw_response_wrapper( - chats.search, - ) - - @cached_property - def reminders(self) -> RemindersResourceWithRawResponse: - """Reminders operations""" - return RemindersResourceWithRawResponse(self._chats.reminders) - - -class AsyncChatsResourceWithRawResponse: - def __init__(self, chats: AsyncChatsResource) -> None: - self._chats = chats - - self.create = async_to_raw_response_wrapper( - chats.create, - ) - self.retrieve = async_to_raw_response_wrapper( - chats.retrieve, - ) - self.archive = async_to_raw_response_wrapper( - chats.archive, - ) - self.search = async_to_raw_response_wrapper( - chats.search, - ) - - @cached_property - def reminders(self) -> AsyncRemindersResourceWithRawResponse: - """Reminders operations""" - return AsyncRemindersResourceWithRawResponse(self._chats.reminders) - - -class ChatsResourceWithStreamingResponse: - def __init__(self, chats: ChatsResource) -> None: - self._chats = chats - - self.create = to_streamed_response_wrapper( - chats.create, - ) - self.retrieve = to_streamed_response_wrapper( - chats.retrieve, - ) - self.archive = to_streamed_response_wrapper( - chats.archive, - ) - self.search = to_streamed_response_wrapper( - chats.search, - ) - - @cached_property - def reminders(self) -> RemindersResourceWithStreamingResponse: - """Reminders operations""" - return RemindersResourceWithStreamingResponse(self._chats.reminders) - - -class AsyncChatsResourceWithStreamingResponse: - def __init__(self, chats: AsyncChatsResource) -> None: - self._chats = chats - - self.create = async_to_streamed_response_wrapper( - chats.create, - ) - self.retrieve = async_to_streamed_response_wrapper( - chats.retrieve, - ) - self.archive = async_to_streamed_response_wrapper( - chats.archive, - ) - self.search = async_to_streamed_response_wrapper( - chats.search, - ) - - @cached_property - def reminders(self) -> AsyncRemindersResourceWithStreamingResponse: - """Reminders operations""" - return AsyncRemindersResourceWithStreamingResponse(self._chats.reminders) diff --git a/src/beeper_desktop_api/resources/chats/reminders.py b/src/beeper_desktop_api/resources/chats/reminders.py deleted file mode 100644 index 61cfc5a..0000000 --- a/src/beeper_desktop_api/resources/chats/reminders.py +++ /dev/null @@ -1,273 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import httpx - -from ..._types import Body, Query, Headers, NotGiven, not_given -from ..._utils import maybe_transform, async_maybe_transform -from ..._compat import cached_property -from ..._resource import SyncAPIResource, AsyncAPIResource -from ..._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from ...types.chats import reminder_create_params, reminder_delete_params -from ..._base_client import make_request_options -from ...types.shared.base_response import BaseResponse - -__all__ = ["RemindersResource", "AsyncRemindersResource"] - - -class RemindersResource(SyncAPIResource): - """Reminders operations""" - - @cached_property - def with_raw_response(self) -> RemindersResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers - """ - return RemindersResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> RemindersResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response - """ - return RemindersResourceWithStreamingResponse(self) - - def create( - self, - *, - chat_id: str, - reminder: reminder_create_params.Reminder, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BaseResponse: - """ - Set a reminder for a chat at a specific time - - Args: - chat_id: The identifier of the chat to set reminder for (accepts both chatID and local - chat ID) - - reminder: Reminder configuration - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._post( - "/v0/set-chat-reminder", - body=maybe_transform( - { - "chat_id": chat_id, - "reminder": reminder, - }, - reminder_create_params.ReminderCreateParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=BaseResponse, - ) - - def delete( - self, - *, - chat_id: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BaseResponse: - """ - Clear an existing reminder from a chat - - Args: - chat_id: The identifier of the chat to clear reminder from (accepts both chatID and local - chat ID) - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._post( - "/v0/clear-chat-reminder", - body=maybe_transform({"chat_id": chat_id}, reminder_delete_params.ReminderDeleteParams), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=BaseResponse, - ) - - -class AsyncRemindersResource(AsyncAPIResource): - """Reminders operations""" - - @cached_property - def with_raw_response(self) -> AsyncRemindersResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers - """ - return AsyncRemindersResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncRemindersResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response - """ - return AsyncRemindersResourceWithStreamingResponse(self) - - async def create( - self, - *, - chat_id: str, - reminder: reminder_create_params.Reminder, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BaseResponse: - """ - Set a reminder for a chat at a specific time - - Args: - chat_id: The identifier of the chat to set reminder for (accepts both chatID and local - chat ID) - - reminder: Reminder configuration - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._post( - "/v0/set-chat-reminder", - body=await async_maybe_transform( - { - "chat_id": chat_id, - "reminder": reminder, - }, - reminder_create_params.ReminderCreateParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=BaseResponse, - ) - - async def delete( - self, - *, - chat_id: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BaseResponse: - """ - Clear an existing reminder from a chat - - Args: - chat_id: The identifier of the chat to clear reminder from (accepts both chatID and local - chat ID) - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._post( - "/v0/clear-chat-reminder", - body=await async_maybe_transform({"chat_id": chat_id}, reminder_delete_params.ReminderDeleteParams), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=BaseResponse, - ) - - -class RemindersResourceWithRawResponse: - def __init__(self, reminders: RemindersResource) -> None: - self._reminders = reminders - - self.create = to_raw_response_wrapper( - reminders.create, - ) - self.delete = to_raw_response_wrapper( - reminders.delete, - ) - - -class AsyncRemindersResourceWithRawResponse: - def __init__(self, reminders: AsyncRemindersResource) -> None: - self._reminders = reminders - - self.create = async_to_raw_response_wrapper( - reminders.create, - ) - self.delete = async_to_raw_response_wrapper( - reminders.delete, - ) - - -class RemindersResourceWithStreamingResponse: - def __init__(self, reminders: RemindersResource) -> None: - self._reminders = reminders - - self.create = to_streamed_response_wrapper( - reminders.create, - ) - self.delete = to_streamed_response_wrapper( - reminders.delete, - ) - - -class AsyncRemindersResourceWithStreamingResponse: - def __init__(self, reminders: AsyncRemindersResource) -> None: - self._reminders = reminders - - self.create = async_to_streamed_response_wrapper( - reminders.create, - ) - self.delete = async_to_streamed_response_wrapper( - reminders.delete, - ) diff --git a/src/beeper_desktop_api/resources/contacts.py b/src/beeper_desktop_api/resources/contacts.py deleted file mode 100644 index bdcd990..0000000 --- a/src/beeper_desktop_api/resources/contacts.py +++ /dev/null @@ -1,199 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import httpx - -from ..types import contact_search_params -from .._types import Body, Query, Headers, NotGiven, not_given -from .._utils import maybe_transform, async_maybe_transform -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from .._base_client import make_request_options -from ..types.contact_search_response import ContactSearchResponse - -__all__ = ["ContactsResource", "AsyncContactsResource"] - - -class ContactsResource(SyncAPIResource): - """Contacts operations""" - - @cached_property - def with_raw_response(self) -> ContactsResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers - """ - return ContactsResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> ContactsResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response - """ - return ContactsResourceWithStreamingResponse(self) - - def search( - self, - *, - account_id: str, - query: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ContactSearchResponse: - """Search users across on a specific account using the network's search API. - - Only - use for creating new chats. - - Args: - account_id: Beeper account ID this resource belongs to. - - query: Text to search users by. Network-specific behavior. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get( - "/v0/search-users", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform( - { - "account_id": account_id, - "query": query, - }, - contact_search_params.ContactSearchParams, - ), - ), - cast_to=ContactSearchResponse, - ) - - -class AsyncContactsResource(AsyncAPIResource): - """Contacts operations""" - - @cached_property - def with_raw_response(self) -> AsyncContactsResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers - """ - return AsyncContactsResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncContactsResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response - """ - return AsyncContactsResourceWithStreamingResponse(self) - - async def search( - self, - *, - account_id: str, - query: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ContactSearchResponse: - """Search users across on a specific account using the network's search API. - - Only - use for creating new chats. - - Args: - account_id: Beeper account ID this resource belongs to. - - query: Text to search users by. Network-specific behavior. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._get( - "/v0/search-users", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=await async_maybe_transform( - { - "account_id": account_id, - "query": query, - }, - contact_search_params.ContactSearchParams, - ), - ), - cast_to=ContactSearchResponse, - ) - - -class ContactsResourceWithRawResponse: - def __init__(self, contacts: ContactsResource) -> None: - self._contacts = contacts - - self.search = to_raw_response_wrapper( - contacts.search, - ) - - -class AsyncContactsResourceWithRawResponse: - def __init__(self, contacts: AsyncContactsResource) -> None: - self._contacts = contacts - - self.search = async_to_raw_response_wrapper( - contacts.search, - ) - - -class ContactsResourceWithStreamingResponse: - def __init__(self, contacts: ContactsResource) -> None: - self._contacts = contacts - - self.search = to_streamed_response_wrapper( - contacts.search, - ) - - -class AsyncContactsResourceWithStreamingResponse: - def __init__(self, contacts: AsyncContactsResource) -> None: - self._contacts = contacts - - self.search = async_to_streamed_response_wrapper( - contacts.search, - ) diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py deleted file mode 100644 index 1cdefc8..0000000 --- a/src/beeper_desktop_api/resources/messages.py +++ /dev/null @@ -1,423 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import List, Union, Optional -from datetime import datetime -from typing_extensions import Literal - -import httpx - -from ..types import message_send_params, message_search_params -from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given -from .._utils import maybe_transform, async_maybe_transform -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from ..pagination import SyncCursor, AsyncCursor -from .._base_client import AsyncPaginator, make_request_options -from ..types.shared.message import Message -from ..types.message_send_response import MessageSendResponse - -__all__ = ["MessagesResource", "AsyncMessagesResource"] - - -class MessagesResource(SyncAPIResource): - """Messages operations""" - - @cached_property - def with_raw_response(self) -> MessagesResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers - """ - return MessagesResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> MessagesResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response - """ - return MessagesResourceWithStreamingResponse(self) - - def search( - self, - *, - account_ids: SequenceNotStr[str] | Omit = omit, - chat_ids: SequenceNotStr[str] | Omit = omit, - chat_type: Literal["group", "single"] | Omit = omit, - cursor: str | Omit = omit, - date_after: Union[str, datetime] | Omit = omit, - date_before: Union[str, datetime] | Omit = omit, - direction: Literal["after", "before"] | Omit = omit, - exclude_low_priority: Optional[bool] | Omit = omit, - include_muted: Optional[bool] | Omit = omit, - limit: int | Omit = omit, - media_types: List[Literal["any", "video", "image", "link", "file"]] | Omit = omit, - query: str | Omit = omit, - sender: Union[Literal["me", "others"], str] | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursor[Message]: - """ - Search messages across chats using Beeper's message index - - Args: - account_ids: Limit search to specific Beeper account IDs (bridge instances). - - chat_ids: Limit search to specific Beeper chat IDs. - - chat_type: Filter by chat type: 'group' for group chats, 'single' for 1:1 chats. - - cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. - - date_after: Only include messages with timestamp strictly after this ISO 8601 datetime - (e.g., '2024-07-01T00:00:00Z' or '2024-07-01T00:00:00+02:00'). - - date_before: Only include messages with timestamp strictly before this ISO 8601 datetime - (e.g., '2024-07-31T23:59:59Z' or '2024-07-31T23:59:59+02:00'). - - direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' - fetches newer results. Defaults to 'before' when only 'cursor' is provided. - - exclude_low_priority: Exclude messages marked Low Priority by the user. Default: true. Set to false to - include all. - - include_muted: Include messages in chats marked as Muted by the user, which are usually less - important. Default: true. Set to false if the user wants a more refined search. - - limit: Maximum number of messages to return (1–500). Defaults to 20. The current - implementation caps each page at 20 items even if a higher limit is requested. - - media_types: Filter messages by media types. Use ['any'] for any media type, or specify exact - types like ['video', 'image']. Omit for no media filtering. - - query: Literal word search (NOT semantic). Finds messages containing these EXACT words - in any order. Use single words users actually type, not concepts or phrases. - Example: use "dinner" not "dinner plans", use "sick" not "health issues". If - omitted, returns results filtered only by other parameters. - - sender: Filter by sender: 'me' (messages sent by the authenticated user), 'others' - (messages sent by others), or a specific user ID string (user.id). - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get_api_list( - "/v0/search-messages", - page=SyncCursor[Message], - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform( - { - "account_ids": account_ids, - "chat_ids": chat_ids, - "chat_type": chat_type, - "cursor": cursor, - "date_after": date_after, - "date_before": date_before, - "direction": direction, - "exclude_low_priority": exclude_low_priority, - "include_muted": include_muted, - "limit": limit, - "media_types": media_types, - "query": query, - "sender": sender, - }, - message_search_params.MessageSearchParams, - ), - ), - model=Message, - ) - - def send( - self, - *, - chat_id: str, - reply_to_message_id: str | Omit = omit, - text: str | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> MessageSendResponse: - """Send a text message to a specific chat. - - Supports replying to existing messages. - Returns the sent message ID. - - Args: - chat_id: Unique identifier of the chat (a.k.a. room or thread). - - reply_to_message_id: Provide a message ID to send this as a reply to an existing message - - text: Text content of the message you want to send. You may use markdown. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._post( - "/v0/send-message", - body=maybe_transform( - { - "chat_id": chat_id, - "reply_to_message_id": reply_to_message_id, - "text": text, - }, - message_send_params.MessageSendParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=MessageSendResponse, - ) - - -class AsyncMessagesResource(AsyncAPIResource): - """Messages operations""" - - @cached_property - def with_raw_response(self) -> AsyncMessagesResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers - """ - return AsyncMessagesResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncMessagesResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response - """ - return AsyncMessagesResourceWithStreamingResponse(self) - - def search( - self, - *, - account_ids: SequenceNotStr[str] | Omit = omit, - chat_ids: SequenceNotStr[str] | Omit = omit, - chat_type: Literal["group", "single"] | Omit = omit, - cursor: str | Omit = omit, - date_after: Union[str, datetime] | Omit = omit, - date_before: Union[str, datetime] | Omit = omit, - direction: Literal["after", "before"] | Omit = omit, - exclude_low_priority: Optional[bool] | Omit = omit, - include_muted: Optional[bool] | Omit = omit, - limit: int | Omit = omit, - media_types: List[Literal["any", "video", "image", "link", "file"]] | Omit = omit, - query: str | Omit = omit, - sender: Union[Literal["me", "others"], str] | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[Message, AsyncCursor[Message]]: - """ - Search messages across chats using Beeper's message index - - Args: - account_ids: Limit search to specific Beeper account IDs (bridge instances). - - chat_ids: Limit search to specific Beeper chat IDs. - - chat_type: Filter by chat type: 'group' for group chats, 'single' for 1:1 chats. - - cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. - - date_after: Only include messages with timestamp strictly after this ISO 8601 datetime - (e.g., '2024-07-01T00:00:00Z' or '2024-07-01T00:00:00+02:00'). - - date_before: Only include messages with timestamp strictly before this ISO 8601 datetime - (e.g., '2024-07-31T23:59:59Z' or '2024-07-31T23:59:59+02:00'). - - direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' - fetches newer results. Defaults to 'before' when only 'cursor' is provided. - - exclude_low_priority: Exclude messages marked Low Priority by the user. Default: true. Set to false to - include all. - - include_muted: Include messages in chats marked as Muted by the user, which are usually less - important. Default: true. Set to false if the user wants a more refined search. - - limit: Maximum number of messages to return (1–500). Defaults to 20. The current - implementation caps each page at 20 items even if a higher limit is requested. - - media_types: Filter messages by media types. Use ['any'] for any media type, or specify exact - types like ['video', 'image']. Omit for no media filtering. - - query: Literal word search (NOT semantic). Finds messages containing these EXACT words - in any order. Use single words users actually type, not concepts or phrases. - Example: use "dinner" not "dinner plans", use "sick" not "health issues". If - omitted, returns results filtered only by other parameters. - - sender: Filter by sender: 'me' (messages sent by the authenticated user), 'others' - (messages sent by others), or a specific user ID string (user.id). - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get_api_list( - "/v0/search-messages", - page=AsyncCursor[Message], - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform( - { - "account_ids": account_ids, - "chat_ids": chat_ids, - "chat_type": chat_type, - "cursor": cursor, - "date_after": date_after, - "date_before": date_before, - "direction": direction, - "exclude_low_priority": exclude_low_priority, - "include_muted": include_muted, - "limit": limit, - "media_types": media_types, - "query": query, - "sender": sender, - }, - message_search_params.MessageSearchParams, - ), - ), - model=Message, - ) - - async def send( - self, - *, - chat_id: str, - reply_to_message_id: str | Omit = omit, - text: str | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> MessageSendResponse: - """Send a text message to a specific chat. - - Supports replying to existing messages. - Returns the sent message ID. - - Args: - chat_id: Unique identifier of the chat (a.k.a. room or thread). - - reply_to_message_id: Provide a message ID to send this as a reply to an existing message - - text: Text content of the message you want to send. You may use markdown. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._post( - "/v0/send-message", - body=await async_maybe_transform( - { - "chat_id": chat_id, - "reply_to_message_id": reply_to_message_id, - "text": text, - }, - message_send_params.MessageSendParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=MessageSendResponse, - ) - - -class MessagesResourceWithRawResponse: - def __init__(self, messages: MessagesResource) -> None: - self._messages = messages - - self.search = to_raw_response_wrapper( - messages.search, - ) - self.send = to_raw_response_wrapper( - messages.send, - ) - - -class AsyncMessagesResourceWithRawResponse: - def __init__(self, messages: AsyncMessagesResource) -> None: - self._messages = messages - - self.search = async_to_raw_response_wrapper( - messages.search, - ) - self.send = async_to_raw_response_wrapper( - messages.send, - ) - - -class MessagesResourceWithStreamingResponse: - def __init__(self, messages: MessagesResource) -> None: - self._messages = messages - - self.search = to_streamed_response_wrapper( - messages.search, - ) - self.send = to_streamed_response_wrapper( - messages.send, - ) - - -class AsyncMessagesResourceWithStreamingResponse: - def __init__(self, messages: AsyncMessagesResource) -> None: - self._messages = messages - - self.search = async_to_streamed_response_wrapper( - messages.search, - ) - self.send = async_to_streamed_response_wrapper( - messages.send, - ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index 5bede4c..bb86833 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from .chat import Chat as Chat from .shared import ( User as User, Error as Error, @@ -11,22 +10,4 @@ Attachment as Attachment, BaseResponse as BaseResponse, ) -from .account import Account as Account from .user_info import UserInfo as UserInfo -from .open_response import OpenResponse as OpenResponse -from .search_response import SearchResponse as SearchResponse -from .chat_create_params import ChatCreateParams as ChatCreateParams -from .chat_search_params import ChatSearchParams as ChatSearchParams -from .client_open_params import ClientOpenParams as ClientOpenParams -from .chat_archive_params import ChatArchiveParams as ChatArchiveParams -from .message_send_params import MessageSendParams as MessageSendParams -from .chat_create_response import ChatCreateResponse as ChatCreateResponse -from .chat_retrieve_params import ChatRetrieveParams as ChatRetrieveParams -from .client_search_params import ClientSearchParams as ClientSearchParams -from .account_list_response import AccountListResponse as AccountListResponse -from .contact_search_params import ContactSearchParams as ContactSearchParams -from .message_search_params import MessageSearchParams as MessageSearchParams -from .message_send_response import MessageSendResponse as MessageSendResponse -from .contact_search_response import ContactSearchResponse as ContactSearchResponse -from .download_asset_response import DownloadAssetResponse as DownloadAssetResponse -from .client_download_asset_params import ClientDownloadAssetParams as ClientDownloadAssetParams diff --git a/src/beeper_desktop_api/types/account.py b/src/beeper_desktop_api/types/account.py deleted file mode 100644 index 54c2c36..0000000 --- a/src/beeper_desktop_api/types/account.py +++ /dev/null @@ -1,25 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from pydantic import Field as FieldInfo - -from .._models import BaseModel -from .shared.user import User - -__all__ = ["Account"] - - -class Account(BaseModel): - account_id: str = FieldInfo(alias="accountID") - """Chat account added to Beeper. Use this to route account-scoped actions.""" - - network: str - """Display-only human-readable network name (e.g., 'WhatsApp', 'Messenger'). - - You MUST use 'accountID' to perform actions. - """ - - user: User - """A person on or reachable through Beeper. - - Values are best-effort and can vary by network. - """ diff --git a/src/beeper_desktop_api/types/account_list_response.py b/src/beeper_desktop_api/types/account_list_response.py deleted file mode 100644 index 8268843..0000000 --- a/src/beeper_desktop_api/types/account_list_response.py +++ /dev/null @@ -1,10 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List -from typing_extensions import TypeAlias - -from .account import Account - -__all__ = ["AccountListResponse"] - -AccountListResponse: TypeAlias = List[Account] diff --git a/src/beeper_desktop_api/types/chat.py b/src/beeper_desktop_api/types/chat.py deleted file mode 100644 index 493585a..0000000 --- a/src/beeper_desktop_api/types/chat.py +++ /dev/null @@ -1,70 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List, Union, Optional -from datetime import datetime -from typing_extensions import Literal - -from pydantic import Field as FieldInfo - -from .._models import BaseModel -from .shared.user import User - -__all__ = ["Chat", "Participants"] - - -class Participants(BaseModel): - has_more: bool = FieldInfo(alias="hasMore") - """True if there are more participants than included in items.""" - - items: List[User] - """Participants returned for this chat (limited by the request; may be a subset).""" - - total: int - """Total number of participants in the chat.""" - - -class Chat(BaseModel): - id: str - """Unique identifier of the chat (room/thread ID, same as id) across Beeper.""" - - account_id: str = FieldInfo(alias="accountID") - """Beeper account ID this chat belongs to.""" - - network: str - """Display-only human-readable network name (e.g., 'WhatsApp', 'Messenger'). - - You MUST use 'accountID' to perform actions. - """ - - participants: Participants - """Chat participants information.""" - - title: str - """Display title of the chat as computed by the client/server.""" - - type: Literal["single", "group"] - """Chat type: 'single' for direct messages, 'group' for group chats.""" - - unread_count: int = FieldInfo(alias="unreadCount") - """Number of unread messages.""" - - is_archived: Optional[bool] = FieldInfo(alias="isArchived", default=None) - """True if chat is archived.""" - - is_muted: Optional[bool] = FieldInfo(alias="isMuted", default=None) - """True if chat notifications are muted.""" - - is_pinned: Optional[bool] = FieldInfo(alias="isPinned", default=None) - """True if chat is pinned.""" - - last_activity: Optional[datetime] = FieldInfo(alias="lastActivity", default=None) - """Timestamp of last activity. - - Chats with more recent activity are often more important. - """ - - last_read_message_sort_key: Union[int, str, None] = FieldInfo(alias="lastReadMessageSortKey", default=None) - """Last read message sortKey (hsOrder). Used to compute 'isUnread'.""" - - local_chat_id: Optional[str] = FieldInfo(alias="localChatID", default=None) - """Local chat ID specific to this Beeper Desktop installation.""" diff --git a/src/beeper_desktop_api/types/chat_archive_params.py b/src/beeper_desktop_api/types/chat_archive_params.py deleted file mode 100644 index 35a3124..0000000 --- a/src/beeper_desktop_api/types/chat_archive_params.py +++ /dev/null @@ -1,20 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, Annotated, TypedDict - -from .._utils import PropertyInfo - -__all__ = ["ChatArchiveParams"] - - -class ChatArchiveParams(TypedDict, total=False): - chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] - """ - The identifier of the chat to archive or unarchive (accepts both chatID and - local chat ID) - """ - - archived: bool - """True to archive, false to unarchive""" diff --git a/src/beeper_desktop_api/types/chat_create_params.py b/src/beeper_desktop_api/types/chat_create_params.py deleted file mode 100644 index 686bfaa..0000000 --- a/src/beeper_desktop_api/types/chat_create_params.py +++ /dev/null @@ -1,30 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Literal, Required, Annotated, TypedDict - -from .._types import SequenceNotStr -from .._utils import PropertyInfo - -__all__ = ["ChatCreateParams"] - - -class ChatCreateParams(TypedDict, total=False): - account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] - """Account to create the chat on.""" - - participant_ids: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")]] - """User IDs to include in the new chat.""" - - type: Required[Literal["single", "group"]] - """ - Chat type to create: 'single' requires exactly one participantID; 'group' - supports multiple participants and optional title. - """ - - message_text: Annotated[str, PropertyInfo(alias="messageText")] - """Optional first message content if the platform requires it to create the chat.""" - - title: str - """Optional title for group chats; ignored for single chats on most platforms.""" diff --git a/src/beeper_desktop_api/types/chat_create_response.py b/src/beeper_desktop_api/types/chat_create_response.py deleted file mode 100644 index 64b6981..0000000 --- a/src/beeper_desktop_api/types/chat_create_response.py +++ /dev/null @@ -1,14 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Optional - -from pydantic import Field as FieldInfo - -from .shared.base_response import BaseResponse - -__all__ = ["ChatCreateResponse"] - - -class ChatCreateResponse(BaseResponse): - chat_id: Optional[str] = FieldInfo(alias="chatID", default=None) - """Newly created chat if available.""" diff --git a/src/beeper_desktop_api/types/chat_retrieve_params.py b/src/beeper_desktop_api/types/chat_retrieve_params.py deleted file mode 100644 index 7d0d0ff..0000000 --- a/src/beeper_desktop_api/types/chat_retrieve_params.py +++ /dev/null @@ -1,25 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import Optional -from typing_extensions import Required, Annotated, TypedDict - -from .._utils import PropertyInfo - -__all__ = ["ChatRetrieveParams"] - - -class ChatRetrieveParams(TypedDict, total=False): - chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] - """Unique identifier of the chat to retrieve. - - Not available for iMessage chats. Participants are limited by - 'maxParticipantCount'. - """ - - max_participant_count: Annotated[Optional[int], PropertyInfo(alias="maxParticipantCount")] - """Maximum number of participants to return. - - Use -1 for all; otherwise 0–500. Defaults to 20. - """ diff --git a/src/beeper_desktop_api/types/chat_search_params.py b/src/beeper_desktop_api/types/chat_search_params.py deleted file mode 100644 index de94b8d..0000000 --- a/src/beeper_desktop_api/types/chat_search_params.py +++ /dev/null @@ -1,81 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import Union, Optional -from datetime import datetime -from typing_extensions import Literal, Annotated, TypedDict - -from .._types import SequenceNotStr -from .._utils import PropertyInfo - -__all__ = ["ChatSearchParams"] - - -class ChatSearchParams(TypedDict, total=False): - account_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="accountIDs")] - """ - Provide an array of account IDs to filter chats from specific messaging accounts - only - """ - - cursor: str - """Pagination cursor from previous response. - - Use with direction to navigate results - """ - - direction: Literal["after", "before"] - """Pagination direction: "after" for newer page, "before" for older page. - - Defaults to "before" when only cursor is provided. - """ - - inbox: Literal["primary", "low-priority", "archive"] - """ - Filter by inbox type: "primary" (non-archived, non-low-priority), - "low-priority", or "archive". If not specified, shows all chats. - """ - - include_muted: Annotated[Optional[bool], PropertyInfo(alias="includeMuted")] - """Include chats marked as Muted by the user, which are usually less important. - - Default: true. Set to false if the user wants a more refined search. - """ - - last_activity_after: Annotated[Union[str, datetime], PropertyInfo(alias="lastActivityAfter", format="iso8601")] - """ - Provide an ISO datetime string to only retrieve chats with last activity after - this time - """ - - last_activity_before: Annotated[Union[str, datetime], PropertyInfo(alias="lastActivityBefore", format="iso8601")] - """ - Provide an ISO datetime string to only retrieve chats with last activity before - this time - """ - - limit: int - """Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50""" - - query: str - """Literal token search (non-semantic). - - Use single words users type (e.g., "dinner"). When multiple words provided, ALL - must match. Case-insensitive. - """ - - scope: Literal["titles", "participants"] - """ - Search scope: 'titles' matches title + network; 'participants' matches - participant names. - """ - - type: Literal["single", "group", "any"] - """ - Specify the type of chats to retrieve: use "single" for direct messages, "group" - for group chats, or "any" to get all types - """ - - unread_only: Annotated[Optional[bool], PropertyInfo(alias="unreadOnly")] - """Set to true to only retrieve chats that have unread messages""" diff --git a/src/beeper_desktop_api/types/chats/__init__.py b/src/beeper_desktop_api/types/chats/__init__.py index 87b79ef..f8ee8b1 100644 --- a/src/beeper_desktop_api/types/chats/__init__.py +++ b/src/beeper_desktop_api/types/chats/__init__.py @@ -1,6 +1,3 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. from __future__ import annotations - -from .reminder_create_params import ReminderCreateParams as ReminderCreateParams -from .reminder_delete_params import ReminderDeleteParams as ReminderDeleteParams diff --git a/src/beeper_desktop_api/types/chats/reminder_create_params.py b/src/beeper_desktop_api/types/chats/reminder_create_params.py deleted file mode 100644 index 62bbbda..0000000 --- a/src/beeper_desktop_api/types/chats/reminder_create_params.py +++ /dev/null @@ -1,28 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, Annotated, TypedDict - -from ..._utils import PropertyInfo - -__all__ = ["ReminderCreateParams", "Reminder"] - - -class ReminderCreateParams(TypedDict, total=False): - chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] - """ - The identifier of the chat to set reminder for (accepts both chatID and local - chat ID) - """ - - reminder: Required[Reminder] - """Reminder configuration""" - - -class Reminder(TypedDict, total=False): - remind_at_ms: Required[Annotated[float, PropertyInfo(alias="remindAtMs")]] - """Unix timestamp in milliseconds when reminder should trigger""" - - dismiss_on_incoming_message: Annotated[bool, PropertyInfo(alias="dismissOnIncomingMessage")] - """Cancel reminder if someone messages in the chat""" diff --git a/src/beeper_desktop_api/types/chats/reminder_delete_params.py b/src/beeper_desktop_api/types/chats/reminder_delete_params.py deleted file mode 100644 index 6a8eb99..0000000 --- a/src/beeper_desktop_api/types/chats/reminder_delete_params.py +++ /dev/null @@ -1,17 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, Annotated, TypedDict - -from ..._utils import PropertyInfo - -__all__ = ["ReminderDeleteParams"] - - -class ReminderDeleteParams(TypedDict, total=False): - chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] - """ - The identifier of the chat to clear reminder from (accepts both chatID and local - chat ID) - """ diff --git a/src/beeper_desktop_api/types/client_download_asset_params.py b/src/beeper_desktop_api/types/client_download_asset_params.py deleted file mode 100644 index fe824e0..0000000 --- a/src/beeper_desktop_api/types/client_download_asset_params.py +++ /dev/null @@ -1,12 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, TypedDict - -__all__ = ["ClientDownloadAssetParams"] - - -class ClientDownloadAssetParams(TypedDict, total=False): - url: Required[str] - """Matrix content URL (mxc:// or localmxc://) for the asset to download.""" diff --git a/src/beeper_desktop_api/types/client_open_params.py b/src/beeper_desktop_api/types/client_open_params.py deleted file mode 100644 index 84dea5f..0000000 --- a/src/beeper_desktop_api/types/client_open_params.py +++ /dev/null @@ -1,26 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Annotated, TypedDict - -from .._utils import PropertyInfo - -__all__ = ["ClientOpenParams"] - - -class ClientOpenParams(TypedDict, total=False): - chat_id: Annotated[str, PropertyInfo(alias="chatID")] - """Optional Beeper chat ID (or local chat ID) to focus after opening the app. - - If omitted, only opens/focuses the app. - """ - - draft_attachment_path: Annotated[str, PropertyInfo(alias="draftAttachmentPath")] - """Optional draft attachment path to populate in the message input field.""" - - draft_text: Annotated[str, PropertyInfo(alias="draftText")] - """Optional draft text to populate in the message input field.""" - - message_id: Annotated[str, PropertyInfo(alias="messageID")] - """Optional message ID. Jumps to that message in the chat when opening.""" diff --git a/src/beeper_desktop_api/types/client_search_params.py b/src/beeper_desktop_api/types/client_search_params.py deleted file mode 100644 index 06d58e4..0000000 --- a/src/beeper_desktop_api/types/client_search_params.py +++ /dev/null @@ -1,12 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, TypedDict - -__all__ = ["ClientSearchParams"] - - -class ClientSearchParams(TypedDict, total=False): - query: Required[str] - """User-typed search text. Literal word matching (NOT semantic).""" diff --git a/src/beeper_desktop_api/types/contact_search_params.py b/src/beeper_desktop_api/types/contact_search_params.py deleted file mode 100644 index 6808003..0000000 --- a/src/beeper_desktop_api/types/contact_search_params.py +++ /dev/null @@ -1,17 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, Annotated, TypedDict - -from .._utils import PropertyInfo - -__all__ = ["ContactSearchParams"] - - -class ContactSearchParams(TypedDict, total=False): - account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] - """Beeper account ID this resource belongs to.""" - - query: Required[str] - """Text to search users by. Network-specific behavior.""" diff --git a/src/beeper_desktop_api/types/contact_search_response.py b/src/beeper_desktop_api/types/contact_search_response.py deleted file mode 100644 index 71c609e..0000000 --- a/src/beeper_desktop_api/types/contact_search_response.py +++ /dev/null @@ -1,12 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List - -from .._models import BaseModel -from .shared.user import User - -__all__ = ["ContactSearchResponse"] - - -class ContactSearchResponse(BaseModel): - items: List[User] diff --git a/src/beeper_desktop_api/types/download_asset_response.py b/src/beeper_desktop_api/types/download_asset_response.py deleted file mode 100644 index 47bc22e..0000000 --- a/src/beeper_desktop_api/types/download_asset_response.py +++ /dev/null @@ -1,17 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Optional - -from pydantic import Field as FieldInfo - -from .._models import BaseModel - -__all__ = ["DownloadAssetResponse"] - - -class DownloadAssetResponse(BaseModel): - error: Optional[str] = None - """Error message if the download failed.""" - - src_url: Optional[str] = FieldInfo(alias="srcURL", default=None) - """Local file URL to the downloaded asset.""" diff --git a/src/beeper_desktop_api/types/message_search_params.py b/src/beeper_desktop_api/types/message_search_params.py deleted file mode 100644 index 4aadb17..0000000 --- a/src/beeper_desktop_api/types/message_search_params.py +++ /dev/null @@ -1,85 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import List, Union, Optional -from datetime import datetime -from typing_extensions import Literal, Annotated, TypedDict - -from .._types import SequenceNotStr -from .._utils import PropertyInfo - -__all__ = ["MessageSearchParams"] - - -class MessageSearchParams(TypedDict, total=False): - account_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="accountIDs")] - """Limit search to specific Beeper account IDs (bridge instances).""" - - chat_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="chatIDs")] - """Limit search to specific Beeper chat IDs.""" - - chat_type: Annotated[Literal["group", "single"], PropertyInfo(alias="chatType")] - """Filter by chat type: 'group' for group chats, 'single' for 1:1 chats.""" - - cursor: str - """Opaque pagination cursor; do not inspect. Use together with 'direction'.""" - - date_after: Annotated[Union[str, datetime], PropertyInfo(alias="dateAfter", format="iso8601")] - """ - Only include messages with timestamp strictly after this ISO 8601 datetime - (e.g., '2024-07-01T00:00:00Z' or '2024-07-01T00:00:00+02:00'). - """ - - date_before: Annotated[Union[str, datetime], PropertyInfo(alias="dateBefore", format="iso8601")] - """ - Only include messages with timestamp strictly before this ISO 8601 datetime - (e.g., '2024-07-31T23:59:59Z' or '2024-07-31T23:59:59+02:00'). - """ - - direction: Literal["after", "before"] - """ - Pagination direction used with 'cursor': 'before' fetches older results, 'after' - fetches newer results. Defaults to 'before' when only 'cursor' is provided. - """ - - exclude_low_priority: Annotated[Optional[bool], PropertyInfo(alias="excludeLowPriority")] - """Exclude messages marked Low Priority by the user. - - Default: true. Set to false to include all. - """ - - include_muted: Annotated[Optional[bool], PropertyInfo(alias="includeMuted")] - """ - Include messages in chats marked as Muted by the user, which are usually less - important. Default: true. Set to false if the user wants a more refined search. - """ - - limit: int - """Maximum number of messages to return (1–500). - - Defaults to 20. The current implementation caps each page at 20 items even if a - higher limit is requested. - """ - - media_types: Annotated[List[Literal["any", "video", "image", "link", "file"]], PropertyInfo(alias="mediaTypes")] - """Filter messages by media types. - - Use ['any'] for any media type, or specify exact types like ['video', 'image']. - Omit for no media filtering. - """ - - query: str - """Literal word search (NOT semantic). - - Finds messages containing these EXACT words in any order. Use single words users - actually type, not concepts or phrases. Example: use "dinner" not "dinner - plans", use "sick" not "health issues". If omitted, returns results filtered - only by other parameters. - """ - - sender: Union[Literal["me", "others"], str] - """ - Filter by sender: 'me' (messages sent by the authenticated user), 'others' - (messages sent by others), or a specific user ID string (user.id). - """ diff --git a/src/beeper_desktop_api/types/message_send_params.py b/src/beeper_desktop_api/types/message_send_params.py deleted file mode 100644 index c0bacd7..0000000 --- a/src/beeper_desktop_api/types/message_send_params.py +++ /dev/null @@ -1,20 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, Annotated, TypedDict - -from .._utils import PropertyInfo - -__all__ = ["MessageSendParams"] - - -class MessageSendParams(TypedDict, total=False): - chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] - """Unique identifier of the chat (a.k.a. room or thread).""" - - reply_to_message_id: Annotated[str, PropertyInfo(alias="replyToMessageID")] - """Provide a message ID to send this as a reply to an existing message""" - - text: str - """Text content of the message you want to send. You may use markdown.""" diff --git a/src/beeper_desktop_api/types/message_send_response.py b/src/beeper_desktop_api/types/message_send_response.py deleted file mode 100644 index d1098af..0000000 --- a/src/beeper_desktop_api/types/message_send_response.py +++ /dev/null @@ -1,15 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from pydantic import Field as FieldInfo - -from .shared.base_response import BaseResponse - -__all__ = ["MessageSendResponse"] - - -class MessageSendResponse(BaseResponse): - chat_id: str = FieldInfo(alias="chatID") - """Unique identifier of the chat (a.k.a. room or thread).""" - - pending_message_id: str = FieldInfo(alias="pendingMessageID") - """Pending message ID""" diff --git a/src/beeper_desktop_api/types/open_response.py b/src/beeper_desktop_api/types/open_response.py deleted file mode 100644 index 970f2ba..0000000 --- a/src/beeper_desktop_api/types/open_response.py +++ /dev/null @@ -1,10 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from .._models import BaseModel - -__all__ = ["OpenResponse"] - - -class OpenResponse(BaseModel): - success: bool - """Whether the app was successfully opened/focused.""" diff --git a/src/beeper_desktop_api/types/search_response.py b/src/beeper_desktop_api/types/search_response.py deleted file mode 100644 index fe5113c..0000000 --- a/src/beeper_desktop_api/types/search_response.py +++ /dev/null @@ -1,48 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict, List, Optional - -from pydantic import Field as FieldInfo - -from .chat import Chat -from .._models import BaseModel -from .shared.message import Message - -__all__ = ["SearchResponse", "Results", "ResultsMessages"] - - -class ResultsMessages(BaseModel): - chats: Dict[str, Chat] - """Map of chatID -> chat details for chats referenced in items.""" - - has_more: bool = FieldInfo(alias="hasMore") - """True if additional results can be fetched using the provided cursors.""" - - items: List[Message] - """Messages matching the query and filters.""" - - newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", default=None) - """Cursor for fetching newer results (use with direction='after'). - - Opaque string; do not inspect. - """ - - oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) - """Cursor for fetching older results (use with direction='before'). - - Opaque string; do not inspect. - """ - - -class Results(BaseModel): - chats: List[Chat] - """Top chat results.""" - - in_groups: List[Chat] - """Top group results by participant matches.""" - - messages: ResultsMessages - - -class SearchResponse(BaseModel): - results: Results diff --git a/tests/api_resources/chats/__init__.py b/tests/api_resources/chats/__init__.py deleted file mode 100644 index fd8019a..0000000 --- a/tests/api_resources/chats/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/chats/test_reminders.py b/tests/api_resources/chats/test_reminders.py deleted file mode 100644 index d11a216..0000000 --- a/tests/api_resources/chats/test_reminders.py +++ /dev/null @@ -1,176 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from tests.utils import assert_matches_type -from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types.shared import BaseResponse - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestReminders: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @parametrize - def test_method_create(self, client: BeeperDesktop) -> None: - reminder = client.chats.reminders.create( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, - ) - assert_matches_type(BaseResponse, reminder, path=["response"]) - - @parametrize - def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: - reminder = client.chats.reminders.create( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={ - "remind_at_ms": 0, - "dismiss_on_incoming_message": True, - }, - ) - assert_matches_type(BaseResponse, reminder, path=["response"]) - - @parametrize - def test_raw_response_create(self, client: BeeperDesktop) -> None: - response = client.chats.reminders.with_raw_response.create( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - reminder = response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) - - @parametrize - def test_streaming_response_create(self, client: BeeperDesktop) -> None: - with client.chats.reminders.with_streaming_response.create( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - reminder = response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - def test_method_delete(self, client: BeeperDesktop) -> None: - reminder = client.chats.reminders.delete( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - assert_matches_type(BaseResponse, reminder, path=["response"]) - - @parametrize - def test_raw_response_delete(self, client: BeeperDesktop) -> None: - response = client.chats.reminders.with_raw_response.delete( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - reminder = response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) - - @parametrize - def test_streaming_response_delete(self, client: BeeperDesktop) -> None: - with client.chats.reminders.with_streaming_response.delete( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - reminder = response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) - - assert cast(Any, response.is_closed) is True - - -class TestAsyncReminders: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @parametrize - async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: - reminder = await async_client.chats.reminders.create( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, - ) - assert_matches_type(BaseResponse, reminder, path=["response"]) - - @parametrize - async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: - reminder = await async_client.chats.reminders.create( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={ - "remind_at_ms": 0, - "dismiss_on_incoming_message": True, - }, - ) - assert_matches_type(BaseResponse, reminder, path=["response"]) - - @parametrize - async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.chats.reminders.with_raw_response.create( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - reminder = await response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) - - @parametrize - async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.chats.reminders.with_streaming_response.create( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - reminder = await response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - async def test_method_delete(self, async_client: AsyncBeeperDesktop) -> None: - reminder = await async_client.chats.reminders.delete( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - assert_matches_type(BaseResponse, reminder, path=["response"]) - - @parametrize - async def test_raw_response_delete(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.chats.reminders.with_raw_response.delete( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - reminder = await response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) - - @parametrize - async def test_streaming_response_delete(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.chats.reminders.with_streaming_response.delete( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - reminder = await response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) - - assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_accounts.py b/tests/api_resources/test_accounts.py deleted file mode 100644 index 46ac702..0000000 --- a/tests/api_resources/test_accounts.py +++ /dev/null @@ -1,74 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from tests.utils import assert_matches_type -from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types import AccountListResponse - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestAccounts: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @parametrize - def test_method_list(self, client: BeeperDesktop) -> None: - account = client.accounts.list() - assert_matches_type(AccountListResponse, account, path=["response"]) - - @parametrize - def test_raw_response_list(self, client: BeeperDesktop) -> None: - response = client.accounts.with_raw_response.list() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - account = response.parse() - assert_matches_type(AccountListResponse, account, path=["response"]) - - @parametrize - def test_streaming_response_list(self, client: BeeperDesktop) -> None: - with client.accounts.with_streaming_response.list() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - account = response.parse() - assert_matches_type(AccountListResponse, account, path=["response"]) - - assert cast(Any, response.is_closed) is True - - -class TestAsyncAccounts: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @parametrize - async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: - account = await async_client.accounts.list() - assert_matches_type(AccountListResponse, account, path=["response"]) - - @parametrize - async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.accounts.with_raw_response.list() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - account = await response.parse() - assert_matches_type(AccountListResponse, account, path=["response"]) - - @parametrize - async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.accounts.with_streaming_response.list() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - account = await response.parse() - assert_matches_type(AccountListResponse, account, path=["response"]) - - assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py deleted file mode 100644 index 074879b..0000000 --- a/tests/api_resources/test_chats.py +++ /dev/null @@ -1,374 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from tests.utils import assert_matches_type -from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types import ( - Chat, - ChatCreateResponse, -) -from beeper_desktop_api._utils import parse_datetime -from beeper_desktop_api.pagination import SyncCursor, AsyncCursor -from beeper_desktop_api.types.shared import BaseResponse - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestChats: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @parametrize - def test_method_create(self, client: BeeperDesktop) -> None: - chat = client.chats.create( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - participant_ids=["string"], - type="single", - ) - 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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - participant_ids=["string"], - type="single", - message_text="messageText", - title="title", - ) - 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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - participant_ids=["string"], - type="single", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - chat = response.parse() - assert_matches_type(ChatCreateResponse, chat, path=["response"]) - - @parametrize - def test_streaming_response_create(self, client: BeeperDesktop) -> None: - with client.chats.with_streaming_response.create( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - participant_ids=["string"], - type="single", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - chat = response.parse() - assert_matches_type(ChatCreateResponse, chat, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - def test_method_retrieve(self, client: BeeperDesktop) -> None: - chat = client.chats.retrieve( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - assert_matches_type(Chat, chat, path=["response"]) - - @parametrize - def test_method_retrieve_with_all_params(self, client: BeeperDesktop) -> None: - chat = client.chats.retrieve( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - max_participant_count=50, - ) - assert_matches_type(Chat, chat, path=["response"]) - - @parametrize - def test_raw_response_retrieve(self, client: BeeperDesktop) -> None: - response = client.chats.with_raw_response.retrieve( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - chat = response.parse() - assert_matches_type(Chat, chat, path=["response"]) - - @parametrize - def test_streaming_response_retrieve(self, client: BeeperDesktop) -> None: - with client.chats.with_streaming_response.retrieve( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - chat = response.parse() - assert_matches_type(Chat, chat, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - def test_method_archive(self, client: BeeperDesktop) -> None: - chat = client.chats.archive( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - assert_matches_type(BaseResponse, chat, path=["response"]) - - @parametrize - def test_method_archive_with_all_params(self, client: BeeperDesktop) -> None: - chat = client.chats.archive( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - archived=True, - ) - assert_matches_type(BaseResponse, chat, path=["response"]) - - @parametrize - def test_raw_response_archive(self, client: BeeperDesktop) -> None: - response = client.chats.with_raw_response.archive( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - chat = response.parse() - assert_matches_type(BaseResponse, chat, path=["response"]) - - @parametrize - def test_streaming_response_archive(self, client: BeeperDesktop) -> None: - with client.chats.with_streaming_response.archive( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - chat = response.parse() - assert_matches_type(BaseResponse, chat, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - def test_method_search(self, client: BeeperDesktop) -> None: - chat = client.chats.search() - assert_matches_type(SyncCursor[Chat], chat, path=["response"]) - - @parametrize - def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: - chat = client.chats.search( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", - ], - cursor="eyJvZmZzZXQiOjE3MTk5OTk5OTl9", - direction="after", - inbox="primary", - include_muted=True, - last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), - last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), - limit=1, - query="x", - scope="titles", - type="single", - unread_only=True, - ) - assert_matches_type(SyncCursor[Chat], chat, path=["response"]) - - @parametrize - def test_raw_response_search(self, client: BeeperDesktop) -> None: - response = client.chats.with_raw_response.search() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - chat = response.parse() - assert_matches_type(SyncCursor[Chat], chat, path=["response"]) - - @parametrize - def test_streaming_response_search(self, client: BeeperDesktop) -> None: - with client.chats.with_streaming_response.search() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - chat = response.parse() - assert_matches_type(SyncCursor[Chat], chat, path=["response"]) - - assert cast(Any, response.is_closed) is True - - -class TestAsyncChats: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @parametrize - async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.create( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - participant_ids=["string"], - type="single", - ) - 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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - participant_ids=["string"], - type="single", - message_text="messageText", - title="title", - ) - 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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - participant_ids=["string"], - type="single", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - chat = await response.parse() - assert_matches_type(ChatCreateResponse, chat, path=["response"]) - - @parametrize - async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.chats.with_streaming_response.create( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - participant_ids=["string"], - type="single", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - chat = await response.parse() - assert_matches_type(ChatCreateResponse, chat, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - async def test_method_retrieve(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.retrieve( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - assert_matches_type(Chat, chat, path=["response"]) - - @parametrize - async def test_method_retrieve_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.retrieve( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - max_participant_count=50, - ) - assert_matches_type(Chat, chat, path=["response"]) - - @parametrize - async def test_raw_response_retrieve(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.chats.with_raw_response.retrieve( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - chat = await response.parse() - assert_matches_type(Chat, chat, path=["response"]) - - @parametrize - async def test_streaming_response_retrieve(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.chats.with_streaming_response.retrieve( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - chat = await response.parse() - assert_matches_type(Chat, chat, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - async def test_method_archive(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.archive( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - assert_matches_type(BaseResponse, chat, path=["response"]) - - @parametrize - async def test_method_archive_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.archive( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - archived=True, - ) - assert_matches_type(BaseResponse, chat, path=["response"]) - - @parametrize - async def test_raw_response_archive(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.chats.with_raw_response.archive( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - chat = await response.parse() - assert_matches_type(BaseResponse, chat, path=["response"]) - - @parametrize - async def test_streaming_response_archive(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.chats.with_streaming_response.archive( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - chat = await response.parse() - assert_matches_type(BaseResponse, chat, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.search() - assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) - - @parametrize - async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.search( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", - ], - cursor="eyJvZmZzZXQiOjE3MTk5OTk5OTl9", - direction="after", - inbox="primary", - include_muted=True, - last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), - last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), - limit=1, - query="x", - scope="titles", - type="single", - unread_only=True, - ) - assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) - - @parametrize - async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.chats.with_raw_response.search() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - chat = await response.parse() - assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) - - @parametrize - async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.chats.with_streaming_response.search() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - chat = await response.parse() - assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) - - assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_client.py b/tests/api_resources/test_client.py deleted file mode 100644 index 96ff0eb..0000000 --- a/tests/api_resources/test_client.py +++ /dev/null @@ -1,222 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from tests.utils import assert_matches_type -from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types import ( - OpenResponse, - SearchResponse, - DownloadAssetResponse, -) - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestClient: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @parametrize - def test_method_download_asset(self, client: BeeperDesktop) -> None: - client_ = client.download_asset( - url="x", - ) - assert_matches_type(DownloadAssetResponse, client_, path=["response"]) - - @parametrize - def test_raw_response_download_asset(self, client: BeeperDesktop) -> None: - response = client.with_raw_response.download_asset( - url="x", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - client_ = response.parse() - assert_matches_type(DownloadAssetResponse, client_, path=["response"]) - - @parametrize - def test_streaming_response_download_asset(self, client: BeeperDesktop) -> None: - with client.with_streaming_response.download_asset( - url="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - client_ = response.parse() - assert_matches_type(DownloadAssetResponse, client_, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - def test_method_open(self, client: BeeperDesktop) -> None: - client_ = client.open() - assert_matches_type(OpenResponse, client_, path=["response"]) - - @parametrize - def test_method_open_with_all_params(self, client: BeeperDesktop) -> None: - client_ = client.open( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - draft_attachment_path="draftAttachmentPath", - draft_text="draftText", - message_id="messageID", - ) - assert_matches_type(OpenResponse, client_, path=["response"]) - - @parametrize - def test_raw_response_open(self, client: BeeperDesktop) -> None: - response = client.with_raw_response.open() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - client_ = response.parse() - assert_matches_type(OpenResponse, client_, path=["response"]) - - @parametrize - def test_streaming_response_open(self, client: BeeperDesktop) -> None: - with client.with_streaming_response.open() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - client_ = response.parse() - assert_matches_type(OpenResponse, client_, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - def test_method_search(self, client: BeeperDesktop) -> None: - client_ = client.search( - query="x", - ) - assert_matches_type(SearchResponse, client_, path=["response"]) - - @parametrize - def test_raw_response_search(self, client: BeeperDesktop) -> None: - response = client.with_raw_response.search( - query="x", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - client_ = response.parse() - assert_matches_type(SearchResponse, client_, path=["response"]) - - @parametrize - def test_streaming_response_search(self, client: BeeperDesktop) -> None: - with client.with_streaming_response.search( - query="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - client_ = response.parse() - assert_matches_type(SearchResponse, client_, path=["response"]) - - assert cast(Any, response.is_closed) is True - - -class TestAsyncClient: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @parametrize - async def test_method_download_asset(self, async_client: AsyncBeeperDesktop) -> None: - client = await async_client.download_asset( - url="x", - ) - assert_matches_type(DownloadAssetResponse, client, path=["response"]) - - @parametrize - async def test_raw_response_download_asset(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.with_raw_response.download_asset( - url="x", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - client = await response.parse() - assert_matches_type(DownloadAssetResponse, client, path=["response"]) - - @parametrize - async def test_streaming_response_download_asset(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.with_streaming_response.download_asset( - url="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - client = await response.parse() - assert_matches_type(DownloadAssetResponse, client, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - async def test_method_open(self, async_client: AsyncBeeperDesktop) -> None: - client = await async_client.open() - assert_matches_type(OpenResponse, client, path=["response"]) - - @parametrize - async def test_method_open_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: - client = await async_client.open( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - draft_attachment_path="draftAttachmentPath", - draft_text="draftText", - message_id="messageID", - ) - assert_matches_type(OpenResponse, client, path=["response"]) - - @parametrize - async def test_raw_response_open(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.with_raw_response.open() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - client = await response.parse() - assert_matches_type(OpenResponse, client, path=["response"]) - - @parametrize - async def test_streaming_response_open(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.with_streaming_response.open() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - client = await response.parse() - assert_matches_type(OpenResponse, client, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: - client = await async_client.search( - query="x", - ) - assert_matches_type(SearchResponse, client, path=["response"]) - - @parametrize - async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.with_raw_response.search( - query="x", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - client = await response.parse() - assert_matches_type(SearchResponse, client, path=["response"]) - - @parametrize - async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.with_streaming_response.search( - query="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - client = await response.parse() - assert_matches_type(SearchResponse, client, path=["response"]) - - assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_contacts.py b/tests/api_resources/test_contacts.py deleted file mode 100644 index 6308d1f..0000000 --- a/tests/api_resources/test_contacts.py +++ /dev/null @@ -1,92 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from tests.utils import assert_matches_type -from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types import ContactSearchResponse - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestContacts: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @parametrize - def test_method_search(self, client: BeeperDesktop) -> None: - contact = client.contacts.search( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) - assert_matches_type(ContactSearchResponse, contact, path=["response"]) - - @parametrize - def test_raw_response_search(self, client: BeeperDesktop) -> None: - response = client.contacts.with_raw_response.search( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - contact = response.parse() - assert_matches_type(ContactSearchResponse, contact, path=["response"]) - - @parametrize - def test_streaming_response_search(self, client: BeeperDesktop) -> None: - with client.contacts.with_streaming_response.search( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - contact = response.parse() - assert_matches_type(ContactSearchResponse, contact, path=["response"]) - - assert cast(Any, response.is_closed) is True - - -class TestAsyncContacts: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @parametrize - async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: - contact = await async_client.contacts.search( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) - assert_matches_type(ContactSearchResponse, contact, path=["response"]) - - @parametrize - async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.contacts.with_raw_response.search( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - contact = await response.parse() - assert_matches_type(ContactSearchResponse, contact, path=["response"]) - - @parametrize - async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.contacts.with_streaming_response.search( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - contact = await response.parse() - assert_matches_type(ContactSearchResponse, contact, path=["response"]) - - assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py deleted file mode 100644 index 594f1dc..0000000 --- a/tests/api_resources/test_messages.py +++ /dev/null @@ -1,201 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from tests.utils import assert_matches_type -from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types import MessageSendResponse -from beeper_desktop_api._utils import parse_datetime -from beeper_desktop_api.pagination import SyncCursor, AsyncCursor -from beeper_desktop_api.types.shared import Message - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestMessages: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @parametrize - def test_method_search(self, client: BeeperDesktop) -> None: - message = client.messages.search() - assert_matches_type(SyncCursor[Message], message, path=["response"]) - - @parametrize - def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: - message = client.messages.search( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", - ], - chat_ids=["!NCdzlIaMjZUmvmvyHU:beeper.com", "1231073"], - chat_type="group", - cursor="1725489123456|c29tZUltc2dQYWdl", - date_after=parse_datetime("2025-08-01T00:00:00Z"), - date_before=parse_datetime("2025-08-31T23:59:59Z"), - direction="before", - exclude_low_priority=True, - include_muted=True, - limit=20, - media_types=["any"], - query="dinner", - sender="me", - ) - assert_matches_type(SyncCursor[Message], message, path=["response"]) - - @parametrize - def test_raw_response_search(self, client: BeeperDesktop) -> None: - response = client.messages.with_raw_response.search() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - message = response.parse() - assert_matches_type(SyncCursor[Message], message, path=["response"]) - - @parametrize - def test_streaming_response_search(self, client: BeeperDesktop) -> None: - with client.messages.with_streaming_response.search() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - message = response.parse() - assert_matches_type(SyncCursor[Message], message, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - def test_method_send(self, client: BeeperDesktop) -> None: - message = client.messages.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - assert_matches_type(MessageSendResponse, message, path=["response"]) - - @parametrize - def test_method_send_with_all_params(self, client: BeeperDesktop) -> None: - message = client.messages.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reply_to_message_id="replyToMessageID", - text="text", - ) - assert_matches_type(MessageSendResponse, message, path=["response"]) - - @parametrize - def test_raw_response_send(self, client: BeeperDesktop) -> None: - response = client.messages.with_raw_response.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - message = response.parse() - assert_matches_type(MessageSendResponse, message, path=["response"]) - - @parametrize - def test_streaming_response_send(self, client: BeeperDesktop) -> None: - with client.messages.with_streaming_response.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - message = response.parse() - assert_matches_type(MessageSendResponse, message, path=["response"]) - - assert cast(Any, response.is_closed) is True - - -class TestAsyncMessages: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @parametrize - async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: - message = await async_client.messages.search() - assert_matches_type(AsyncCursor[Message], message, path=["response"]) - - @parametrize - async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: - message = await async_client.messages.search( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", - ], - chat_ids=["!NCdzlIaMjZUmvmvyHU:beeper.com", "1231073"], - chat_type="group", - cursor="1725489123456|c29tZUltc2dQYWdl", - date_after=parse_datetime("2025-08-01T00:00:00Z"), - date_before=parse_datetime("2025-08-31T23:59:59Z"), - direction="before", - exclude_low_priority=True, - include_muted=True, - limit=20, - media_types=["any"], - query="dinner", - sender="me", - ) - assert_matches_type(AsyncCursor[Message], message, path=["response"]) - - @parametrize - async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.messages.with_raw_response.search() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - message = await response.parse() - assert_matches_type(AsyncCursor[Message], message, path=["response"]) - - @parametrize - async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.messages.with_streaming_response.search() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - message = await response.parse() - assert_matches_type(AsyncCursor[Message], message, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - async def test_method_send(self, async_client: AsyncBeeperDesktop) -> None: - message = await async_client.messages.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - assert_matches_type(MessageSendResponse, message, path=["response"]) - - @parametrize - async def test_method_send_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: - message = await async_client.messages.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reply_to_message_id="replyToMessageID", - text="text", - ) - assert_matches_type(MessageSendResponse, message, path=["response"]) - - @parametrize - async def test_raw_response_send(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.messages.with_raw_response.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - message = await response.parse() - assert_matches_type(MessageSendResponse, message, path=["response"]) - - @parametrize - async def test_streaming_response_send(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.messages.with_streaming_response.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - message = await response.parse() - assert_matches_type(MessageSendResponse, message, path=["response"]) - - assert cast(Any, response.is_closed) is True diff --git a/tests/test_client.py b/tests/test_client.py index 9ac5182..1450af1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -747,20 +747,20 @@ def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: - respx_mock.get("/v0/get-accounts").mock(side_effect=httpx.TimeoutException("Test timeout error")) + respx_mock.get("/oauth/userinfo").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - client.accounts.with_streaming_response.list().__enter__() + client.token.with_streaming_response.info().__enter__() assert _get_open_connections(self.client) == 0 @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: - respx_mock.get("/v0/get-accounts").mock(return_value=httpx.Response(500)) + respx_mock.get("/oauth/userinfo").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - client.accounts.with_streaming_response.list().__enter__() + client.token.with_streaming_response.info().__enter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -787,9 +787,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500) return httpx.Response(200) - respx_mock.get("/v0/get-accounts").mock(side_effect=retry_handler) + respx_mock.get("/oauth/userinfo").mock(side_effect=retry_handler) - response = client.accounts.with_raw_response.list() + response = client.token.with_raw_response.info() assert response.retries_taken == failures_before_success assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success @@ -811,9 +811,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500) return httpx.Response(200) - respx_mock.get("/v0/get-accounts").mock(side_effect=retry_handler) + respx_mock.get("/oauth/userinfo").mock(side_effect=retry_handler) - response = client.accounts.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()}) + response = client.token.with_raw_response.info(extra_headers={"x-stainless-retry-count": Omit()}) assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 @@ -834,9 +834,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500) return httpx.Response(200) - respx_mock.get("/v0/get-accounts").mock(side_effect=retry_handler) + respx_mock.get("/oauth/userinfo").mock(side_effect=retry_handler) - response = client.accounts.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"}) + response = client.token.with_raw_response.info(extra_headers={"x-stainless-retry-count": "42"}) assert response.http_request.headers.get("x-stainless-retry-count") == "42" @@ -1584,10 +1584,10 @@ async def test_parse_retry_after_header(self, remaining_retries: int, retry_afte async def test_retrying_timeout_errors_doesnt_leak( self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop ) -> None: - respx_mock.get("/v0/get-accounts").mock(side_effect=httpx.TimeoutException("Test timeout error")) + respx_mock.get("/oauth/userinfo").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - await async_client.accounts.with_streaming_response.list().__aenter__() + await async_client.token.with_streaming_response.info().__aenter__() assert _get_open_connections(self.client) == 0 @@ -1596,10 +1596,10 @@ async def test_retrying_timeout_errors_doesnt_leak( async def test_retrying_status_errors_doesnt_leak( self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop ) -> None: - respx_mock.get("/v0/get-accounts").mock(return_value=httpx.Response(500)) + respx_mock.get("/oauth/userinfo").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - await async_client.accounts.with_streaming_response.list().__aenter__() + await async_client.token.with_streaming_response.info().__aenter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -1627,9 +1627,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500) return httpx.Response(200) - respx_mock.get("/v0/get-accounts").mock(side_effect=retry_handler) + respx_mock.get("/oauth/userinfo").mock(side_effect=retry_handler) - response = await client.accounts.with_raw_response.list() + response = await client.token.with_raw_response.info() assert response.retries_taken == failures_before_success assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success @@ -1652,9 +1652,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500) return httpx.Response(200) - respx_mock.get("/v0/get-accounts").mock(side_effect=retry_handler) + respx_mock.get("/oauth/userinfo").mock(side_effect=retry_handler) - response = await client.accounts.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()}) + response = await client.token.with_raw_response.info(extra_headers={"x-stainless-retry-count": Omit()}) assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 @@ -1676,9 +1676,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500) return httpx.Response(200) - respx_mock.get("/v0/get-accounts").mock(side_effect=retry_handler) + respx_mock.get("/oauth/userinfo").mock(side_effect=retry_handler) - response = await client.accounts.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"}) + response = await client.token.with_raw_response.info(extra_headers={"x-stainless-retry-count": "42"}) assert response.http_request.headers.get("x-stainless-retry-count") == "42" From 72749f6a8946ce33aeaf988792e6f96681067dce Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:58:21 +0000 Subject: [PATCH 04/98] feat(api): manual updates --- .github/workflows/publish-pypi.yml | 31 +++++++++++ .github/workflows/release-doctor.yml | 21 ++++++++ .release-please-manifest.json | 3 ++ .stats.yml | 2 +- CONTRIBUTING.md | 4 +- README.md | 14 ++--- bin/check-release-environment | 21 ++++++++ pyproject.toml | 6 +-- release-please-config.json | 66 +++++++++++++++++++++++ src/beeper_desktop_api/_version.py | 2 +- src/beeper_desktop_api/resources/token.py | 8 +-- 11 files changed, 160 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/publish-pypi.yml create mode 100644 .github/workflows/release-doctor.yml create mode 100644 .release-please-manifest.json create mode 100644 bin/check-release-environment create mode 100644 release-please-config.json diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..c22364e --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,31 @@ +# This workflow is triggered when a GitHub release is created. +# It can also be run manually to re-publish to PyPI in case it failed for some reason. +# You can run this workflow by navigating to https://www.github.com/beeper/desktop-api-python/actions/workflows/publish-pypi.yml +name: Publish PyPI +on: + workflow_dispatch: + + release: + types: [published] + +jobs: + publish: + name: publish + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - 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: Publish to PyPI + run: | + bash ./bin/publish-pypi + env: + PYPI_TOKEN: ${{ secrets.BEEPER_DESKTOP_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml new file mode 100644 index 0000000..c6b3e44 --- /dev/null +++ b/.github/workflows/release-doctor.yml @@ -0,0 +1,21 @@ +name: Release Doctor +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + release_doctor: + name: release doctor + runs-on: ubuntu-latest + if: github.repository == 'beeper/desktop-api-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') + + steps: + - uses: actions/checkout@v4 + + - name: Check release environment + run: | + bash ./bin/check-release-environment + env: + PYPI_TOKEN: ${{ secrets.BEEPER_DESKTOP_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..1332969 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.1" +} \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index abfa216..ab027c2 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 1 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-8c712fe19f280b0b89ecc8a3ce61e9f6b165cee97ce33f66c66a7a5db339c755.yml openapi_spec_hash: 1ea71129cc1a1ccc3dc8a99566082311 -config_hash: def03aa92de3408ec65438763617f5c7 +config_hash: f83b2b6eb86f2dd68101065998479cb2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7899f17..81bc94a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,7 @@ If you’d like to use the repository from source, you can either install from g To install via git: ```sh -$ pip install git+ssh://git@github.com/stainless-sdks/beeper-desktop-api-python.git +$ pip install git+ssh://git@github.com/beeper/desktop-api-python.git ``` Alternatively, you can build from source and install the wheel file: @@ -120,7 +120,7 @@ the changes aren't made through the automated pipeline, you may want to make rel ### Publish with a GitHub workflow -You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/stainless-sdks/beeper-desktop-api-python/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. +You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/beeper/desktop-api-python/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. ### Publish manually diff --git a/README.md b/README.md index c634600..51fb670 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ The REST API documentation can be found on [developers.beeper.com](https://devel ## Installation ```sh -# install from this staging repo -pip install git+ssh://git@github.com/stainless-sdks/beeper-desktop-api-python.git +# install from the production repo +pip install git+ssh://git@github.com/beeper/desktop-api-python.git ``` > [!NOTE] @@ -73,8 +73,8 @@ By default, the async client uses `httpx` for HTTP requests. However, for improv You can enable this by installing `aiohttp`: ```sh -# install from this staging repo -pip install 'beeper_desktop_api[aiohttp] @ git+ssh://git@github.com/stainless-sdks/beeper-desktop-api-python.git' +# 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()`: @@ -236,9 +236,9 @@ token = response.parse() # get the object that `token.info()` would have return print(token.sub) ``` -These methods return an [`APIResponse`](https://github.com/stainless-sdks/beeper-desktop-api-python/tree/main/src/beeper_desktop_api/_response.py) object. +These methods return an [`APIResponse`](https://github.com/beeper/desktop-api-python/tree/main/src/beeper_desktop_api/_response.py) object. -The async client returns an [`AsyncAPIResponse`](https://github.com/stainless-sdks/beeper-desktop-api-python/tree/main/src/beeper_desktop_api/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. +The async client returns an [`AsyncAPIResponse`](https://github.com/beeper/desktop-api-python/tree/main/src/beeper_desktop_api/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. #### `.with_streaming_response` @@ -342,7 +342,7 @@ This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) con We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. -We are keen for your feedback; please open an [issue](https://www.github.com/stainless-sdks/beeper-desktop-api-python/issues) with questions, bugs, or suggestions. +We are keen for your feedback; please open an [issue](https://www.github.com/beeper/desktop-api-python/issues) with questions, bugs, or suggestions. ### Determining the installed version diff --git a/bin/check-release-environment b/bin/check-release-environment new file mode 100644 index 0000000..b845b0f --- /dev/null +++ b/bin/check-release-environment @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +errors=() + +if [ -z "${PYPI_TOKEN}" ]; then + errors+=("The PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") +fi + +lenErrors=${#errors[@]} + +if [[ lenErrors -gt 0 ]]; then + echo -e "Found the following errors in the release environment:\n" + + for error in "${errors[@]}"; do + echo -e "- $error\n" + done + + exit 1 +fi + +echo "The environment is ready to push releases!" diff --git a/pyproject.toml b/pyproject.toml index b90ac16..d3a4a85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,8 +35,8 @@ classifiers = [ ] [project.urls] -Homepage = "https://github.com/stainless-sdks/beeper-desktop-api-python" -Repository = "https://github.com/stainless-sdks/beeper-desktop-api-python" +Homepage = "https://github.com/beeper/desktop-api-python" +Repository = "https://github.com/beeper/desktop-api-python" [project.optional-dependencies] aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] @@ -124,7 +124,7 @@ path = "README.md" [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] # replace relative links with absolute links pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' -replacement = '[\1](https://github.com/stainless-sdks/beeper-desktop-api-python/tree/main/\g<2>)' +replacement = '[\1](https://github.com/beeper/desktop-api-python/tree/main/\g<2>)' [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..fd672c1 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,66 @@ +{ + "packages": { + ".": {} + }, + "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", + "include-v-in-tag": true, + "include-component-in-tag": false, + "versioning": "prerelease", + "prerelease": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "pull-request-header": "Automated Release PR", + "pull-request-title-pattern": "release: ${version}", + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "revert", + "section": "Reverts" + }, + { + "type": "chore", + "section": "Chores" + }, + { + "type": "docs", + "section": "Documentation" + }, + { + "type": "style", + "section": "Styles" + }, + { + "type": "refactor", + "section": "Refactors" + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "build", + "section": "Build System" + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + } + ], + "release-type": "python", + "extra-files": [ + "src/beeper_desktop_api/_version.py" + ] +} \ No newline at end of file diff --git a/src/beeper_desktop_api/_version.py b/src/beeper_desktop_api/_version.py index 72a9009..3ba6273 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__ = "0.0.1" +__version__ = "0.0.1" # x-release-please-version diff --git a/src/beeper_desktop_api/resources/token.py b/src/beeper_desktop_api/resources/token.py index fbf0425..5648872 100644 --- a/src/beeper_desktop_api/resources/token.py +++ b/src/beeper_desktop_api/resources/token.py @@ -28,7 +28,7 @@ def with_raw_response(self) -> TokenResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers """ return TokenResourceWithRawResponse(self) @@ -37,7 +37,7 @@ def with_streaming_response(self) -> TokenResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response """ return TokenResourceWithStreamingResponse(self) @@ -70,7 +70,7 @@ def with_raw_response(self) -> AsyncTokenResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers """ return AsyncTokenResourceWithRawResponse(self) @@ -79,7 +79,7 @@ def with_streaming_response(self) -> AsyncTokenResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/beeper-desktop-api-python#with_streaming_response + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response """ return AsyncTokenResourceWithStreamingResponse(self) From 3b6387f4dd2dc45834edf9355148cd3f2d39451f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:58:48 +0000 Subject: [PATCH 05/98] feat(api): manual updates --- .stats.yml | 6 +- README.md | 136 +++- api.md | 61 +- src/beeper_desktop_api/_client.py | 351 ++++++++- src/beeper_desktop_api/resources/__init__.py | 56 ++ src/beeper_desktop_api/resources/accounts.py | 145 ++++ .../resources/chats/__init__.py | 33 + .../resources/chats/chats.py | 668 ++++++++++++++++++ .../resources/chats/reminders.py | 267 +++++++ src/beeper_desktop_api/resources/contacts.py | 197 ++++++ src/beeper_desktop_api/resources/messages.py | 423 +++++++++++ src/beeper_desktop_api/types/__init__.py | 19 + src/beeper_desktop_api/types/account.py | 19 + .../types/account_list_response.py | 10 + src/beeper_desktop_api/types/chat.py | 67 ++ .../types/chat_archive_params.py | 12 + .../types/chat_create_params.py | 30 + .../types/chat_create_response.py | 14 + .../types/chat_retrieve_params.py | 18 + .../types/chat_search_params.py | 81 +++ .../types/chats/__init__.py | 2 + .../types/chats/reminder_create_params.py | 22 + .../types/client_download_asset_params.py | 12 + .../types/client_open_params.py | 26 + .../types/client_search_params.py | 12 + .../types/contact_search_params.py | 17 + .../types/contact_search_response.py | 12 + .../types/download_asset_response.py | 17 + .../types/message_search_params.py | 85 +++ .../types/message_send_params.py | 20 + .../types/message_send_response.py | 15 + src/beeper_desktop_api/types/open_response.py | 10 + .../types/search_response.py | 48 ++ src/beeper_desktop_api/types/shared/error.py | 12 +- tests/api_resources/chats/__init__.py | 1 + tests/api_resources/chats/test_reminders.py | 206 ++++++ tests/api_resources/test_accounts.py | 74 ++ tests/api_resources/test_chats.py | 402 +++++++++++ tests/api_resources/test_client.py | 222 ++++++ tests/api_resources/test_contacts.py | 92 +++ tests/api_resources/test_messages.py | 203 ++++++ tests/test_client.py | 40 +- 42 files changed, 4113 insertions(+), 50 deletions(-) create mode 100644 src/beeper_desktop_api/resources/accounts.py create mode 100644 src/beeper_desktop_api/resources/chats/__init__.py create mode 100644 src/beeper_desktop_api/resources/chats/chats.py create mode 100644 src/beeper_desktop_api/resources/chats/reminders.py create mode 100644 src/beeper_desktop_api/resources/contacts.py create mode 100644 src/beeper_desktop_api/resources/messages.py create mode 100644 src/beeper_desktop_api/types/account.py create mode 100644 src/beeper_desktop_api/types/account_list_response.py create mode 100644 src/beeper_desktop_api/types/chat.py create mode 100644 src/beeper_desktop_api/types/chat_archive_params.py create mode 100644 src/beeper_desktop_api/types/chat_create_params.py create mode 100644 src/beeper_desktop_api/types/chat_create_response.py create mode 100644 src/beeper_desktop_api/types/chat_retrieve_params.py create mode 100644 src/beeper_desktop_api/types/chat_search_params.py create mode 100644 src/beeper_desktop_api/types/chats/reminder_create_params.py create mode 100644 src/beeper_desktop_api/types/client_download_asset_params.py create mode 100644 src/beeper_desktop_api/types/client_open_params.py create mode 100644 src/beeper_desktop_api/types/client_search_params.py create mode 100644 src/beeper_desktop_api/types/contact_search_params.py create mode 100644 src/beeper_desktop_api/types/contact_search_response.py create mode 100644 src/beeper_desktop_api/types/download_asset_response.py create mode 100644 src/beeper_desktop_api/types/message_search_params.py create mode 100644 src/beeper_desktop_api/types/message_send_params.py create mode 100644 src/beeper_desktop_api/types/message_send_response.py create mode 100644 src/beeper_desktop_api/types/open_response.py create mode 100644 src/beeper_desktop_api/types/search_response.py create mode 100644 tests/api_resources/chats/__init__.py create mode 100644 tests/api_resources/chats/test_reminders.py create mode 100644 tests/api_resources/test_accounts.py create mode 100644 tests/api_resources/test_chats.py create mode 100644 tests/api_resources/test_client.py create mode 100644 tests/api_resources/test_contacts.py create mode 100644 tests/api_resources/test_messages.py diff --git a/.stats.yml b/.stats.yml index ab027c2..8526f3e 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 1 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-8c712fe19f280b0b89ecc8a3ce61e9f6b165cee97ce33f66c66a7a5db339c755.yml -openapi_spec_hash: 1ea71129cc1a1ccc3dc8a99566082311 +configured_endpoints: 14 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-9f5190d7df873112f3512b5796cd95341f0fa0d2585488d3e829be80ee6045ce.yml +openapi_spec_hash: ba834200758376aaea47b2a276f64c1b config_hash: f83b2b6eb86f2dd68101065998479cb2 diff --git a/README.md b/README.md index 51fb670..9f0e7ba 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,12 @@ client = BeeperDesktop( access_token=os.environ.get("BEEPER_ACCESS_TOKEN"), # This is the default and can be omitted ) -user_info = client.token.info() -print(user_info.sub) +page = client.chats.search( + include_muted=True, + limit=3, + type="single", +) +print(page.items) ``` While you can provide a `access_token` keyword argument, @@ -57,8 +61,12 @@ client = AsyncBeeperDesktop( async def main() -> None: - user_info = await client.token.info() - print(user_info.sub) + page = await client.chats.search( + include_muted=True, + limit=3, + type="single", + ) + print(page.items) asyncio.run(main()) @@ -90,8 +98,12 @@ async def main() -> None: access_token="My Access Token", http_client=DefaultAioHttpClient(), ) as client: - user_info = await client.token.info() - print(user_info.sub) + page = await client.chats.search( + include_muted=True, + limit=3, + type="single", + ) + print(page.items) asyncio.run(main()) @@ -106,6 +118,101 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. +## Pagination + +List methods in the Beeper Desktop API are paginated. + +This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually: + +```python +from beeper_desktop_api import BeeperDesktop + +client = BeeperDesktop() + +all_messages = [] +# Automatically fetches more pages as needed. +for message in client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", +): + # Do something with message here + all_messages.append(message) +print(all_messages) +``` + +Or, asynchronously: + +```python +import asyncio +from beeper_desktop_api import AsyncBeeperDesktop + +client = AsyncBeeperDesktop() + + +async def main() -> None: + all_messages = [] + # Iterate through items across all pages, issuing requests as needed. + async for message in client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", + ): + all_messages.append(message) + print(all_messages) + + +asyncio.run(main()) +``` + +Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages: + +```python +first_page = await client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", +) +if first_page.has_next_page(): + print(f"will fetch next page using these details: {first_page.next_page_info()}") + next_page = await first_page.get_next_page() + print(f"number of items we just fetched: {len(next_page.items)}") + +# Remove `await` for non-async usage. +``` + +Or just work directly with the returned data: + +```python +first_page = await client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", +) + +print(f"next page cursor: {first_page.oldest_cursor}") # => "next page cursor: ..." +for message in first_page.items: + print(message.id) + +# Remove `await` for non-async usage. +``` + +## Nested params + +Nested parameters are dictionaries, typed using `TypedDict`, for example: + +```python +from beeper_desktop_api import BeeperDesktop + +client = BeeperDesktop() + +base_response = client.chats.reminders.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, +) +print(base_response.reminder) +``` + ## Handling errors When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `beeper_desktop_api.APIConnectionError` is raised. @@ -122,7 +229,10 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() try: - client.token.info() + client.messages.send( + chat_id="1229391", + text="Hello! Just checking in on the project status.", + ) except beeper_desktop_api.APIConnectionError as e: print("The server could not be reached") print(e.__cause__) # an underlying Exception, likely raised within httpx. @@ -165,7 +275,7 @@ client = BeeperDesktop( ) # Or, configure per-request: -client.with_options(max_retries=5).token.info() +client.with_options(max_retries=5).accounts.list() ``` ### Timeouts @@ -188,7 +298,7 @@ client = BeeperDesktop( ) # Override per-request: -client.with_options(timeout=5.0).token.info() +client.with_options(timeout=5.0).accounts.list() ``` On timeout, an `APITimeoutError` is thrown. @@ -229,11 +339,11 @@ The "raw" Response object can be accessed by prefixing `.with_raw_response.` to from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() -response = client.token.with_raw_response.info() +response = client.accounts.with_raw_response.list() print(response.headers.get('X-My-Header')) -token = response.parse() # get the object that `token.info()` would have returned -print(token.sub) +account = response.parse() # get the object that `accounts.list()` would have returned +print(account) ``` These methods return an [`APIResponse`](https://github.com/beeper/desktop-api-python/tree/main/src/beeper_desktop_api/_response.py) object. @@ -247,7 +357,7 @@ The above interface eagerly reads the full response body when you make the reque To stream the response body, use `.with_streaming_response` instead, which requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. In the async client, these are async methods. ```python -with client.token.with_streaming_response.info() as response: +with client.accounts.with_streaming_response.list() as response: print(response.headers.get("X-My-Header")) for line in response.iter_lines(): diff --git a/api.md b/api.md index 83f0189..cfe2dc3 100644 --- a/api.md +++ b/api.md @@ -4,22 +4,79 @@ from beeper_desktop_api.types import Attachment, BaseResponse, Error, Message, Reaction, User ``` +# BeeperDesktop + +Types: + +```python +from beeper_desktop_api.types import DownloadAssetResponse, OpenResponse, SearchResponse +``` + +Methods: + +- client.download_asset(\*\*params) -> DownloadAssetResponse +- client.open(\*\*params) -> OpenResponse +- client.search(\*\*params) -> SearchResponse + # Accounts Types: ```python -from beeper_desktop_api.types import Account +from beeper_desktop_api.types import Account, AccountListResponse ``` +Methods: + +- client.accounts.list() -> AccountListResponse + +# Contacts + +Types: + +```python +from beeper_desktop_api.types import ContactSearchResponse +``` + +Methods: + +- client.contacts.search(\*\*params) -> ContactSearchResponse + # Chats Types: ```python -from beeper_desktop_api.types import Chat +from beeper_desktop_api.types import Chat, ChatCreateResponse +``` + +Methods: + +- client.chats.create(\*\*params) -> ChatCreateResponse +- client.chats.retrieve(chat_id, \*\*params) -> Chat +- client.chats.archive(chat_id, \*\*params) -> BaseResponse +- client.chats.search(\*\*params) -> SyncCursor[Chat] + +## Reminders + +Methods: + +- client.chats.reminders.create(chat_id, \*\*params) -> BaseResponse +- client.chats.reminders.delete(chat_id) -> BaseResponse + +# Messages + +Types: + +```python +from beeper_desktop_api.types import MessageSendResponse ``` +Methods: + +- client.messages.search(\*\*params) -> SyncCursor[Message] +- client.messages.send(\*\*params) -> MessageSendResponse + # Token Types: diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index 44866d0..7a1a10e 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -10,25 +10,46 @@ from . import _exceptions from ._qs import Querystring +from .types import client_open_params, client_search_params, client_download_asset_params from ._types import ( + Body, Omit, + Query, + Headers, Timeout, NotGiven, Transport, ProxiesTypes, RequestOptions, + omit, not_given, ) -from ._utils import is_given, get_async_library +from ._utils import ( + is_given, + maybe_transform, + get_async_library, + async_maybe_transform, +) from ._version import __version__ -from .resources import token +from ._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .resources import token, accounts, contacts, messages from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError, BeeperDesktopError from ._base_client import ( DEFAULT_MAX_RETRIES, SyncAPIClient, AsyncAPIClient, + make_request_options, ) +from .resources.chats import chats +from .types.open_response import OpenResponse +from .types.search_response import SearchResponse +from .types.download_asset_response import DownloadAssetResponse __all__ = [ "Timeout", @@ -43,6 +64,10 @@ class BeeperDesktop(SyncAPIClient): + accounts: accounts.AccountsResource + contacts: contacts.ContactsResource + chats: chats.ChatsResource + messages: messages.MessagesResource token: token.TokenResource with_raw_response: BeeperDesktopWithRawResponse with_streaming_response: BeeperDesktopWithStreamedResponse @@ -101,6 +126,10 @@ def __init__( _strict_response_validation=_strict_response_validation, ) + self.accounts = accounts.AccountsResource(self) + self.contacts = contacts.ContactsResource(self) + self.chats = chats.ChatsResource(self) + self.messages = messages.MessagesResource(self) self.token = token.TokenResource(self) self.with_raw_response = BeeperDesktopWithRawResponse(self) self.with_streaming_response = BeeperDesktopWithStreamedResponse(self) @@ -176,6 +205,133 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy + def download_asset( + self, + *, + url: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DownloadAssetResponse: + """ + Download a Matrix asset using its mxc:// or localmxc:// URL and return the local + file URL. + + Args: + url: Matrix content URL (mxc:// or localmxc://) for the asset to download. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self.post( + "/v1/app/download-asset", + body=maybe_transform({"url": url}, client_download_asset_params.ClientDownloadAssetParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DownloadAssetResponse, + ) + + def open( + self, + *, + chat_id: str | Omit = omit, + draft_attachment_path: str | Omit = omit, + draft_text: str | Omit = omit, + message_id: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OpenResponse: + """ + Open Beeper Desktop and optionally navigate to a specific chat, message, or + pre-fill draft text and attachment. + + Args: + chat_id: Optional Beeper chat ID (or local chat ID) to focus after opening the app. If + omitted, only opens/focuses the app. + + draft_attachment_path: Optional draft attachment path to populate in the message input field. + + draft_text: Optional draft text to populate in the message input field. + + message_id: Optional message ID. Jumps to that message in the chat when opening. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self.post( + "/v1/app/open", + body=maybe_transform( + { + "chat_id": chat_id, + "draft_attachment_path": draft_attachment_path, + "draft_text": draft_text, + "message_id": message_id, + }, + client_open_params.ClientOpenParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=OpenResponse, + ) + + def search( + self, + *, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SearchResponse: + """ + Returns matching chats, participant name matches in groups, and the first page + of messages in one call. Paginate messages via search-messages. Paginate chats + via search-chats. Uses the same sorting as the chat search in the app. + + Args: + query: User-typed search text. Literal word matching (NOT semantic). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self.get( + "/v1/search", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"query": query}, client_search_params.ClientSearchParams), + ), + cast_to=SearchResponse, + ) + @override def _make_status_error( self, @@ -211,6 +367,10 @@ def _make_status_error( class AsyncBeeperDesktop(AsyncAPIClient): + accounts: accounts.AsyncAccountsResource + contacts: contacts.AsyncContactsResource + chats: chats.AsyncChatsResource + messages: messages.AsyncMessagesResource token: token.AsyncTokenResource with_raw_response: AsyncBeeperDesktopWithRawResponse with_streaming_response: AsyncBeeperDesktopWithStreamedResponse @@ -269,6 +429,10 @@ def __init__( _strict_response_validation=_strict_response_validation, ) + self.accounts = accounts.AsyncAccountsResource(self) + self.contacts = contacts.AsyncContactsResource(self) + self.chats = chats.AsyncChatsResource(self) + self.messages = messages.AsyncMessagesResource(self) self.token = token.AsyncTokenResource(self) self.with_raw_response = AsyncBeeperDesktopWithRawResponse(self) self.with_streaming_response = AsyncBeeperDesktopWithStreamedResponse(self) @@ -344,6 +508,133 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy + async def download_asset( + self, + *, + url: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DownloadAssetResponse: + """ + Download a Matrix asset using its mxc:// or localmxc:// URL and return the local + file URL. + + Args: + url: Matrix content URL (mxc:// or localmxc://) for the asset to download. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self.post( + "/v1/app/download-asset", + body=await async_maybe_transform({"url": url}, client_download_asset_params.ClientDownloadAssetParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DownloadAssetResponse, + ) + + async def open( + self, + *, + chat_id: str | Omit = omit, + draft_attachment_path: str | Omit = omit, + draft_text: str | Omit = omit, + message_id: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> OpenResponse: + """ + Open Beeper Desktop and optionally navigate to a specific chat, message, or + pre-fill draft text and attachment. + + Args: + chat_id: Optional Beeper chat ID (or local chat ID) to focus after opening the app. If + omitted, only opens/focuses the app. + + draft_attachment_path: Optional draft attachment path to populate in the message input field. + + draft_text: Optional draft text to populate in the message input field. + + message_id: Optional message ID. Jumps to that message in the chat when opening. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self.post( + "/v1/app/open", + body=await async_maybe_transform( + { + "chat_id": chat_id, + "draft_attachment_path": draft_attachment_path, + "draft_text": draft_text, + "message_id": message_id, + }, + client_open_params.ClientOpenParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=OpenResponse, + ) + + async def search( + self, + *, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SearchResponse: + """ + Returns matching chats, participant name matches in groups, and the first page + of messages in one call. Paginate messages via search-messages. Paginate chats + via search-chats. Uses the same sorting as the chat search in the app. + + Args: + query: User-typed search text. Literal word matching (NOT semantic). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self.get( + "/v1/search", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"query": query}, client_search_params.ClientSearchParams), + ), + cast_to=SearchResponse, + ) + @override def _make_status_error( self, @@ -380,23 +671,79 @@ def _make_status_error( class BeeperDesktopWithRawResponse: def __init__(self, client: BeeperDesktop) -> None: + self.accounts = accounts.AccountsResourceWithRawResponse(client.accounts) + self.contacts = contacts.ContactsResourceWithRawResponse(client.contacts) + self.chats = chats.ChatsResourceWithRawResponse(client.chats) + self.messages = messages.MessagesResourceWithRawResponse(client.messages) self.token = token.TokenResourceWithRawResponse(client.token) + self.download_asset = to_raw_response_wrapper( + client.download_asset, + ) + self.open = to_raw_response_wrapper( + client.open, + ) + self.search = to_raw_response_wrapper( + client.search, + ) + class AsyncBeeperDesktopWithRawResponse: def __init__(self, client: AsyncBeeperDesktop) -> None: + self.accounts = accounts.AsyncAccountsResourceWithRawResponse(client.accounts) + self.contacts = contacts.AsyncContactsResourceWithRawResponse(client.contacts) + self.chats = chats.AsyncChatsResourceWithRawResponse(client.chats) + self.messages = messages.AsyncMessagesResourceWithRawResponse(client.messages) self.token = token.AsyncTokenResourceWithRawResponse(client.token) + self.download_asset = async_to_raw_response_wrapper( + client.download_asset, + ) + self.open = async_to_raw_response_wrapper( + client.open, + ) + self.search = async_to_raw_response_wrapper( + client.search, + ) + class BeeperDesktopWithStreamedResponse: def __init__(self, client: BeeperDesktop) -> None: + self.accounts = accounts.AccountsResourceWithStreamingResponse(client.accounts) + self.contacts = contacts.ContactsResourceWithStreamingResponse(client.contacts) + self.chats = chats.ChatsResourceWithStreamingResponse(client.chats) + self.messages = messages.MessagesResourceWithStreamingResponse(client.messages) self.token = token.TokenResourceWithStreamingResponse(client.token) + self.download_asset = to_streamed_response_wrapper( + client.download_asset, + ) + self.open = to_streamed_response_wrapper( + client.open, + ) + self.search = to_streamed_response_wrapper( + client.search, + ) + class AsyncBeeperDesktopWithStreamedResponse: def __init__(self, client: AsyncBeeperDesktop) -> None: + self.accounts = accounts.AsyncAccountsResourceWithStreamingResponse(client.accounts) + self.contacts = contacts.AsyncContactsResourceWithStreamingResponse(client.contacts) + self.chats = chats.AsyncChatsResourceWithStreamingResponse(client.chats) + self.messages = messages.AsyncMessagesResourceWithStreamingResponse(client.messages) self.token = token.AsyncTokenResourceWithStreamingResponse(client.token) + self.download_asset = async_to_streamed_response_wrapper( + client.download_asset, + ) + self.open = async_to_streamed_response_wrapper( + client.open, + ) + self.search = async_to_streamed_response_wrapper( + client.search, + ) + Client = BeeperDesktop diff --git a/src/beeper_desktop_api/resources/__init__.py b/src/beeper_desktop_api/resources/__init__.py index 7c3b25f..24ab242 100644 --- a/src/beeper_desktop_api/resources/__init__.py +++ b/src/beeper_desktop_api/resources/__init__.py @@ -1,5 +1,13 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from .chats import ( + ChatsResource, + AsyncChatsResource, + ChatsResourceWithRawResponse, + AsyncChatsResourceWithRawResponse, + ChatsResourceWithStreamingResponse, + AsyncChatsResourceWithStreamingResponse, +) from .token import ( TokenResource, AsyncTokenResource, @@ -8,8 +16,56 @@ TokenResourceWithStreamingResponse, AsyncTokenResourceWithStreamingResponse, ) +from .accounts import ( + AccountsResource, + AsyncAccountsResource, + AccountsResourceWithRawResponse, + AsyncAccountsResourceWithRawResponse, + AccountsResourceWithStreamingResponse, + AsyncAccountsResourceWithStreamingResponse, +) +from .contacts import ( + ContactsResource, + AsyncContactsResource, + ContactsResourceWithRawResponse, + AsyncContactsResourceWithRawResponse, + ContactsResourceWithStreamingResponse, + AsyncContactsResourceWithStreamingResponse, +) +from .messages import ( + MessagesResource, + AsyncMessagesResource, + MessagesResourceWithRawResponse, + AsyncMessagesResourceWithRawResponse, + MessagesResourceWithStreamingResponse, + AsyncMessagesResourceWithStreamingResponse, +) __all__ = [ + "AccountsResource", + "AsyncAccountsResource", + "AccountsResourceWithRawResponse", + "AsyncAccountsResourceWithRawResponse", + "AccountsResourceWithStreamingResponse", + "AsyncAccountsResourceWithStreamingResponse", + "ContactsResource", + "AsyncContactsResource", + "ContactsResourceWithRawResponse", + "AsyncContactsResourceWithRawResponse", + "ContactsResourceWithStreamingResponse", + "AsyncContactsResourceWithStreamingResponse", + "ChatsResource", + "AsyncChatsResource", + "ChatsResourceWithRawResponse", + "AsyncChatsResourceWithRawResponse", + "ChatsResourceWithStreamingResponse", + "AsyncChatsResourceWithStreamingResponse", + "MessagesResource", + "AsyncMessagesResource", + "MessagesResourceWithRawResponse", + "AsyncMessagesResourceWithRawResponse", + "MessagesResourceWithStreamingResponse", + "AsyncMessagesResourceWithStreamingResponse", "TokenResource", "AsyncTokenResource", "TokenResourceWithRawResponse", diff --git a/src/beeper_desktop_api/resources/accounts.py b/src/beeper_desktop_api/resources/accounts.py new file mode 100644 index 0000000..49a5df2 --- /dev/null +++ b/src/beeper_desktop_api/resources/accounts.py @@ -0,0 +1,145 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from .._types import Body, Query, Headers, NotGiven, not_given +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.account_list_response import AccountListResponse + +__all__ = ["AccountsResource", "AsyncAccountsResource"] + + +class AccountsResource(SyncAPIResource): + """Accounts operations""" + + @cached_property + def with_raw_response(self) -> AccountsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AccountsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AccountsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return AccountsResourceWithStreamingResponse(self) + + def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AccountListResponse: + """ + Lists chat accounts across networks (WhatsApp, Telegram, Twitter/X, etc.) + actively connected to this Beeper Desktop instance + """ + return self._get( + "/v1/accounts", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AccountListResponse, + ) + + +class AsyncAccountsResource(AsyncAPIResource): + """Accounts operations""" + + @cached_property + def with_raw_response(self) -> AsyncAccountsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncAccountsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAccountsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return AsyncAccountsResourceWithStreamingResponse(self) + + async def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AccountListResponse: + """ + Lists chat accounts across networks (WhatsApp, Telegram, Twitter/X, etc.) + actively connected to this Beeper Desktop instance + """ + return await self._get( + "/v1/accounts", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AccountListResponse, + ) + + +class AccountsResourceWithRawResponse: + def __init__(self, accounts: AccountsResource) -> None: + self._accounts = accounts + + self.list = to_raw_response_wrapper( + accounts.list, + ) + + +class AsyncAccountsResourceWithRawResponse: + def __init__(self, accounts: AsyncAccountsResource) -> None: + self._accounts = accounts + + self.list = async_to_raw_response_wrapper( + accounts.list, + ) + + +class AccountsResourceWithStreamingResponse: + def __init__(self, accounts: AccountsResource) -> None: + self._accounts = accounts + + self.list = to_streamed_response_wrapper( + accounts.list, + ) + + +class AsyncAccountsResourceWithStreamingResponse: + def __init__(self, accounts: AsyncAccountsResource) -> None: + self._accounts = accounts + + self.list = async_to_streamed_response_wrapper( + accounts.list, + ) diff --git a/src/beeper_desktop_api/resources/chats/__init__.py b/src/beeper_desktop_api/resources/chats/__init__.py new file mode 100644 index 0000000..e26ae7f --- /dev/null +++ b/src/beeper_desktop_api/resources/chats/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .chats import ( + ChatsResource, + AsyncChatsResource, + ChatsResourceWithRawResponse, + AsyncChatsResourceWithRawResponse, + ChatsResourceWithStreamingResponse, + AsyncChatsResourceWithStreamingResponse, +) +from .reminders import ( + RemindersResource, + AsyncRemindersResource, + RemindersResourceWithRawResponse, + AsyncRemindersResourceWithRawResponse, + RemindersResourceWithStreamingResponse, + AsyncRemindersResourceWithStreamingResponse, +) + +__all__ = [ + "RemindersResource", + "AsyncRemindersResource", + "RemindersResourceWithRawResponse", + "AsyncRemindersResourceWithRawResponse", + "RemindersResourceWithStreamingResponse", + "AsyncRemindersResourceWithStreamingResponse", + "ChatsResource", + "AsyncChatsResource", + "ChatsResourceWithRawResponse", + "AsyncChatsResourceWithRawResponse", + "ChatsResourceWithStreamingResponse", + "AsyncChatsResourceWithStreamingResponse", +] diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py new file mode 100644 index 0000000..b5ec602 --- /dev/null +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -0,0 +1,668 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union, Optional +from datetime import datetime +from typing_extensions import Literal + +import httpx + +from ...types import chat_create_params, chat_search_params, chat_archive_params, chat_retrieve_params +from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from .reminders import ( + RemindersResource, + AsyncRemindersResource, + RemindersResourceWithRawResponse, + AsyncRemindersResourceWithRawResponse, + RemindersResourceWithStreamingResponse, + AsyncRemindersResourceWithStreamingResponse, +) +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ...pagination import SyncCursor, AsyncCursor +from ...types.chat import Chat +from ..._base_client import AsyncPaginator, make_request_options +from ...types.chat_create_response import ChatCreateResponse +from ...types.shared.base_response import BaseResponse + +__all__ = ["ChatsResource", "AsyncChatsResource"] + + +class ChatsResource(SyncAPIResource): + """Chats operations""" + + @cached_property + def reminders(self) -> RemindersResource: + """Reminders operations""" + return RemindersResource(self._client) + + @cached_property + def with_raw_response(self) -> ChatsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return ChatsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ChatsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return ChatsResourceWithStreamingResponse(self) + + def create( + self, + *, + account_id: str, + participant_ids: SequenceNotStr[str], + type: Literal["single", "group"], + message_text: str | Omit = omit, + title: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ChatCreateResponse: + """ + Create a single or group chat on a specific account using participant IDs and + optional title. + + Args: + account_id: Account to create the chat on. + + participant_ids: User IDs to include in the new chat. + + type: Chat type to create: 'single' requires exactly one participantID; 'group' + supports multiple participants and optional title. + + message_text: Optional first message content if the platform requires it to create the chat. + + title: Optional title for group chats; ignored for single chats on most platforms. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/chats", + body=maybe_transform( + { + "account_id": account_id, + "participant_ids": participant_ids, + "type": type, + "message_text": message_text, + "title": title, + }, + chat_create_params.ChatCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ChatCreateResponse, + ) + + def retrieve( + self, + chat_id: str, + *, + max_participant_count: Optional[int] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Chat: + """ + Retrieve chat details including metadata, participants, and latest message + + Args: + chat_id: Unique identifier of the chat to retrieve. Not available for iMessage chats. + Participants are limited by 'maxParticipantCount'. + + max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0–500. + Defaults to 20. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not 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}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + {"max_participant_count": max_participant_count}, chat_retrieve_params.ChatRetrieveParams + ), + ), + cast_to=Chat, + ) + + def archive( + self, + chat_id: str, + *, + archived: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BaseResponse: + """Archive or unarchive a chat. + + Set archived=true to move to archive, + archived=false to move back to inbox + + Args: + chat_id: The identifier of the chat to archive or unarchive (accepts both chatID and + local chat ID) + + archived: True to archive, false to unarchive + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not 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}/archive", + 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 + ), + cast_to=BaseResponse, + ) + + def search( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, + include_muted: Optional[bool] | Omit = omit, + last_activity_after: Union[str, datetime] | Omit = omit, + last_activity_before: Union[str, datetime] | Omit = omit, + limit: int | Omit = omit, + query: str | Omit = omit, + scope: Literal["titles", "participants"] | Omit = omit, + type: Literal["single", "group", "any"] | Omit = omit, + unread_only: Optional[bool] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SyncCursor[Chat]: + """ + Search chats by title/network or participants using Beeper Desktop's renderer + algorithm. + + Args: + account_ids: Provide an array of account IDs to filter chats from specific messaging accounts + only + + cursor: Pagination cursor from previous response. Use with direction to navigate results + + direction: Pagination direction: "after" for newer page, "before" for older page. Defaults + to "before" when only cursor is provided. + + inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), + "low-priority", or "archive". If not specified, shows all chats. + + include_muted: Include chats marked as Muted by the user, which are usually less important. + Default: true. Set to false if the user wants a more refined search. + + last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after + this time + + last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before + this time + + limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 + + query: Literal token search (non-semantic). Use single words users type (e.g., + "dinner"). When multiple words provided, ALL must match. Case-insensitive. + + scope: Search scope: 'titles' matches title + network; 'participants' matches + participant names. + + type: Specify the type of chats to retrieve: use "single" for direct messages, "group" + for group chats, or "any" to get all types + + unread_only: Set to true to only retrieve chats that have unread messages + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v1/chats/search", + page=SyncCursor[Chat], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "cursor": cursor, + "direction": direction, + "inbox": inbox, + "include_muted": include_muted, + "last_activity_after": last_activity_after, + "last_activity_before": last_activity_before, + "limit": limit, + "query": query, + "scope": scope, + "type": type, + "unread_only": unread_only, + }, + chat_search_params.ChatSearchParams, + ), + ), + model=Chat, + ) + + +class AsyncChatsResource(AsyncAPIResource): + """Chats operations""" + + @cached_property + def reminders(self) -> AsyncRemindersResource: + """Reminders operations""" + return AsyncRemindersResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncChatsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncChatsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncChatsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return AsyncChatsResourceWithStreamingResponse(self) + + async def create( + self, + *, + account_id: str, + participant_ids: SequenceNotStr[str], + type: Literal["single", "group"], + message_text: str | Omit = omit, + title: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ChatCreateResponse: + """ + Create a single or group chat on a specific account using participant IDs and + optional title. + + Args: + account_id: Account to create the chat on. + + participant_ids: User IDs to include in the new chat. + + type: Chat type to create: 'single' requires exactly one participantID; 'group' + supports multiple participants and optional title. + + message_text: Optional first message content if the platform requires it to create the chat. + + title: Optional title for group chats; ignored for single chats on most platforms. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/chats", + body=await async_maybe_transform( + { + "account_id": account_id, + "participant_ids": participant_ids, + "type": type, + "message_text": message_text, + "title": title, + }, + chat_create_params.ChatCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ChatCreateResponse, + ) + + async def retrieve( + self, + chat_id: str, + *, + max_participant_count: Optional[int] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Chat: + """ + Retrieve chat details including metadata, participants, and latest message + + Args: + chat_id: Unique identifier of the chat to retrieve. Not available for iMessage chats. + Participants are limited by 'maxParticipantCount'. + + max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0–500. + Defaults to 20. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not 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}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"max_participant_count": max_participant_count}, chat_retrieve_params.ChatRetrieveParams + ), + ), + cast_to=Chat, + ) + + async def archive( + self, + chat_id: str, + *, + archived: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BaseResponse: + """Archive or unarchive a chat. + + Set archived=true to move to archive, + archived=false to move back to inbox + + Args: + chat_id: The identifier of the chat to archive or unarchive (accepts both chatID and + local chat ID) + + archived: True to archive, false to unarchive + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not 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}/archive", + 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 + ), + cast_to=BaseResponse, + ) + + def search( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, + include_muted: Optional[bool] | Omit = omit, + last_activity_after: Union[str, datetime] | Omit = omit, + last_activity_before: Union[str, datetime] | Omit = omit, + limit: int | Omit = omit, + query: str | Omit = omit, + scope: Literal["titles", "participants"] | Omit = omit, + type: Literal["single", "group", "any"] | Omit = omit, + unread_only: Optional[bool] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncPaginator[Chat, AsyncCursor[Chat]]: + """ + Search chats by title/network or participants using Beeper Desktop's renderer + algorithm. + + Args: + account_ids: Provide an array of account IDs to filter chats from specific messaging accounts + only + + cursor: Pagination cursor from previous response. Use with direction to navigate results + + direction: Pagination direction: "after" for newer page, "before" for older page. Defaults + to "before" when only cursor is provided. + + inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), + "low-priority", or "archive". If not specified, shows all chats. + + include_muted: Include chats marked as Muted by the user, which are usually less important. + Default: true. Set to false if the user wants a more refined search. + + last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after + this time + + last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before + this time + + limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 + + query: Literal token search (non-semantic). Use single words users type (e.g., + "dinner"). When multiple words provided, ALL must match. Case-insensitive. + + scope: Search scope: 'titles' matches title + network; 'participants' matches + participant names. + + type: Specify the type of chats to retrieve: use "single" for direct messages, "group" + for group chats, or "any" to get all types + + unread_only: Set to true to only retrieve chats that have unread messages + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v1/chats/search", + page=AsyncCursor[Chat], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "cursor": cursor, + "direction": direction, + "inbox": inbox, + "include_muted": include_muted, + "last_activity_after": last_activity_after, + "last_activity_before": last_activity_before, + "limit": limit, + "query": query, + "scope": scope, + "type": type, + "unread_only": unread_only, + }, + chat_search_params.ChatSearchParams, + ), + ), + model=Chat, + ) + + +class ChatsResourceWithRawResponse: + def __init__(self, chats: ChatsResource) -> None: + self._chats = chats + + self.create = to_raw_response_wrapper( + chats.create, + ) + self.retrieve = to_raw_response_wrapper( + chats.retrieve, + ) + self.archive = to_raw_response_wrapper( + chats.archive, + ) + self.search = to_raw_response_wrapper( + chats.search, + ) + + @cached_property + def reminders(self) -> RemindersResourceWithRawResponse: + """Reminders operations""" + return RemindersResourceWithRawResponse(self._chats.reminders) + + +class AsyncChatsResourceWithRawResponse: + def __init__(self, chats: AsyncChatsResource) -> None: + self._chats = chats + + self.create = async_to_raw_response_wrapper( + chats.create, + ) + self.retrieve = async_to_raw_response_wrapper( + chats.retrieve, + ) + self.archive = async_to_raw_response_wrapper( + chats.archive, + ) + self.search = async_to_raw_response_wrapper( + chats.search, + ) + + @cached_property + def reminders(self) -> AsyncRemindersResourceWithRawResponse: + """Reminders operations""" + return AsyncRemindersResourceWithRawResponse(self._chats.reminders) + + +class ChatsResourceWithStreamingResponse: + def __init__(self, chats: ChatsResource) -> None: + self._chats = chats + + self.create = to_streamed_response_wrapper( + chats.create, + ) + self.retrieve = to_streamed_response_wrapper( + chats.retrieve, + ) + self.archive = to_streamed_response_wrapper( + chats.archive, + ) + self.search = to_streamed_response_wrapper( + chats.search, + ) + + @cached_property + def reminders(self) -> RemindersResourceWithStreamingResponse: + """Reminders operations""" + return RemindersResourceWithStreamingResponse(self._chats.reminders) + + +class AsyncChatsResourceWithStreamingResponse: + def __init__(self, chats: AsyncChatsResource) -> None: + self._chats = chats + + self.create = async_to_streamed_response_wrapper( + chats.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + chats.retrieve, + ) + self.archive = async_to_streamed_response_wrapper( + chats.archive, + ) + self.search = async_to_streamed_response_wrapper( + chats.search, + ) + + @cached_property + def reminders(self) -> AsyncRemindersResourceWithStreamingResponse: + """Reminders operations""" + return AsyncRemindersResourceWithStreamingResponse(self._chats.reminders) diff --git a/src/beeper_desktop_api/resources/chats/reminders.py b/src/beeper_desktop_api/resources/chats/reminders.py new file mode 100644 index 0000000..e9da3b4 --- /dev/null +++ b/src/beeper_desktop_api/resources/chats/reminders.py @@ -0,0 +1,267 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ...types.chats import reminder_create_params +from ..._base_client import make_request_options +from ...types.shared.base_response import BaseResponse + +__all__ = ["RemindersResource", "AsyncRemindersResource"] + + +class RemindersResource(SyncAPIResource): + """Reminders operations""" + + @cached_property + def with_raw_response(self) -> RemindersResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return RemindersResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> RemindersResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return RemindersResourceWithStreamingResponse(self) + + def create( + self, + chat_id: str, + *, + reminder: reminder_create_params.Reminder, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BaseResponse: + """ + Set a reminder for a chat at a specific time + + Args: + chat_id: The identifier of the chat to set reminder for (accepts both chatID and local + chat ID) + + reminder: Reminder configuration + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not 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}/reminders", + 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 + ), + cast_to=BaseResponse, + ) + + def delete( + self, + chat_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BaseResponse: + """ + Clear an existing reminder from a chat + + Args: + chat_id: The identifier of the chat to clear reminder from (accepts both chatID and local + chat ID) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + return self._delete( + f"/v1/chats/{chat_id}/reminders", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BaseResponse, + ) + + +class AsyncRemindersResource(AsyncAPIResource): + """Reminders operations""" + + @cached_property + def with_raw_response(self) -> AsyncRemindersResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncRemindersResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncRemindersResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return AsyncRemindersResourceWithStreamingResponse(self) + + async def create( + self, + chat_id: str, + *, + reminder: reminder_create_params.Reminder, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BaseResponse: + """ + Set a reminder for a chat at a specific time + + Args: + chat_id: The identifier of the chat to set reminder for (accepts both chatID and local + chat ID) + + reminder: Reminder configuration + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not 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}/reminders", + 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 + ), + cast_to=BaseResponse, + ) + + async def delete( + self, + chat_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BaseResponse: + """ + Clear an existing reminder from a chat + + Args: + chat_id: The identifier of the chat to clear reminder from (accepts both chatID and local + chat ID) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + return await self._delete( + f"/v1/chats/{chat_id}/reminders", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BaseResponse, + ) + + +class RemindersResourceWithRawResponse: + def __init__(self, reminders: RemindersResource) -> None: + self._reminders = reminders + + self.create = to_raw_response_wrapper( + reminders.create, + ) + self.delete = to_raw_response_wrapper( + reminders.delete, + ) + + +class AsyncRemindersResourceWithRawResponse: + def __init__(self, reminders: AsyncRemindersResource) -> None: + self._reminders = reminders + + self.create = async_to_raw_response_wrapper( + reminders.create, + ) + self.delete = async_to_raw_response_wrapper( + reminders.delete, + ) + + +class RemindersResourceWithStreamingResponse: + def __init__(self, reminders: RemindersResource) -> None: + self._reminders = reminders + + self.create = to_streamed_response_wrapper( + reminders.create, + ) + self.delete = to_streamed_response_wrapper( + reminders.delete, + ) + + +class AsyncRemindersResourceWithStreamingResponse: + def __init__(self, reminders: AsyncRemindersResource) -> None: + self._reminders = reminders + + self.create = async_to_streamed_response_wrapper( + reminders.create, + ) + self.delete = async_to_streamed_response_wrapper( + reminders.delete, + ) diff --git a/src/beeper_desktop_api/resources/contacts.py b/src/beeper_desktop_api/resources/contacts.py new file mode 100644 index 0000000..db84950 --- /dev/null +++ b/src/beeper_desktop_api/resources/contacts.py @@ -0,0 +1,197 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import contact_search_params +from .._types import Body, Query, Headers, NotGiven, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.contact_search_response import ContactSearchResponse + +__all__ = ["ContactsResource", "AsyncContactsResource"] + + +class ContactsResource(SyncAPIResource): + """Contacts operations""" + + @cached_property + def with_raw_response(self) -> ContactsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return ContactsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ContactsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return ContactsResourceWithStreamingResponse(self) + + def search( + self, + *, + account_id: str, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ContactSearchResponse: + """ + Search contacts across on a specific account using the network's search API. + Only use for creating new chats. + + Args: + account_id: Account ID this resource belongs to. + + query: Text to search users by. Network-specific behavior. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/v1/contacts/search", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_id": account_id, + "query": query, + }, + contact_search_params.ContactSearchParams, + ), + ), + cast_to=ContactSearchResponse, + ) + + +class AsyncContactsResource(AsyncAPIResource): + """Contacts operations""" + + @cached_property + def with_raw_response(self) -> AsyncContactsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncContactsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncContactsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return AsyncContactsResourceWithStreamingResponse(self) + + async def search( + self, + *, + account_id: str, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ContactSearchResponse: + """ + Search contacts across on a specific account using the network's search API. + Only use for creating new chats. + + Args: + account_id: Account ID this resource belongs to. + + query: Text to search users by. Network-specific behavior. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/v1/contacts/search", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "account_id": account_id, + "query": query, + }, + contact_search_params.ContactSearchParams, + ), + ), + cast_to=ContactSearchResponse, + ) + + +class ContactsResourceWithRawResponse: + def __init__(self, contacts: ContactsResource) -> None: + self._contacts = contacts + + self.search = to_raw_response_wrapper( + contacts.search, + ) + + +class AsyncContactsResourceWithRawResponse: + def __init__(self, contacts: AsyncContactsResource) -> None: + self._contacts = contacts + + self.search = async_to_raw_response_wrapper( + contacts.search, + ) + + +class ContactsResourceWithStreamingResponse: + def __init__(self, contacts: ContactsResource) -> None: + self._contacts = contacts + + self.search = to_streamed_response_wrapper( + contacts.search, + ) + + +class AsyncContactsResourceWithStreamingResponse: + def __init__(self, contacts: AsyncContactsResource) -> None: + self._contacts = contacts + + self.search = async_to_streamed_response_wrapper( + contacts.search, + ) diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py new file mode 100644 index 0000000..ea1ea25 --- /dev/null +++ b/src/beeper_desktop_api/resources/messages.py @@ -0,0 +1,423 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Union, Optional +from datetime import datetime +from typing_extensions import Literal + +import httpx + +from ..types import message_send_params, message_search_params +from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..pagination import SyncCursor, AsyncCursor +from .._base_client import AsyncPaginator, make_request_options +from ..types.shared.message import Message +from ..types.message_send_response import MessageSendResponse + +__all__ = ["MessagesResource", "AsyncMessagesResource"] + + +class MessagesResource(SyncAPIResource): + """Messages operations""" + + @cached_property + def with_raw_response(self) -> MessagesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return MessagesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> MessagesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return MessagesResourceWithStreamingResponse(self) + + def search( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + chat_ids: SequenceNotStr[str] | Omit = omit, + chat_type: Literal["group", "single"] | Omit = omit, + cursor: str | Omit = omit, + date_after: Union[str, datetime] | Omit = omit, + date_before: Union[str, datetime] | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + exclude_low_priority: Optional[bool] | Omit = omit, + include_muted: Optional[bool] | Omit = omit, + limit: int | Omit = omit, + media_types: List[Literal["any", "video", "image", "link", "file"]] | Omit = omit, + query: str | Omit = omit, + sender: Union[Literal["me", "others"], str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SyncCursor[Message]: + """ + Search messages across chats using Beeper's message index + + Args: + account_ids: Limit search to specific account IDs. + + chat_ids: Limit search to specific chat IDs. + + chat_type: Filter by chat type: 'group' for group chats, 'single' for 1:1 chats. + + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. + + date_after: Only include messages with timestamp strictly after this ISO 8601 datetime + (e.g., '2024-07-01T00:00:00Z' or '2024-07-01T00:00:00+02:00'). + + date_before: Only include messages with timestamp strictly before this ISO 8601 datetime + (e.g., '2024-07-31T23:59:59Z' or '2024-07-31T23:59:59+02:00'). + + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + + exclude_low_priority: Exclude messages marked Low Priority by the user. Default: true. Set to false to + include all. + + include_muted: Include messages in chats marked as Muted by the user, which are usually less + important. Default: true. Set to false if the user wants a more refined search. + + limit: Maximum number of messages to return (1–500). Defaults to 20. The current + implementation caps each page at 20 items even if a higher limit is requested. + + media_types: Filter messages by media types. Use ['any'] for any media type, or specify exact + types like ['video', 'image']. Omit for no media filtering. + + query: Literal word search (NOT semantic). Finds messages containing these EXACT words + in any order. Use single words users actually type, not concepts or phrases. + Example: use "dinner" not "dinner plans", use "sick" not "health issues". If + omitted, returns results filtered only by other parameters. + + sender: Filter by sender: 'me' (messages sent by the authenticated user), 'others' + (messages sent by others), or a specific user ID string (user.id). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v1/messages/search", + page=SyncCursor[Message], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "chat_ids": chat_ids, + "chat_type": chat_type, + "cursor": cursor, + "date_after": date_after, + "date_before": date_before, + "direction": direction, + "exclude_low_priority": exclude_low_priority, + "include_muted": include_muted, + "limit": limit, + "media_types": media_types, + "query": query, + "sender": sender, + }, + message_search_params.MessageSearchParams, + ), + ), + model=Message, + ) + + def send( + self, + *, + chat_id: str, + reply_to_message_id: str | Omit = omit, + text: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> MessageSendResponse: + """Send a text message to a specific chat. + + Supports replying to existing messages. + Returns the sent message ID. + + Args: + chat_id: Unique identifier of the chat. + + reply_to_message_id: Provide a message ID to send this as a reply to an existing message + + text: Text content of the message you want to send. You may use markdown. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/messages", + body=maybe_transform( + { + "chat_id": chat_id, + "reply_to_message_id": reply_to_message_id, + "text": text, + }, + message_send_params.MessageSendParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=MessageSendResponse, + ) + + +class AsyncMessagesResource(AsyncAPIResource): + """Messages operations""" + + @cached_property + def with_raw_response(self) -> AsyncMessagesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncMessagesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncMessagesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return AsyncMessagesResourceWithStreamingResponse(self) + + def search( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + chat_ids: SequenceNotStr[str] | Omit = omit, + chat_type: Literal["group", "single"] | Omit = omit, + cursor: str | Omit = omit, + date_after: Union[str, datetime] | Omit = omit, + date_before: Union[str, datetime] | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + exclude_low_priority: Optional[bool] | Omit = omit, + include_muted: Optional[bool] | Omit = omit, + limit: int | Omit = omit, + media_types: List[Literal["any", "video", "image", "link", "file"]] | Omit = omit, + query: str | Omit = omit, + sender: Union[Literal["me", "others"], str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncPaginator[Message, AsyncCursor[Message]]: + """ + Search messages across chats using Beeper's message index + + Args: + account_ids: Limit search to specific account IDs. + + chat_ids: Limit search to specific chat IDs. + + chat_type: Filter by chat type: 'group' for group chats, 'single' for 1:1 chats. + + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. + + date_after: Only include messages with timestamp strictly after this ISO 8601 datetime + (e.g., '2024-07-01T00:00:00Z' or '2024-07-01T00:00:00+02:00'). + + date_before: Only include messages with timestamp strictly before this ISO 8601 datetime + (e.g., '2024-07-31T23:59:59Z' or '2024-07-31T23:59:59+02:00'). + + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + + exclude_low_priority: Exclude messages marked Low Priority by the user. Default: true. Set to false to + include all. + + include_muted: Include messages in chats marked as Muted by the user, which are usually less + important. Default: true. Set to false if the user wants a more refined search. + + limit: Maximum number of messages to return (1–500). Defaults to 20. The current + implementation caps each page at 20 items even if a higher limit is requested. + + media_types: Filter messages by media types. Use ['any'] for any media type, or specify exact + types like ['video', 'image']. Omit for no media filtering. + + query: Literal word search (NOT semantic). Finds messages containing these EXACT words + in any order. Use single words users actually type, not concepts or phrases. + Example: use "dinner" not "dinner plans", use "sick" not "health issues". If + omitted, returns results filtered only by other parameters. + + sender: Filter by sender: 'me' (messages sent by the authenticated user), 'others' + (messages sent by others), or a specific user ID string (user.id). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v1/messages/search", + page=AsyncCursor[Message], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "chat_ids": chat_ids, + "chat_type": chat_type, + "cursor": cursor, + "date_after": date_after, + "date_before": date_before, + "direction": direction, + "exclude_low_priority": exclude_low_priority, + "include_muted": include_muted, + "limit": limit, + "media_types": media_types, + "query": query, + "sender": sender, + }, + message_search_params.MessageSearchParams, + ), + ), + model=Message, + ) + + async def send( + self, + *, + chat_id: str, + reply_to_message_id: str | Omit = omit, + text: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> MessageSendResponse: + """Send a text message to a specific chat. + + Supports replying to existing messages. + Returns the sent message ID. + + Args: + chat_id: Unique identifier of the chat. + + reply_to_message_id: Provide a message ID to send this as a reply to an existing message + + text: Text content of the message you want to send. You may use markdown. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/messages", + body=await async_maybe_transform( + { + "chat_id": chat_id, + "reply_to_message_id": reply_to_message_id, + "text": text, + }, + message_send_params.MessageSendParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=MessageSendResponse, + ) + + +class MessagesResourceWithRawResponse: + def __init__(self, messages: MessagesResource) -> None: + self._messages = messages + + self.search = to_raw_response_wrapper( + messages.search, + ) + self.send = to_raw_response_wrapper( + messages.send, + ) + + +class AsyncMessagesResourceWithRawResponse: + def __init__(self, messages: AsyncMessagesResource) -> None: + self._messages = messages + + self.search = async_to_raw_response_wrapper( + messages.search, + ) + self.send = async_to_raw_response_wrapper( + messages.send, + ) + + +class MessagesResourceWithStreamingResponse: + def __init__(self, messages: MessagesResource) -> None: + self._messages = messages + + self.search = to_streamed_response_wrapper( + messages.search, + ) + self.send = to_streamed_response_wrapper( + messages.send, + ) + + +class AsyncMessagesResourceWithStreamingResponse: + def __init__(self, messages: AsyncMessagesResource) -> None: + self._messages = messages + + self.search = async_to_streamed_response_wrapper( + messages.search, + ) + self.send = async_to_streamed_response_wrapper( + messages.send, + ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index bb86833..5bede4c 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from .chat import Chat as Chat from .shared import ( User as User, Error as Error, @@ -10,4 +11,22 @@ Attachment as Attachment, BaseResponse as BaseResponse, ) +from .account import Account as Account from .user_info import UserInfo as UserInfo +from .open_response import OpenResponse as OpenResponse +from .search_response import SearchResponse as SearchResponse +from .chat_create_params import ChatCreateParams as ChatCreateParams +from .chat_search_params import ChatSearchParams as ChatSearchParams +from .client_open_params import ClientOpenParams as ClientOpenParams +from .chat_archive_params import ChatArchiveParams as ChatArchiveParams +from .message_send_params import MessageSendParams as MessageSendParams +from .chat_create_response import ChatCreateResponse as ChatCreateResponse +from .chat_retrieve_params import ChatRetrieveParams as ChatRetrieveParams +from .client_search_params import ClientSearchParams as ClientSearchParams +from .account_list_response import AccountListResponse as AccountListResponse +from .contact_search_params import ContactSearchParams as ContactSearchParams +from .message_search_params import MessageSearchParams as MessageSearchParams +from .message_send_response import MessageSendResponse as MessageSendResponse +from .contact_search_response import ContactSearchResponse as ContactSearchResponse +from .download_asset_response import DownloadAssetResponse as DownloadAssetResponse +from .client_download_asset_params import ClientDownloadAssetParams as ClientDownloadAssetParams diff --git a/src/beeper_desktop_api/types/account.py b/src/beeper_desktop_api/types/account.py new file mode 100644 index 0000000..97336b7 --- /dev/null +++ b/src/beeper_desktop_api/types/account.py @@ -0,0 +1,19 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from pydantic import Field as FieldInfo + +from .._models import BaseModel +from .shared.user import User + +__all__ = ["Account"] + + +class Account(BaseModel): + account_id: str = FieldInfo(alias="accountID") + """Chat account added to Beeper. Use this to route account-scoped actions.""" + + network: str + """Display-only human-readable network name (e.g., 'WhatsApp', 'Messenger').""" + + user: User + """User the account belongs to.""" diff --git a/src/beeper_desktop_api/types/account_list_response.py b/src/beeper_desktop_api/types/account_list_response.py new file mode 100644 index 0000000..8268843 --- /dev/null +++ b/src/beeper_desktop_api/types/account_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .account import Account + +__all__ = ["AccountListResponse"] + +AccountListResponse: TypeAlias = List[Account] diff --git a/src/beeper_desktop_api/types/chat.py b/src/beeper_desktop_api/types/chat.py new file mode 100644 index 0000000..d580426 --- /dev/null +++ b/src/beeper_desktop_api/types/chat.py @@ -0,0 +1,67 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from datetime import datetime +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel +from .shared.user import User + +__all__ = ["Chat", "Participants"] + + +class Participants(BaseModel): + has_more: bool = FieldInfo(alias="hasMore") + """True if there are more participants than included in items.""" + + items: List[User] + """Participants returned for this chat (limited by the request; may be a subset).""" + + total: int + """Total number of participants in the chat.""" + + +class Chat(BaseModel): + id: str + """Unique identifier of the chat (room/thread ID, same as id) across Beeper.""" + + account_id: str = FieldInfo(alias="accountID") + """Beeper account ID this chat belongs to.""" + + network: str + """Display-only human-readable network name (e.g., 'WhatsApp', 'Messenger').""" + + participants: Participants + """Chat participants information.""" + + title: str + """Display title of the chat as computed by the client/server.""" + + type: Literal["single", "group"] + """Chat type: 'single' for direct messages, 'group' for group chats.""" + + unread_count: int = FieldInfo(alias="unreadCount") + """Number of unread messages.""" + + is_archived: Optional[bool] = FieldInfo(alias="isArchived", default=None) + """True if chat is archived.""" + + is_muted: Optional[bool] = FieldInfo(alias="isMuted", default=None) + """True if chat notifications are muted.""" + + is_pinned: Optional[bool] = FieldInfo(alias="isPinned", default=None) + """True if chat is pinned.""" + + last_activity: Optional[datetime] = FieldInfo(alias="lastActivity", default=None) + """Timestamp of last activity. + + Chats with more recent activity are often more important. + """ + + last_read_message_sort_key: Union[int, str, None] = FieldInfo(alias="lastReadMessageSortKey", default=None) + """Last read message sortKey (hsOrder). Used to compute 'isUnread'.""" + + local_chat_id: Optional[str] = FieldInfo(alias="localChatID", default=None) + """Local chat ID specific to this Beeper Desktop installation.""" diff --git a/src/beeper_desktop_api/types/chat_archive_params.py b/src/beeper_desktop_api/types/chat_archive_params.py new file mode 100644 index 0000000..38cc168 --- /dev/null +++ b/src/beeper_desktop_api/types/chat_archive_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["ChatArchiveParams"] + + +class ChatArchiveParams(TypedDict, total=False): + archived: bool + """True to archive, false to unarchive""" diff --git a/src/beeper_desktop_api/types/chat_create_params.py b/src/beeper_desktop_api/types/chat_create_params.py new file mode 100644 index 0000000..686bfaa --- /dev/null +++ b/src/beeper_desktop_api/types/chat_create_params.py @@ -0,0 +1,30 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo + +__all__ = ["ChatCreateParams"] + + +class ChatCreateParams(TypedDict, total=False): + account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] + """Account to create the chat on.""" + + participant_ids: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")]] + """User IDs to include in the new chat.""" + + type: Required[Literal["single", "group"]] + """ + Chat type to create: 'single' requires exactly one participantID; 'group' + supports multiple participants and optional title. + """ + + message_text: Annotated[str, PropertyInfo(alias="messageText")] + """Optional first message content if the platform requires it to create the chat.""" + + title: str + """Optional title for group chats; ignored for single chats on most platforms.""" diff --git a/src/beeper_desktop_api/types/chat_create_response.py b/src/beeper_desktop_api/types/chat_create_response.py new file mode 100644 index 0000000..64b6981 --- /dev/null +++ b/src/beeper_desktop_api/types/chat_create_response.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from .shared.base_response import BaseResponse + +__all__ = ["ChatCreateResponse"] + + +class ChatCreateResponse(BaseResponse): + chat_id: Optional[str] = FieldInfo(alias="chatID", default=None) + """Newly created chat if available.""" diff --git a/src/beeper_desktop_api/types/chat_retrieve_params.py b/src/beeper_desktop_api/types/chat_retrieve_params.py new file mode 100644 index 0000000..ea22752 --- /dev/null +++ b/src/beeper_desktop_api/types/chat_retrieve_params.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["ChatRetrieveParams"] + + +class ChatRetrieveParams(TypedDict, total=False): + max_participant_count: Annotated[Optional[int], PropertyInfo(alias="maxParticipantCount")] + """Maximum number of participants to return. + + Use -1 for all; otherwise 0–500. Defaults to 20. + """ diff --git a/src/beeper_desktop_api/types/chat_search_params.py b/src/beeper_desktop_api/types/chat_search_params.py new file mode 100644 index 0000000..de94b8d --- /dev/null +++ b/src/beeper_desktop_api/types/chat_search_params.py @@ -0,0 +1,81 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union, Optional +from datetime import datetime +from typing_extensions import Literal, Annotated, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo + +__all__ = ["ChatSearchParams"] + + +class ChatSearchParams(TypedDict, total=False): + account_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="accountIDs")] + """ + Provide an array of account IDs to filter chats from specific messaging accounts + only + """ + + cursor: str + """Pagination cursor from previous response. + + Use with direction to navigate results + """ + + direction: Literal["after", "before"] + """Pagination direction: "after" for newer page, "before" for older page. + + Defaults to "before" when only cursor is provided. + """ + + inbox: Literal["primary", "low-priority", "archive"] + """ + Filter by inbox type: "primary" (non-archived, non-low-priority), + "low-priority", or "archive". If not specified, shows all chats. + """ + + include_muted: Annotated[Optional[bool], PropertyInfo(alias="includeMuted")] + """Include chats marked as Muted by the user, which are usually less important. + + Default: true. Set to false if the user wants a more refined search. + """ + + last_activity_after: Annotated[Union[str, datetime], PropertyInfo(alias="lastActivityAfter", format="iso8601")] + """ + Provide an ISO datetime string to only retrieve chats with last activity after + this time + """ + + last_activity_before: Annotated[Union[str, datetime], PropertyInfo(alias="lastActivityBefore", format="iso8601")] + """ + Provide an ISO datetime string to only retrieve chats with last activity before + this time + """ + + limit: int + """Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50""" + + query: str + """Literal token search (non-semantic). + + Use single words users type (e.g., "dinner"). When multiple words provided, ALL + must match. Case-insensitive. + """ + + scope: Literal["titles", "participants"] + """ + Search scope: 'titles' matches title + network; 'participants' matches + participant names. + """ + + type: Literal["single", "group", "any"] + """ + Specify the type of chats to retrieve: use "single" for direct messages, "group" + for group chats, or "any" to get all types + """ + + unread_only: Annotated[Optional[bool], PropertyInfo(alias="unreadOnly")] + """Set to true to only retrieve chats that have unread messages""" diff --git a/src/beeper_desktop_api/types/chats/__init__.py b/src/beeper_desktop_api/types/chats/__init__.py index f8ee8b1..848b361 100644 --- a/src/beeper_desktop_api/types/chats/__init__.py +++ b/src/beeper_desktop_api/types/chats/__init__.py @@ -1,3 +1,5 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. from __future__ import annotations + +from .reminder_create_params import ReminderCreateParams as ReminderCreateParams diff --git a/src/beeper_desktop_api/types/chats/reminder_create_params.py b/src/beeper_desktop_api/types/chats/reminder_create_params.py new file mode 100644 index 0000000..810263e --- /dev/null +++ b/src/beeper_desktop_api/types/chats/reminder_create_params.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from ..._utils import PropertyInfo + +__all__ = ["ReminderCreateParams", "Reminder"] + + +class ReminderCreateParams(TypedDict, total=False): + reminder: Required[Reminder] + """Reminder configuration""" + + +class Reminder(TypedDict, total=False): + remind_at_ms: Required[Annotated[float, PropertyInfo(alias="remindAtMs")]] + """Unix timestamp in milliseconds when reminder should trigger""" + + dismiss_on_incoming_message: Annotated[bool, PropertyInfo(alias="dismissOnIncomingMessage")] + """Cancel reminder if someone messages in the chat""" diff --git a/src/beeper_desktop_api/types/client_download_asset_params.py b/src/beeper_desktop_api/types/client_download_asset_params.py new file mode 100644 index 0000000..fe824e0 --- /dev/null +++ b/src/beeper_desktop_api/types/client_download_asset_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["ClientDownloadAssetParams"] + + +class ClientDownloadAssetParams(TypedDict, total=False): + url: Required[str] + """Matrix content URL (mxc:// or localmxc://) for the asset to download.""" diff --git a/src/beeper_desktop_api/types/client_open_params.py b/src/beeper_desktop_api/types/client_open_params.py new file mode 100644 index 0000000..84dea5f --- /dev/null +++ b/src/beeper_desktop_api/types/client_open_params.py @@ -0,0 +1,26 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["ClientOpenParams"] + + +class ClientOpenParams(TypedDict, total=False): + chat_id: Annotated[str, PropertyInfo(alias="chatID")] + """Optional Beeper chat ID (or local chat ID) to focus after opening the app. + + If omitted, only opens/focuses the app. + """ + + draft_attachment_path: Annotated[str, PropertyInfo(alias="draftAttachmentPath")] + """Optional draft attachment path to populate in the message input field.""" + + draft_text: Annotated[str, PropertyInfo(alias="draftText")] + """Optional draft text to populate in the message input field.""" + + message_id: Annotated[str, PropertyInfo(alias="messageID")] + """Optional message ID. Jumps to that message in the chat when opening.""" diff --git a/src/beeper_desktop_api/types/client_search_params.py b/src/beeper_desktop_api/types/client_search_params.py new file mode 100644 index 0000000..06d58e4 --- /dev/null +++ b/src/beeper_desktop_api/types/client_search_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["ClientSearchParams"] + + +class ClientSearchParams(TypedDict, total=False): + query: Required[str] + """User-typed search text. Literal word matching (NOT semantic).""" diff --git a/src/beeper_desktop_api/types/contact_search_params.py b/src/beeper_desktop_api/types/contact_search_params.py new file mode 100644 index 0000000..53d052f --- /dev/null +++ b/src/beeper_desktop_api/types/contact_search_params.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["ContactSearchParams"] + + +class ContactSearchParams(TypedDict, total=False): + account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] + """Account ID this resource belongs to.""" + + query: Required[str] + """Text to search users by. Network-specific behavior.""" diff --git a/src/beeper_desktop_api/types/contact_search_response.py b/src/beeper_desktop_api/types/contact_search_response.py new file mode 100644 index 0000000..71c609e --- /dev/null +++ b/src/beeper_desktop_api/types/contact_search_response.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from .._models import BaseModel +from .shared.user import User + +__all__ = ["ContactSearchResponse"] + + +class ContactSearchResponse(BaseModel): + items: List[User] diff --git a/src/beeper_desktop_api/types/download_asset_response.py b/src/beeper_desktop_api/types/download_asset_response.py new file mode 100644 index 0000000..47bc22e --- /dev/null +++ b/src/beeper_desktop_api/types/download_asset_response.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["DownloadAssetResponse"] + + +class DownloadAssetResponse(BaseModel): + error: Optional[str] = None + """Error message if the download failed.""" + + src_url: Optional[str] = FieldInfo(alias="srcURL", default=None) + """Local file URL to the downloaded asset.""" diff --git a/src/beeper_desktop_api/types/message_search_params.py b/src/beeper_desktop_api/types/message_search_params.py new file mode 100644 index 0000000..650775f --- /dev/null +++ b/src/beeper_desktop_api/types/message_search_params.py @@ -0,0 +1,85 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Union, Optional +from datetime import datetime +from typing_extensions import Literal, Annotated, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo + +__all__ = ["MessageSearchParams"] + + +class MessageSearchParams(TypedDict, total=False): + account_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="accountIDs")] + """Limit search to specific account IDs.""" + + chat_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="chatIDs")] + """Limit search to specific chat IDs.""" + + chat_type: Annotated[Literal["group", "single"], PropertyInfo(alias="chatType")] + """Filter by chat type: 'group' for group chats, 'single' for 1:1 chats.""" + + cursor: str + """Opaque pagination cursor; do not inspect. Use together with 'direction'.""" + + date_after: Annotated[Union[str, datetime], PropertyInfo(alias="dateAfter", format="iso8601")] + """ + Only include messages with timestamp strictly after this ISO 8601 datetime + (e.g., '2024-07-01T00:00:00Z' or '2024-07-01T00:00:00+02:00'). + """ + + date_before: Annotated[Union[str, datetime], PropertyInfo(alias="dateBefore", format="iso8601")] + """ + Only include messages with timestamp strictly before this ISO 8601 datetime + (e.g., '2024-07-31T23:59:59Z' or '2024-07-31T23:59:59+02:00'). + """ + + direction: Literal["after", "before"] + """ + Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + """ + + exclude_low_priority: Annotated[Optional[bool], PropertyInfo(alias="excludeLowPriority")] + """Exclude messages marked Low Priority by the user. + + Default: true. Set to false to include all. + """ + + include_muted: Annotated[Optional[bool], PropertyInfo(alias="includeMuted")] + """ + Include messages in chats marked as Muted by the user, which are usually less + important. Default: true. Set to false if the user wants a more refined search. + """ + + limit: int + """Maximum number of messages to return (1–500). + + Defaults to 20. The current implementation caps each page at 20 items even if a + higher limit is requested. + """ + + media_types: Annotated[List[Literal["any", "video", "image", "link", "file"]], PropertyInfo(alias="mediaTypes")] + """Filter messages by media types. + + Use ['any'] for any media type, or specify exact types like ['video', 'image']. + Omit for no media filtering. + """ + + query: str + """Literal word search (NOT semantic). + + Finds messages containing these EXACT words in any order. Use single words users + actually type, not concepts or phrases. Example: use "dinner" not "dinner + plans", use "sick" not "health issues". If omitted, returns results filtered + only by other parameters. + """ + + sender: Union[Literal["me", "others"], str] + """ + Filter by sender: 'me' (messages sent by the authenticated user), 'others' + (messages sent by others), or a specific user ID string (user.id). + """ diff --git a/src/beeper_desktop_api/types/message_send_params.py b/src/beeper_desktop_api/types/message_send_params.py new file mode 100644 index 0000000..8b05d6a --- /dev/null +++ b/src/beeper_desktop_api/types/message_send_params.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["MessageSendParams"] + + +class MessageSendParams(TypedDict, total=False): + chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] + """Unique identifier of the chat.""" + + reply_to_message_id: Annotated[str, PropertyInfo(alias="replyToMessageID")] + """Provide a message ID to send this as a reply to an existing message""" + + text: str + """Text content of the message you want to send. You may use markdown.""" diff --git a/src/beeper_desktop_api/types/message_send_response.py b/src/beeper_desktop_api/types/message_send_response.py new file mode 100644 index 0000000..05cc535 --- /dev/null +++ b/src/beeper_desktop_api/types/message_send_response.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from pydantic import Field as FieldInfo + +from .shared.base_response import BaseResponse + +__all__ = ["MessageSendResponse"] + + +class MessageSendResponse(BaseResponse): + chat_id: str = FieldInfo(alias="chatID") + """Unique identifier of the chat.""" + + pending_message_id: str = FieldInfo(alias="pendingMessageID") + """Pending message ID""" diff --git a/src/beeper_desktop_api/types/open_response.py b/src/beeper_desktop_api/types/open_response.py new file mode 100644 index 0000000..970f2ba --- /dev/null +++ b/src/beeper_desktop_api/types/open_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["OpenResponse"] + + +class OpenResponse(BaseModel): + success: bool + """Whether the app was successfully opened/focused.""" diff --git a/src/beeper_desktop_api/types/search_response.py b/src/beeper_desktop_api/types/search_response.py new file mode 100644 index 0000000..fe5113c --- /dev/null +++ b/src/beeper_desktop_api/types/search_response.py @@ -0,0 +1,48 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional + +from pydantic import Field as FieldInfo + +from .chat import Chat +from .._models import BaseModel +from .shared.message import Message + +__all__ = ["SearchResponse", "Results", "ResultsMessages"] + + +class ResultsMessages(BaseModel): + chats: Dict[str, Chat] + """Map of chatID -> chat details for chats referenced in items.""" + + has_more: bool = FieldInfo(alias="hasMore") + """True if additional results can be fetched using the provided cursors.""" + + items: List[Message] + """Messages matching the query and filters.""" + + newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", default=None) + """Cursor for fetching newer results (use with direction='after'). + + Opaque string; do not inspect. + """ + + oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) + """Cursor for fetching older results (use with direction='before'). + + Opaque string; do not inspect. + """ + + +class Results(BaseModel): + chats: List[Chat] + """Top chat results.""" + + in_groups: List[Chat] + """Top group results by participant matches.""" + + messages: ResultsMessages + + +class SearchResponse(BaseModel): + results: Results diff --git a/src/beeper_desktop_api/types/shared/error.py b/src/beeper_desktop_api/types/shared/error.py index 1f82efd..e5b5a77 100644 --- a/src/beeper_desktop_api/types/shared/error.py +++ b/src/beeper_desktop_api/types/shared/error.py @@ -1,18 +1,10 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Dict, Optional - from ..._models import BaseModel __all__ = ["Error"] class Error(BaseModel): - error: str - """Error message""" - - code: Optional[str] = None - """Error code""" - - details: Optional[Dict[str, str]] = None - """Additional error details""" + error: Error + """Error details""" diff --git a/tests/api_resources/chats/__init__.py b/tests/api_resources/chats/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/api_resources/chats/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/chats/test_reminders.py b/tests/api_resources/chats/test_reminders.py new file mode 100644 index 0000000..fea1bcb --- /dev/null +++ b/tests/api_resources/chats/test_reminders.py @@ -0,0 +1,206 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types.shared import BaseResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestReminders: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_create(self, client: BeeperDesktop) -> None: + reminder = client.chats.reminders.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, + ) + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: + reminder = client.chats.reminders.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={ + "remind_at_ms": 0, + "dismiss_on_incoming_message": True, + }, + ) + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + def test_raw_response_create(self, client: BeeperDesktop) -> None: + response = client.chats.reminders.with_raw_response.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + reminder = response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + def test_streaming_response_create(self, client: BeeperDesktop) -> None: + with client.chats.reminders.with_streaming_response.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + reminder = response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_create(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.chats.reminders.with_raw_response.create( + chat_id="", + reminder={"remind_at_ms": 0}, + ) + + @parametrize + def test_method_delete(self, client: BeeperDesktop) -> None: + reminder = client.chats.reminders.delete( + "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + def test_raw_response_delete(self, client: BeeperDesktop) -> None: + response = client.chats.reminders.with_raw_response.delete( + "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + reminder = response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + def test_streaming_response_delete(self, client: BeeperDesktop) -> None: + with client.chats.reminders.with_streaming_response.delete( + "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + reminder = response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_delete(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.chats.reminders.with_raw_response.delete( + "", + ) + + +class TestAsyncReminders: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: + reminder = await async_client.chats.reminders.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, + ) + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + reminder = await async_client.chats.reminders.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={ + "remind_at_ms": 0, + "dismiss_on_incoming_message": True, + }, + ) + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.reminders.with_raw_response.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + reminder = await response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.reminders.with_streaming_response.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + reminder = await response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_create(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.chats.reminders.with_raw_response.create( + chat_id="", + reminder={"remind_at_ms": 0}, + ) + + @parametrize + async def test_method_delete(self, async_client: AsyncBeeperDesktop) -> None: + reminder = await async_client.chats.reminders.delete( + "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + async def test_raw_response_delete(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.reminders.with_raw_response.delete( + "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + reminder = await response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.reminders.with_streaming_response.delete( + "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + reminder = await response.parse() + assert_matches_type(BaseResponse, reminder, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_delete(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.chats.reminders.with_raw_response.delete( + "", + ) diff --git a/tests/api_resources/test_accounts.py b/tests/api_resources/test_accounts.py new file mode 100644 index 0000000..46ac702 --- /dev/null +++ b/tests/api_resources/test_accounts.py @@ -0,0 +1,74 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import AccountListResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAccounts: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_list(self, client: BeeperDesktop) -> None: + account = client.accounts.list() + assert_matches_type(AccountListResponse, account, path=["response"]) + + @parametrize + def test_raw_response_list(self, client: BeeperDesktop) -> None: + response = client.accounts.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + account = response.parse() + assert_matches_type(AccountListResponse, account, path=["response"]) + + @parametrize + def test_streaming_response_list(self, client: BeeperDesktop) -> None: + with client.accounts.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + account = response.parse() + assert_matches_type(AccountListResponse, account, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncAccounts: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: + account = await async_client.accounts.list() + assert_matches_type(AccountListResponse, account, path=["response"]) + + @parametrize + async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.accounts.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + account = await response.parse() + assert_matches_type(AccountListResponse, account, path=["response"]) + + @parametrize + async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.accounts.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + account = await response.parse() + assert_matches_type(AccountListResponse, account, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py new file mode 100644 index 0000000..3eba100 --- /dev/null +++ b/tests/api_resources/test_chats.py @@ -0,0 +1,402 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import ( + Chat, + ChatCreateResponse, +) +from beeper_desktop_api._utils import parse_datetime +from beeper_desktop_api.pagination import SyncCursor, AsyncCursor +from beeper_desktop_api.types.shared import BaseResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestChats: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_create(self, client: BeeperDesktop) -> None: + chat = client.chats.create( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + ) + 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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + message_text="messageText", + title="title", + ) + 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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + @parametrize + def test_streaming_response_create(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.create( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_retrieve(self, client: BeeperDesktop) -> None: + chat = client.chats.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_method_retrieve_with_all_params(self, client: BeeperDesktop) -> None: + chat = client.chats.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + max_participant_count=50, + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_raw_response_retrieve(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_streaming_response_retrieve(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_retrieve(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.chats.with_raw_response.retrieve( + chat_id="", + ) + + @parametrize + def test_method_archive(self, client: BeeperDesktop) -> None: + chat = client.chats.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(BaseResponse, chat, path=["response"]) + + @parametrize + def test_method_archive_with_all_params(self, client: BeeperDesktop) -> None: + chat = client.chats.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + archived=True, + ) + assert_matches_type(BaseResponse, chat, path=["response"]) + + @parametrize + def test_raw_response_archive(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(BaseResponse, chat, path=["response"]) + + @parametrize + def test_streaming_response_archive(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(BaseResponse, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_archive(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.chats.with_raw_response.archive( + chat_id="", + ) + + @parametrize + def test_method_search(self, client: BeeperDesktop) -> None: + chat = client.chats.search() + assert_matches_type(SyncCursor[Chat], chat, path=["response"]) + + @parametrize + def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: + chat = client.chats.search( + account_ids=[ + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", + ], + cursor="eyJvZmZzZXQiOjE3MTk5OTk5OTl9", + direction="after", + inbox="primary", + include_muted=True, + last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), + last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), + limit=1, + query="x", + scope="titles", + type="single", + unread_only=True, + ) + assert_matches_type(SyncCursor[Chat], chat, path=["response"]) + + @parametrize + def test_raw_response_search(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.search() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(SyncCursor[Chat], chat, path=["response"]) + + @parametrize + def test_streaming_response_search(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.search() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(SyncCursor[Chat], chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncChats: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.create( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + ) + 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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + message_text="messageText", + title="title", + ) + 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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + @parametrize + async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.create( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + participant_ids=["string"], + type="single", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_method_retrieve_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + max_participant_count=50, + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.retrieve( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.chats.with_raw_response.retrieve( + chat_id="", + ) + + @parametrize + async def test_method_archive(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(BaseResponse, chat, path=["response"]) + + @parametrize + async def test_method_archive_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + archived=True, + ) + assert_matches_type(BaseResponse, chat, path=["response"]) + + @parametrize + async def test_raw_response_archive(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(BaseResponse, chat, path=["response"]) + + @parametrize + async def test_streaming_response_archive(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.archive( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(BaseResponse, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_archive(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.chats.with_raw_response.archive( + chat_id="", + ) + + @parametrize + async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.search() + assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) + + @parametrize + async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.search( + account_ids=[ + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", + ], + cursor="eyJvZmZzZXQiOjE3MTk5OTk5OTl9", + direction="after", + inbox="primary", + include_muted=True, + last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), + last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), + limit=1, + query="x", + scope="titles", + type="single", + unread_only=True, + ) + assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) + + @parametrize + async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.search() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) + + @parametrize + async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.search() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_client.py b/tests/api_resources/test_client.py new file mode 100644 index 0000000..d5de032 --- /dev/null +++ b/tests/api_resources/test_client.py @@ -0,0 +1,222 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import ( + OpenResponse, + SearchResponse, + DownloadAssetResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestClient: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_download_asset(self, client: BeeperDesktop) -> None: + client_ = client.download_asset( + url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", + ) + assert_matches_type(DownloadAssetResponse, client_, path=["response"]) + + @parametrize + def test_raw_response_download_asset(self, client: BeeperDesktop) -> None: + response = client.with_raw_response.download_asset( + url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client_ = response.parse() + assert_matches_type(DownloadAssetResponse, client_, path=["response"]) + + @parametrize + def test_streaming_response_download_asset(self, client: BeeperDesktop) -> None: + with client.with_streaming_response.download_asset( + url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client_ = response.parse() + assert_matches_type(DownloadAssetResponse, client_, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_open(self, client: BeeperDesktop) -> None: + client_ = client.open() + assert_matches_type(OpenResponse, client_, path=["response"]) + + @parametrize + def test_method_open_with_all_params(self, client: BeeperDesktop) -> None: + client_ = client.open( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + draft_attachment_path="draftAttachmentPath", + draft_text="draftText", + message_id="messageID", + ) + assert_matches_type(OpenResponse, client_, path=["response"]) + + @parametrize + def test_raw_response_open(self, client: BeeperDesktop) -> None: + response = client.with_raw_response.open() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client_ = response.parse() + assert_matches_type(OpenResponse, client_, path=["response"]) + + @parametrize + def test_streaming_response_open(self, client: BeeperDesktop) -> None: + with client.with_streaming_response.open() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client_ = response.parse() + assert_matches_type(OpenResponse, client_, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_search(self, client: BeeperDesktop) -> None: + client_ = client.search( + query="x", + ) + assert_matches_type(SearchResponse, client_, path=["response"]) + + @parametrize + def test_raw_response_search(self, client: BeeperDesktop) -> None: + response = client.with_raw_response.search( + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client_ = response.parse() + assert_matches_type(SearchResponse, client_, path=["response"]) + + @parametrize + def test_streaming_response_search(self, client: BeeperDesktop) -> None: + with client.with_streaming_response.search( + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client_ = response.parse() + assert_matches_type(SearchResponse, client_, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncClient: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_download_asset(self, async_client: AsyncBeeperDesktop) -> None: + client = await async_client.download_asset( + url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", + ) + assert_matches_type(DownloadAssetResponse, client, path=["response"]) + + @parametrize + async def test_raw_response_download_asset(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.with_raw_response.download_asset( + url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client = await response.parse() + assert_matches_type(DownloadAssetResponse, client, path=["response"]) + + @parametrize + async def test_streaming_response_download_asset(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.with_streaming_response.download_asset( + url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client = await response.parse() + assert_matches_type(DownloadAssetResponse, client, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_open(self, async_client: AsyncBeeperDesktop) -> None: + client = await async_client.open() + assert_matches_type(OpenResponse, client, path=["response"]) + + @parametrize + async def test_method_open_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + client = await async_client.open( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + draft_attachment_path="draftAttachmentPath", + draft_text="draftText", + message_id="messageID", + ) + assert_matches_type(OpenResponse, client, path=["response"]) + + @parametrize + async def test_raw_response_open(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.with_raw_response.open() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client = await response.parse() + assert_matches_type(OpenResponse, client, path=["response"]) + + @parametrize + async def test_streaming_response_open(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.with_streaming_response.open() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client = await response.parse() + assert_matches_type(OpenResponse, client, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: + client = await async_client.search( + query="x", + ) + assert_matches_type(SearchResponse, client, path=["response"]) + + @parametrize + async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.with_raw_response.search( + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client = await response.parse() + assert_matches_type(SearchResponse, client, path=["response"]) + + @parametrize + async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.with_streaming_response.search( + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client = await response.parse() + assert_matches_type(SearchResponse, client, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_contacts.py b/tests/api_resources/test_contacts.py new file mode 100644 index 0000000..6308d1f --- /dev/null +++ b/tests/api_resources/test_contacts.py @@ -0,0 +1,92 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import ContactSearchResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestContacts: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_search(self, client: BeeperDesktop) -> None: + contact = client.contacts.search( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + @parametrize + def test_raw_response_search(self, client: BeeperDesktop) -> None: + response = client.contacts.with_raw_response.search( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + contact = response.parse() + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + @parametrize + def test_streaming_response_search(self, client: BeeperDesktop) -> None: + with client.contacts.with_streaming_response.search( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + contact = response.parse() + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncContacts: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: + contact = await async_client.contacts.search( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + @parametrize + async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.contacts.with_raw_response.search( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + contact = await response.parse() + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + @parametrize + async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.contacts.with_streaming_response.search( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + contact = await response.parse() + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py new file mode 100644 index 0000000..eb5fc6e --- /dev/null +++ b/tests/api_resources/test_messages.py @@ -0,0 +1,203 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import MessageSendResponse +from beeper_desktop_api._utils import parse_datetime +from beeper_desktop_api.pagination import SyncCursor, AsyncCursor +from beeper_desktop_api.types.shared import Message + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestMessages: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_search(self, client: BeeperDesktop) -> None: + message = client.messages.search() + assert_matches_type(SyncCursor[Message], message, path=["response"]) + + @parametrize + def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: + message = client.messages.search( + account_ids=[ + "whatsapp", + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", + ], + chat_ids=["!NCdzlIaMjZUmvmvyHU:beeper.com", "1231073"], + chat_type="group", + cursor="1725489123456|c29tZUltc2dQYWdl", + date_after=parse_datetime("2025-08-01T00:00:00Z"), + date_before=parse_datetime("2025-08-31T23:59:59Z"), + direction="before", + exclude_low_priority=True, + include_muted=True, + limit=20, + media_types=["any"], + query="dinner", + sender="me", + ) + assert_matches_type(SyncCursor[Message], message, path=["response"]) + + @parametrize + def test_raw_response_search(self, client: BeeperDesktop) -> None: + response = client.messages.with_raw_response.search() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = response.parse() + assert_matches_type(SyncCursor[Message], message, path=["response"]) + + @parametrize + def test_streaming_response_search(self, client: BeeperDesktop) -> None: + with client.messages.with_streaming_response.search() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = response.parse() + assert_matches_type(SyncCursor[Message], message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_send(self, client: BeeperDesktop) -> None: + message = client.messages.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(MessageSendResponse, message, path=["response"]) + + @parametrize + def test_method_send_with_all_params(self, client: BeeperDesktop) -> None: + message = client.messages.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reply_to_message_id="replyToMessageID", + text="text", + ) + assert_matches_type(MessageSendResponse, message, path=["response"]) + + @parametrize + def test_raw_response_send(self, client: BeeperDesktop) -> None: + response = client.messages.with_raw_response.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = response.parse() + assert_matches_type(MessageSendResponse, message, path=["response"]) + + @parametrize + def test_streaming_response_send(self, client: BeeperDesktop) -> None: + with client.messages.with_streaming_response.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = response.parse() + assert_matches_type(MessageSendResponse, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncMessages: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.search() + assert_matches_type(AsyncCursor[Message], message, path=["response"]) + + @parametrize + async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.search( + account_ids=[ + "whatsapp", + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", + ], + chat_ids=["!NCdzlIaMjZUmvmvyHU:beeper.com", "1231073"], + chat_type="group", + cursor="1725489123456|c29tZUltc2dQYWdl", + date_after=parse_datetime("2025-08-01T00:00:00Z"), + date_before=parse_datetime("2025-08-31T23:59:59Z"), + direction="before", + exclude_low_priority=True, + include_muted=True, + limit=20, + media_types=["any"], + query="dinner", + sender="me", + ) + assert_matches_type(AsyncCursor[Message], message, path=["response"]) + + @parametrize + async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.messages.with_raw_response.search() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = await response.parse() + assert_matches_type(AsyncCursor[Message], message, path=["response"]) + + @parametrize + async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.messages.with_streaming_response.search() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = await response.parse() + assert_matches_type(AsyncCursor[Message], message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_send(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(MessageSendResponse, message, path=["response"]) + + @parametrize + async def test_method_send_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reply_to_message_id="replyToMessageID", + text="text", + ) + assert_matches_type(MessageSendResponse, message, path=["response"]) + + @parametrize + async def test_raw_response_send(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.messages.with_raw_response.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = await response.parse() + assert_matches_type(MessageSendResponse, message, path=["response"]) + + @parametrize + async def test_streaming_response_send(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.messages.with_streaming_response.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = await response.parse() + assert_matches_type(MessageSendResponse, message, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/test_client.py b/tests/test_client.py index 1450af1..c9e3e9e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -747,20 +747,20 @@ def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: - respx_mock.get("/oauth/userinfo").mock(side_effect=httpx.TimeoutException("Test timeout error")) + respx_mock.get("/v1/accounts").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - client.token.with_streaming_response.info().__enter__() + client.accounts.with_streaming_response.list().__enter__() assert _get_open_connections(self.client) == 0 @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: - respx_mock.get("/oauth/userinfo").mock(return_value=httpx.Response(500)) + respx_mock.get("/v1/accounts").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - client.token.with_streaming_response.info().__enter__() + client.accounts.with_streaming_response.list().__enter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -787,9 +787,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500) return httpx.Response(200) - respx_mock.get("/oauth/userinfo").mock(side_effect=retry_handler) + respx_mock.get("/v1/accounts").mock(side_effect=retry_handler) - response = client.token.with_raw_response.info() + response = client.accounts.with_raw_response.list() assert response.retries_taken == failures_before_success assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success @@ -811,9 +811,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500) return httpx.Response(200) - respx_mock.get("/oauth/userinfo").mock(side_effect=retry_handler) + respx_mock.get("/v1/accounts").mock(side_effect=retry_handler) - response = client.token.with_raw_response.info(extra_headers={"x-stainless-retry-count": Omit()}) + response = client.accounts.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()}) assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 @@ -834,9 +834,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500) return httpx.Response(200) - respx_mock.get("/oauth/userinfo").mock(side_effect=retry_handler) + respx_mock.get("/v1/accounts").mock(side_effect=retry_handler) - response = client.token.with_raw_response.info(extra_headers={"x-stainless-retry-count": "42"}) + response = client.accounts.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"}) assert response.http_request.headers.get("x-stainless-retry-count") == "42" @@ -1584,10 +1584,10 @@ async def test_parse_retry_after_header(self, remaining_retries: int, retry_afte async def test_retrying_timeout_errors_doesnt_leak( self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop ) -> None: - respx_mock.get("/oauth/userinfo").mock(side_effect=httpx.TimeoutException("Test timeout error")) + respx_mock.get("/v1/accounts").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - await async_client.token.with_streaming_response.info().__aenter__() + await async_client.accounts.with_streaming_response.list().__aenter__() assert _get_open_connections(self.client) == 0 @@ -1596,10 +1596,10 @@ async def test_retrying_timeout_errors_doesnt_leak( async def test_retrying_status_errors_doesnt_leak( self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop ) -> None: - respx_mock.get("/oauth/userinfo").mock(return_value=httpx.Response(500)) + respx_mock.get("/v1/accounts").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - await async_client.token.with_streaming_response.info().__aenter__() + await async_client.accounts.with_streaming_response.list().__aenter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -1627,9 +1627,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500) return httpx.Response(200) - respx_mock.get("/oauth/userinfo").mock(side_effect=retry_handler) + respx_mock.get("/v1/accounts").mock(side_effect=retry_handler) - response = await client.token.with_raw_response.info() + response = await client.accounts.with_raw_response.list() assert response.retries_taken == failures_before_success assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success @@ -1652,9 +1652,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500) return httpx.Response(200) - respx_mock.get("/oauth/userinfo").mock(side_effect=retry_handler) + respx_mock.get("/v1/accounts").mock(side_effect=retry_handler) - response = await client.token.with_raw_response.info(extra_headers={"x-stainless-retry-count": Omit()}) + response = await client.accounts.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()}) assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 @@ -1676,9 +1676,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500) return httpx.Response(200) - respx_mock.get("/oauth/userinfo").mock(side_effect=retry_handler) + respx_mock.get("/v1/accounts").mock(side_effect=retry_handler) - response = await client.token.with_raw_response.info(extra_headers={"x-stainless-retry-count": "42"}) + response = await client.accounts.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"}) assert response.http_request.headers.get("x-stainless-retry-count") == "42" From 57f10839b4d45b1869afd5174bb235b4474254a1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:02:04 +0000 Subject: [PATCH 06/98] feat(api): manual updates --- .stats.yml | 4 +- api.md | 4 +- src/beeper_desktop_api/resources/accounts.py | 4 +- .../resources/chats/chats.py | 133 +++++++++++++++++- src/beeper_desktop_api/resources/messages.py | 130 ++++++++++++++++- src/beeper_desktop_api/types/__init__.py | 3 + .../types/chat_list_params.py | 30 ++++ .../types/chat_list_response.py | 13 ++ .../types/message_list_params.py | 27 ++++ tests/api_resources/test_chats.py | 79 +++++++++++ tests/api_resources/test_messages.py | 86 ++++++++++- 11 files changed, 505 insertions(+), 8 deletions(-) create mode 100644 src/beeper_desktop_api/types/chat_list_params.py create mode 100644 src/beeper_desktop_api/types/chat_list_response.py create mode 100644 src/beeper_desktop_api/types/message_list_params.py diff --git a/.stats.yml b/.stats.yml index 8526f3e..e531b74 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 14 +configured_endpoints: 16 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-9f5190d7df873112f3512b5796cd95341f0fa0d2585488d3e829be80ee6045ce.yml openapi_spec_hash: ba834200758376aaea47b2a276f64c1b -config_hash: f83b2b6eb86f2dd68101065998479cb2 +config_hash: be3f3b31e322be0f4de6a23e32ab004c diff --git a/api.md b/api.md index cfe2dc3..dbf9b16 100644 --- a/api.md +++ b/api.md @@ -47,13 +47,14 @@ Methods: Types: ```python -from beeper_desktop_api.types import Chat, ChatCreateResponse +from beeper_desktop_api.types import Chat, ChatCreateResponse, ChatListResponse ``` Methods: - client.chats.create(\*\*params) -> ChatCreateResponse - client.chats.retrieve(chat_id, \*\*params) -> Chat +- client.chats.list(\*\*params) -> SyncCursor[ChatListResponse] - client.chats.archive(chat_id, \*\*params) -> BaseResponse - client.chats.search(\*\*params) -> SyncCursor[Chat] @@ -74,6 +75,7 @@ from beeper_desktop_api.types import MessageSendResponse Methods: +- client.messages.list(\*\*params) -> SyncCursor[Message] - client.messages.search(\*\*params) -> SyncCursor[Message] - client.messages.send(\*\*params) -> MessageSendResponse diff --git a/src/beeper_desktop_api/resources/accounts.py b/src/beeper_desktop_api/resources/accounts.py index 49a5df2..1210fce 100644 --- a/src/beeper_desktop_api/resources/accounts.py +++ b/src/beeper_desktop_api/resources/accounts.py @@ -20,7 +20,7 @@ class AccountsResource(SyncAPIResource): - """Accounts operations""" + """Manage connected chat accounts""" @cached_property def with_raw_response(self) -> AccountsResourceWithRawResponse: @@ -65,7 +65,7 @@ def list( class AsyncAccountsResource(AsyncAPIResource): - """Accounts operations""" + """Manage connected chat accounts""" @cached_property def with_raw_response(self) -> AsyncAccountsResourceWithRawResponse: diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index b5ec602..93587e7 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -8,7 +8,7 @@ import httpx -from ...types import chat_create_params, chat_search_params, chat_archive_params, chat_retrieve_params +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, NotGiven, SequenceNotStr, omit, not_given from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property @@ -30,6 +30,7 @@ from ...pagination import SyncCursor, AsyncCursor from ...types.chat import Chat from ..._base_client import AsyncPaginator, make_request_options +from ...types.chat_list_response import ChatListResponse from ...types.chat_create_response import ChatCreateResponse from ...types.shared.base_response import BaseResponse @@ -166,6 +167,65 @@ def retrieve( cast_to=Chat, ) + def list( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + limit: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SyncCursor[ChatListResponse]: + """List all chats sorted by last activity (most recent first). + + Combines all + accounts into a single paginated list. + + Args: + account_ids: Limit to specific account IDs. If omitted, fetches from all accounts. + + cursor: Timestamp cursor (milliseconds since epoch) for pagination. Use with direction + to navigate results. + + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + + limit: Maximum number of chats to return (1–200). Defaults to 50. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v1/chats", + page=SyncCursor[ChatListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "cursor": cursor, + "direction": direction, + "limit": limit, + }, + chat_list_params.ChatListParams, + ), + ), + model=ChatListResponse, + ) + def archive( self, chat_id: str, @@ -436,6 +496,65 @@ async def retrieve( cast_to=Chat, ) + def list( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + limit: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncPaginator[ChatListResponse, AsyncCursor[ChatListResponse]]: + """List all chats sorted by last activity (most recent first). + + Combines all + accounts into a single paginated list. + + Args: + account_ids: Limit to specific account IDs. If omitted, fetches from all accounts. + + cursor: Timestamp cursor (milliseconds since epoch) for pagination. Use with direction + to navigate results. + + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + + limit: Maximum number of chats to return (1–200). Defaults to 50. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v1/chats", + page=AsyncCursor[ChatListResponse], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "cursor": cursor, + "direction": direction, + "limit": limit, + }, + chat_list_params.ChatListParams, + ), + ), + model=ChatListResponse, + ) + async def archive( self, chat_id: str, @@ -586,6 +705,9 @@ def __init__(self, chats: ChatsResource) -> None: self.retrieve = to_raw_response_wrapper( chats.retrieve, ) + self.list = to_raw_response_wrapper( + chats.list, + ) self.archive = to_raw_response_wrapper( chats.archive, ) @@ -609,6 +731,9 @@ def __init__(self, chats: AsyncChatsResource) -> None: self.retrieve = async_to_raw_response_wrapper( chats.retrieve, ) + self.list = async_to_raw_response_wrapper( + chats.list, + ) self.archive = async_to_raw_response_wrapper( chats.archive, ) @@ -632,6 +757,9 @@ def __init__(self, chats: ChatsResource) -> None: self.retrieve = to_streamed_response_wrapper( chats.retrieve, ) + self.list = to_streamed_response_wrapper( + chats.list, + ) self.archive = to_streamed_response_wrapper( chats.archive, ) @@ -655,6 +783,9 @@ def __init__(self, chats: AsyncChatsResource) -> None: self.retrieve = async_to_streamed_response_wrapper( chats.retrieve, ) + self.list = async_to_streamed_response_wrapper( + chats.list, + ) self.archive = async_to_streamed_response_wrapper( chats.archive, ) diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index ea1ea25..485a86b 100644 --- a/src/beeper_desktop_api/resources/messages.py +++ b/src/beeper_desktop_api/resources/messages.py @@ -8,7 +8,7 @@ import httpx -from ..types import message_send_params, message_search_params +from ..types import message_list_params, message_send_params, message_search_params from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property @@ -49,6 +49,64 @@ def with_streaming_response(self) -> MessagesResourceWithStreamingResponse: """ return MessagesResourceWithStreamingResponse(self) + def list( + self, + *, + chat_id: str, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + limit: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SyncCursor[Message]: + """List all messages in a chat with cursor-based pagination. + + Sorted by timestamp. + + Args: + chat_id: The chat ID to list messages from + + cursor: Message cursor for pagination. Use with direction to navigate results. + + direction: Pagination direction used with 'cursor': 'before' fetches older messages, + 'after' fetches newer messages. Defaults to 'before' when only 'cursor' is + provided. + + limit: Maximum number of messages to return (1–500). Defaults to 50. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v1/messages", + page=SyncCursor[Message], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "chat_id": chat_id, + "cursor": cursor, + "direction": direction, + "limit": limit, + }, + message_list_params.MessageListParams, + ), + ), + model=Message, + ) + def search( self, *, @@ -223,6 +281,64 @@ def with_streaming_response(self) -> AsyncMessagesResourceWithStreamingResponse: """ return AsyncMessagesResourceWithStreamingResponse(self) + def list( + self, + *, + chat_id: str, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + limit: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncPaginator[Message, AsyncCursor[Message]]: + """List all messages in a chat with cursor-based pagination. + + Sorted by timestamp. + + Args: + chat_id: The chat ID to list messages from + + cursor: Message cursor for pagination. Use with direction to navigate results. + + direction: Pagination direction used with 'cursor': 'before' fetches older messages, + 'after' fetches newer messages. Defaults to 'before' when only 'cursor' is + provided. + + limit: Maximum number of messages to return (1–500). Defaults to 50. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v1/messages", + page=AsyncCursor[Message], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "chat_id": chat_id, + "cursor": cursor, + "direction": direction, + "limit": limit, + }, + message_list_params.MessageListParams, + ), + ), + model=Message, + ) + def search( self, *, @@ -379,6 +495,9 @@ class MessagesResourceWithRawResponse: def __init__(self, messages: MessagesResource) -> None: self._messages = messages + self.list = to_raw_response_wrapper( + messages.list, + ) self.search = to_raw_response_wrapper( messages.search, ) @@ -391,6 +510,9 @@ class AsyncMessagesResourceWithRawResponse: def __init__(self, messages: AsyncMessagesResource) -> None: self._messages = messages + self.list = async_to_raw_response_wrapper( + messages.list, + ) self.search = async_to_raw_response_wrapper( messages.search, ) @@ -403,6 +525,9 @@ class MessagesResourceWithStreamingResponse: def __init__(self, messages: MessagesResource) -> None: self._messages = messages + self.list = to_streamed_response_wrapper( + messages.list, + ) self.search = to_streamed_response_wrapper( messages.search, ) @@ -415,6 +540,9 @@ class AsyncMessagesResourceWithStreamingResponse: def __init__(self, messages: AsyncMessagesResource) -> None: self._messages = messages + self.list = async_to_streamed_response_wrapper( + messages.list, + ) self.search = async_to_streamed_response_wrapper( messages.search, ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index 5bede4c..ffab91e 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -15,10 +15,13 @@ from .user_info import UserInfo as UserInfo from .open_response import OpenResponse as OpenResponse from .search_response import SearchResponse as SearchResponse +from .chat_list_params import ChatListParams as ChatListParams from .chat_create_params import ChatCreateParams as ChatCreateParams +from .chat_list_response import ChatListResponse as ChatListResponse from .chat_search_params import ChatSearchParams as ChatSearchParams from .client_open_params import ClientOpenParams as ClientOpenParams from .chat_archive_params import ChatArchiveParams as ChatArchiveParams +from .message_list_params import MessageListParams as MessageListParams from .message_send_params import MessageSendParams as MessageSendParams from .chat_create_response import ChatCreateResponse as ChatCreateResponse from .chat_retrieve_params import ChatRetrieveParams as ChatRetrieveParams diff --git a/src/beeper_desktop_api/types/chat_list_params.py b/src/beeper_desktop_api/types/chat_list_params.py new file mode 100644 index 0000000..d8e1784 --- /dev/null +++ b/src/beeper_desktop_api/types/chat_list_params.py @@ -0,0 +1,30 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Annotated, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo + +__all__ = ["ChatListParams"] + + +class ChatListParams(TypedDict, total=False): + account_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="accountIDs")] + """Limit to specific account IDs. If omitted, fetches from all accounts.""" + + cursor: str + """Timestamp cursor (milliseconds since epoch) for pagination. + + Use with direction to navigate results. + """ + + direction: Literal["after", "before"] + """ + Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + """ + + limit: int + """Maximum number of chats to return (1–200). Defaults to 50.""" diff --git a/src/beeper_desktop_api/types/chat_list_response.py b/src/beeper_desktop_api/types/chat_list_response.py new file mode 100644 index 0000000..80e3885 --- /dev/null +++ b/src/beeper_desktop_api/types/chat_list_response.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .chat import Chat +from .shared.message import Message + +__all__ = ["ChatListResponse"] + + +class ChatListResponse(Chat): + preview: Optional[Message] = None + """Last message preview for this chat, if available.""" diff --git a/src/beeper_desktop_api/types/message_list_params.py b/src/beeper_desktop_api/types/message_list_params.py new file mode 100644 index 0000000..ca56fab --- /dev/null +++ b/src/beeper_desktop_api/types/message_list_params.py @@ -0,0 +1,27 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["MessageListParams"] + + +class MessageListParams(TypedDict, total=False): + chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] + """The chat ID to list messages from""" + + cursor: str + """Message cursor for pagination. Use with direction to navigate results.""" + + direction: Literal["after", "before"] + """ + Pagination direction used with 'cursor': 'before' fetches older messages, + 'after' fetches newer messages. Defaults to 'before' when only 'cursor' is + provided. + """ + + limit: int + """Maximum number of messages to return (1–500). Defaults to 50.""" diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index 3eba100..3009cc9 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -11,6 +11,7 @@ from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop from beeper_desktop_api.types import ( Chat, + ChatListResponse, ChatCreateResponse, ) from beeper_desktop_api._utils import parse_datetime @@ -117,6 +118,45 @@ def test_path_params_retrieve(self, client: BeeperDesktop) -> None: chat_id="", ) + @parametrize + def test_method_list(self, client: BeeperDesktop) -> None: + chat = client.chats.list() + assert_matches_type(SyncCursor[ChatListResponse], chat, path=["response"]) + + @parametrize + def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: + chat = client.chats.list( + account_ids=[ + "whatsapp", + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", + ], + cursor="1725489123456", + direction="before", + limit=1, + ) + assert_matches_type(SyncCursor[ChatListResponse], chat, path=["response"]) + + @parametrize + def test_raw_response_list(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(SyncCursor[ChatListResponse], chat, path=["response"]) + + @parametrize + def test_streaming_response_list(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(SyncCursor[ChatListResponse], chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + @parametrize def test_method_archive(self, client: BeeperDesktop) -> None: chat = client.chats.archive( @@ -309,6 +349,45 @@ async def test_path_params_retrieve(self, async_client: AsyncBeeperDesktop) -> N chat_id="", ) + @parametrize + async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.list() + assert_matches_type(AsyncCursor[ChatListResponse], chat, path=["response"]) + + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.list( + account_ids=[ + "whatsapp", + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", + ], + cursor="1725489123456", + direction="before", + limit=1, + ) + assert_matches_type(AsyncCursor[ChatListResponse], chat, path=["response"]) + + @parametrize + async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(AsyncCursor[ChatListResponse], chat, path=["response"]) + + @parametrize + async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(AsyncCursor[ChatListResponse], chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + @parametrize async def test_method_archive(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.archive( diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index eb5fc6e..85ebebb 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -9,7 +9,9 @@ from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types import MessageSendResponse +from beeper_desktop_api.types import ( + MessageSendResponse, +) from beeper_desktop_api._utils import parse_datetime from beeper_desktop_api.pagination import SyncCursor, AsyncCursor from beeper_desktop_api.types.shared import Message @@ -20,6 +22,47 @@ class TestMessages: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + @parametrize + def test_method_list(self, client: BeeperDesktop) -> None: + message = client.messages.list( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(SyncCursor[Message], message, path=["response"]) + + @parametrize + def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: + message = client.messages.list( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + cursor="821744079", + direction="before", + limit=1, + ) + assert_matches_type(SyncCursor[Message], message, path=["response"]) + + @parametrize + def test_raw_response_list(self, client: BeeperDesktop) -> None: + response = client.messages.with_raw_response.list( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = response.parse() + assert_matches_type(SyncCursor[Message], message, path=["response"]) + + @parametrize + def test_streaming_response_list(self, client: BeeperDesktop) -> None: + with client.messages.with_streaming_response.list( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = response.parse() + assert_matches_type(SyncCursor[Message], message, path=["response"]) + + assert cast(Any, response.is_closed) is True + @parametrize def test_method_search(self, client: BeeperDesktop) -> None: message = client.messages.search() @@ -114,6 +157,47 @@ class TestAsyncMessages: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) + @parametrize + async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.list( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(AsyncCursor[Message], message, path=["response"]) + + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.list( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + cursor="821744079", + direction="before", + limit=1, + ) + assert_matches_type(AsyncCursor[Message], message, path=["response"]) + + @parametrize + async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.messages.with_raw_response.list( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = await response.parse() + assert_matches_type(AsyncCursor[Message], message, path=["response"]) + + @parametrize + async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.messages.with_streaming_response.list( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = await response.parse() + assert_matches_type(AsyncCursor[Message], message, path=["response"]) + + assert cast(Any, response.is_closed) is True + @parametrize async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.search() From 3d5ce68d5fc040ba65aafd80184b00491aae4626 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:07:31 +0000 Subject: [PATCH 07/98] feat(api): manual updates --- .stats.yml | 2 +- api.md | 20 +-- src/beeper_desktop_api/_client.py | 61 ++++++-- src/beeper_desktop_api/resources/__init__.py | 14 -- src/beeper_desktop_api/resources/token.py | 139 ------------------ src/beeper_desktop_api/types/__init__.py | 2 +- ...ser_info.py => get_token_info_response.py} | 4 +- tests/api_resources/test_client.py | 51 +++++++ tests/api_resources/test_token.py | 74 ---------- 9 files changed, 114 insertions(+), 253 deletions(-) delete mode 100644 src/beeper_desktop_api/resources/token.py rename src/beeper_desktop_api/types/{user_info.py => get_token_info_response.py} (89%) delete mode 100644 tests/api_resources/test_token.py diff --git a/.stats.yml b/.stats.yml index e531b74..3cb504d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 16 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-9f5190d7df873112f3512b5796cd95341f0fa0d2585488d3e829be80ee6045ce.yml openapi_spec_hash: ba834200758376aaea47b2a276f64c1b -config_hash: be3f3b31e322be0f4de6a23e32ab004c +config_hash: 00db138e547960c0d9c47754c2f59051 diff --git a/api.md b/api.md index dbf9b16..502d7e8 100644 --- a/api.md +++ b/api.md @@ -9,12 +9,18 @@ from beeper_desktop_api.types import Attachment, BaseResponse, Error, Message, R Types: ```python -from beeper_desktop_api.types import DownloadAssetResponse, OpenResponse, SearchResponse +from beeper_desktop_api.types import ( + DownloadAssetResponse, + GetTokenInfoResponse, + OpenResponse, + SearchResponse, +) ``` Methods: - client.download_asset(\*\*params) -> DownloadAssetResponse +- client.get_token_info() -> GetTokenInfoResponse - client.open(\*\*params) -> OpenResponse - client.search(\*\*params) -> SearchResponse @@ -78,15 +84,3 @@ Methods: - client.messages.list(\*\*params) -> SyncCursor[Message] - client.messages.search(\*\*params) -> SyncCursor[Message] - client.messages.send(\*\*params) -> MessageSendResponse - -# Token - -Types: - -```python -from beeper_desktop_api.types import UserInfo -``` - -Methods: - -- client.token.info() -> UserInfo diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index 7a1a10e..01dd758 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -37,7 +37,7 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from .resources import token, accounts, contacts, messages +from .resources import accounts, contacts, messages from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError, BeeperDesktopError from ._base_client import ( @@ -50,6 +50,7 @@ from .types.open_response import OpenResponse from .types.search_response import SearchResponse from .types.download_asset_response import DownloadAssetResponse +from .types.get_token_info_response import GetTokenInfoResponse __all__ = [ "Timeout", @@ -68,7 +69,6 @@ class BeeperDesktop(SyncAPIClient): contacts: contacts.ContactsResource chats: chats.ChatsResource messages: messages.MessagesResource - token: token.TokenResource with_raw_response: BeeperDesktopWithRawResponse with_streaming_response: BeeperDesktopWithStreamedResponse @@ -130,7 +130,6 @@ def __init__( self.contacts = contacts.ContactsResource(self) self.chats = chats.ChatsResource(self) self.messages = messages.MessagesResource(self) - self.token = token.TokenResource(self) self.with_raw_response = BeeperDesktopWithRawResponse(self) self.with_streaming_response = BeeperDesktopWithStreamedResponse(self) @@ -240,6 +239,25 @@ def download_asset( cast_to=DownloadAssetResponse, ) + def get_token_info( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> GetTokenInfoResponse: + """Returns information about the authenticated user/token""" + return self.get( + "/oauth/userinfo", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=GetTokenInfoResponse, + ) + def open( self, *, @@ -371,7 +389,6 @@ class AsyncBeeperDesktop(AsyncAPIClient): contacts: contacts.AsyncContactsResource chats: chats.AsyncChatsResource messages: messages.AsyncMessagesResource - token: token.AsyncTokenResource with_raw_response: AsyncBeeperDesktopWithRawResponse with_streaming_response: AsyncBeeperDesktopWithStreamedResponse @@ -433,7 +450,6 @@ def __init__( self.contacts = contacts.AsyncContactsResource(self) self.chats = chats.AsyncChatsResource(self) self.messages = messages.AsyncMessagesResource(self) - self.token = token.AsyncTokenResource(self) self.with_raw_response = AsyncBeeperDesktopWithRawResponse(self) self.with_streaming_response = AsyncBeeperDesktopWithStreamedResponse(self) @@ -543,6 +559,25 @@ async def download_asset( cast_to=DownloadAssetResponse, ) + async def get_token_info( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> GetTokenInfoResponse: + """Returns information about the authenticated user/token""" + return await self.get( + "/oauth/userinfo", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=GetTokenInfoResponse, + ) + async def open( self, *, @@ -675,11 +710,13 @@ def __init__(self, client: BeeperDesktop) -> None: self.contacts = contacts.ContactsResourceWithRawResponse(client.contacts) self.chats = chats.ChatsResourceWithRawResponse(client.chats) self.messages = messages.MessagesResourceWithRawResponse(client.messages) - self.token = token.TokenResourceWithRawResponse(client.token) self.download_asset = to_raw_response_wrapper( client.download_asset, ) + self.get_token_info = to_raw_response_wrapper( + client.get_token_info, + ) self.open = to_raw_response_wrapper( client.open, ) @@ -694,11 +731,13 @@ def __init__(self, client: AsyncBeeperDesktop) -> None: self.contacts = contacts.AsyncContactsResourceWithRawResponse(client.contacts) self.chats = chats.AsyncChatsResourceWithRawResponse(client.chats) self.messages = messages.AsyncMessagesResourceWithRawResponse(client.messages) - self.token = token.AsyncTokenResourceWithRawResponse(client.token) self.download_asset = async_to_raw_response_wrapper( client.download_asset, ) + self.get_token_info = async_to_raw_response_wrapper( + client.get_token_info, + ) self.open = async_to_raw_response_wrapper( client.open, ) @@ -713,11 +752,13 @@ def __init__(self, client: BeeperDesktop) -> None: self.contacts = contacts.ContactsResourceWithStreamingResponse(client.contacts) self.chats = chats.ChatsResourceWithStreamingResponse(client.chats) self.messages = messages.MessagesResourceWithStreamingResponse(client.messages) - self.token = token.TokenResourceWithStreamingResponse(client.token) self.download_asset = to_streamed_response_wrapper( client.download_asset, ) + self.get_token_info = to_streamed_response_wrapper( + client.get_token_info, + ) self.open = to_streamed_response_wrapper( client.open, ) @@ -732,11 +773,13 @@ def __init__(self, client: AsyncBeeperDesktop) -> None: self.contacts = contacts.AsyncContactsResourceWithStreamingResponse(client.contacts) self.chats = chats.AsyncChatsResourceWithStreamingResponse(client.chats) self.messages = messages.AsyncMessagesResourceWithStreamingResponse(client.messages) - self.token = token.AsyncTokenResourceWithStreamingResponse(client.token) self.download_asset = async_to_streamed_response_wrapper( client.download_asset, ) + self.get_token_info = async_to_streamed_response_wrapper( + client.get_token_info, + ) self.open = async_to_streamed_response_wrapper( client.open, ) diff --git a/src/beeper_desktop_api/resources/__init__.py b/src/beeper_desktop_api/resources/__init__.py index 24ab242..ebf006b 100644 --- a/src/beeper_desktop_api/resources/__init__.py +++ b/src/beeper_desktop_api/resources/__init__.py @@ -8,14 +8,6 @@ ChatsResourceWithStreamingResponse, AsyncChatsResourceWithStreamingResponse, ) -from .token import ( - TokenResource, - AsyncTokenResource, - TokenResourceWithRawResponse, - AsyncTokenResourceWithRawResponse, - TokenResourceWithStreamingResponse, - AsyncTokenResourceWithStreamingResponse, -) from .accounts import ( AccountsResource, AsyncAccountsResource, @@ -66,10 +58,4 @@ "AsyncMessagesResourceWithRawResponse", "MessagesResourceWithStreamingResponse", "AsyncMessagesResourceWithStreamingResponse", - "TokenResource", - "AsyncTokenResource", - "TokenResourceWithRawResponse", - "AsyncTokenResourceWithRawResponse", - "TokenResourceWithStreamingResponse", - "AsyncTokenResourceWithStreamingResponse", ] diff --git a/src/beeper_desktop_api/resources/token.py b/src/beeper_desktop_api/resources/token.py deleted file mode 100644 index 5648872..0000000 --- a/src/beeper_desktop_api/resources/token.py +++ /dev/null @@ -1,139 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import httpx - -from .._types import Body, Query, Headers, NotGiven, not_given -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from .._base_client import make_request_options -from ..types.user_info import UserInfo - -__all__ = ["TokenResource", "AsyncTokenResource"] - - -class TokenResource(SyncAPIResource): - """Operations related to the current access token""" - - @cached_property - def with_raw_response(self) -> TokenResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers - """ - return TokenResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> TokenResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response - """ - return TokenResourceWithStreamingResponse(self) - - def info( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> UserInfo: - """Returns information about the authenticated user/token""" - return self._get( - "/oauth/userinfo", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=UserInfo, - ) - - -class AsyncTokenResource(AsyncAPIResource): - """Operations related to the current access token""" - - @cached_property - def with_raw_response(self) -> AsyncTokenResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers - """ - return AsyncTokenResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncTokenResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response - """ - return AsyncTokenResourceWithStreamingResponse(self) - - async def info( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> UserInfo: - """Returns information about the authenticated user/token""" - return await self._get( - "/oauth/userinfo", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=UserInfo, - ) - - -class TokenResourceWithRawResponse: - def __init__(self, token: TokenResource) -> None: - self._token = token - - self.info = to_raw_response_wrapper( - token.info, - ) - - -class AsyncTokenResourceWithRawResponse: - def __init__(self, token: AsyncTokenResource) -> None: - self._token = token - - self.info = async_to_raw_response_wrapper( - token.info, - ) - - -class TokenResourceWithStreamingResponse: - def __init__(self, token: TokenResource) -> None: - self._token = token - - self.info = to_streamed_response_wrapper( - token.info, - ) - - -class AsyncTokenResourceWithStreamingResponse: - def __init__(self, token: AsyncTokenResource) -> None: - self._token = token - - self.info = async_to_streamed_response_wrapper( - token.info, - ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index ffab91e..d778ad9 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -12,7 +12,6 @@ BaseResponse as BaseResponse, ) from .account import Account as Account -from .user_info import UserInfo as UserInfo from .open_response import OpenResponse as OpenResponse from .search_response import SearchResponse as SearchResponse from .chat_list_params import ChatListParams as ChatListParams @@ -32,4 +31,5 @@ from .message_send_response import MessageSendResponse as MessageSendResponse from .contact_search_response import ContactSearchResponse as ContactSearchResponse from .download_asset_response import DownloadAssetResponse as DownloadAssetResponse +from .get_token_info_response import GetTokenInfoResponse as GetTokenInfoResponse from .client_download_asset_params import ClientDownloadAssetParams as ClientDownloadAssetParams diff --git a/src/beeper_desktop_api/types/user_info.py b/src/beeper_desktop_api/types/get_token_info_response.py similarity index 89% rename from src/beeper_desktop_api/types/user_info.py rename to src/beeper_desktop_api/types/get_token_info_response.py index d023e31..5dcf865 100644 --- a/src/beeper_desktop_api/types/user_info.py +++ b/src/beeper_desktop_api/types/get_token_info_response.py @@ -5,10 +5,10 @@ from .._models import BaseModel -__all__ = ["UserInfo"] +__all__ = ["GetTokenInfoResponse"] -class UserInfo(BaseModel): +class GetTokenInfoResponse(BaseModel): iat: float """Issued at timestamp (Unix epoch seconds)""" diff --git a/tests/api_resources/test_client.py b/tests/api_resources/test_client.py index d5de032..45f7fd0 100644 --- a/tests/api_resources/test_client.py +++ b/tests/api_resources/test_client.py @@ -12,6 +12,7 @@ from beeper_desktop_api.types import ( OpenResponse, SearchResponse, + GetTokenInfoResponse, DownloadAssetResponse, ) @@ -52,6 +53,31 @@ def test_streaming_response_download_asset(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True + @parametrize + def test_method_get_token_info(self, client: BeeperDesktop) -> None: + client_ = client.get_token_info() + assert_matches_type(GetTokenInfoResponse, client_, path=["response"]) + + @parametrize + def test_raw_response_get_token_info(self, client: BeeperDesktop) -> None: + response = client.with_raw_response.get_token_info() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client_ = response.parse() + assert_matches_type(GetTokenInfoResponse, client_, path=["response"]) + + @parametrize + def test_streaming_response_get_token_info(self, client: BeeperDesktop) -> None: + with client.with_streaming_response.get_token_info() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client_ = response.parse() + assert_matches_type(GetTokenInfoResponse, client_, path=["response"]) + + assert cast(Any, response.is_closed) is True + @parametrize def test_method_open(self, client: BeeperDesktop) -> None: client_ = client.open() @@ -155,6 +181,31 @@ async def test_streaming_response_download_asset(self, async_client: AsyncBeeper assert cast(Any, response.is_closed) is True + @parametrize + async def test_method_get_token_info(self, async_client: AsyncBeeperDesktop) -> None: + client = await async_client.get_token_info() + assert_matches_type(GetTokenInfoResponse, client, path=["response"]) + + @parametrize + async def test_raw_response_get_token_info(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.with_raw_response.get_token_info() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + client = await response.parse() + assert_matches_type(GetTokenInfoResponse, client, path=["response"]) + + @parametrize + async def test_streaming_response_get_token_info(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.with_streaming_response.get_token_info() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + client = await response.parse() + assert_matches_type(GetTokenInfoResponse, client, path=["response"]) + + assert cast(Any, response.is_closed) is True + @parametrize async def test_method_open(self, async_client: AsyncBeeperDesktop) -> None: client = await async_client.open() diff --git a/tests/api_resources/test_token.py b/tests/api_resources/test_token.py deleted file mode 100644 index 538aa77..0000000 --- a/tests/api_resources/test_token.py +++ /dev/null @@ -1,74 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from tests.utils import assert_matches_type -from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types import UserInfo - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestToken: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @parametrize - def test_method_info(self, client: BeeperDesktop) -> None: - token = client.token.info() - assert_matches_type(UserInfo, token, path=["response"]) - - @parametrize - def test_raw_response_info(self, client: BeeperDesktop) -> None: - response = client.token.with_raw_response.info() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - token = response.parse() - assert_matches_type(UserInfo, token, path=["response"]) - - @parametrize - def test_streaming_response_info(self, client: BeeperDesktop) -> None: - with client.token.with_streaming_response.info() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - token = response.parse() - assert_matches_type(UserInfo, token, path=["response"]) - - assert cast(Any, response.is_closed) is True - - -class TestAsyncToken: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @parametrize - async def test_method_info(self, async_client: AsyncBeeperDesktop) -> None: - token = await async_client.token.info() - assert_matches_type(UserInfo, token, path=["response"]) - - @parametrize - async def test_raw_response_info(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.token.with_raw_response.info() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - token = await response.parse() - assert_matches_type(UserInfo, token, path=["response"]) - - @parametrize - async def test_streaming_response_info(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.token.with_streaming_response.info() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - token = await response.parse() - assert_matches_type(UserInfo, token, path=["response"]) - - assert cast(Any, response.is_closed) is True From a2c72dd31d4a71a4e728a604c79d4b5fd986a297 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:13:33 +0000 Subject: [PATCH 08/98] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 3cb504d..dffcc04 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 16 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-9f5190d7df873112f3512b5796cd95341f0fa0d2585488d3e829be80ee6045ce.yml openapi_spec_hash: ba834200758376aaea47b2a276f64c1b -config_hash: 00db138e547960c0d9c47754c2f59051 +config_hash: 382b53633aa9cc48d7b7f44ecf5e3e8c From 15a1087791f56d536d79c1f8cef334915c67b2cd Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:15:27 +0000 Subject: [PATCH 09/98] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index dffcc04..b4e4371 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 16 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-9f5190d7df873112f3512b5796cd95341f0fa0d2585488d3e829be80ee6045ce.yml openapi_spec_hash: ba834200758376aaea47b2a276f64c1b -config_hash: 382b53633aa9cc48d7b7f44ecf5e3e8c +config_hash: 58f19d979ad9a375e32b814503ce3e86 From d411e41929158e4d1eb902477a7cbf5b39d2d005 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:17:37 +0000 Subject: [PATCH 10/98] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index b4e4371..5b8f55b 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 16 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-9f5190d7df873112f3512b5796cd95341f0fa0d2585488d3e829be80ee6045ce.yml openapi_spec_hash: ba834200758376aaea47b2a276f64c1b -config_hash: 58f19d979ad9a375e32b814503ce3e86 +config_hash: a9434fa7b77fc01af6e667f3717eb768 From 36fbc51aea30ac9dbf86910aec83874a3b81166b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:22:36 +0000 Subject: [PATCH 11/98] feat(api): manual updates --- .stats.yml | 8 +-- api.md | 12 +--- src/beeper_desktop_api/_client.py | 59 ++----------------- src/beeper_desktop_api/types/__init__.py | 1 - .../types/get_token_info_response.py | 31 ---------- tests/api_resources/test_client.py | 51 ---------------- 6 files changed, 11 insertions(+), 151 deletions(-) delete mode 100644 src/beeper_desktop_api/types/get_token_info_response.py diff --git a/.stats.yml b/.stats.yml index 5b8f55b..d64bab5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 16 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-9f5190d7df873112f3512b5796cd95341f0fa0d2585488d3e829be80ee6045ce.yml -openapi_spec_hash: ba834200758376aaea47b2a276f64c1b -config_hash: a9434fa7b77fc01af6e667f3717eb768 +configured_endpoints: 15 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-953cbc1ea1fe675bf2d32b18030a3ac509c521946921cb338c0d1c2cfef89424.yml +openapi_spec_hash: b4d08ca2dc21bc00245c9c9408be89ef +config_hash: d48fc12c89d2d812adf19d0508306f4a diff --git a/api.md b/api.md index 502d7e8..529a147 100644 --- a/api.md +++ b/api.md @@ -9,19 +9,13 @@ from beeper_desktop_api.types import Attachment, BaseResponse, Error, Message, R Types: ```python -from beeper_desktop_api.types import ( - DownloadAssetResponse, - GetTokenInfoResponse, - OpenResponse, - SearchResponse, -) +from beeper_desktop_api.types import DownloadAssetResponse, OpenResponse, SearchResponse ``` Methods: -- client.download_asset(\*\*params) -> DownloadAssetResponse -- client.get_token_info() -> GetTokenInfoResponse -- client.open(\*\*params) -> OpenResponse +- client.download_asset(\*\*params) -> DownloadAssetResponse +- client.open(\*\*params) -> OpenResponse - client.search(\*\*params) -> SearchResponse # Accounts diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index 01dd758..f8ba71d 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -50,7 +50,6 @@ from .types.open_response import OpenResponse from .types.search_response import SearchResponse from .types.download_asset_response import DownloadAssetResponse -from .types.get_token_info_response import GetTokenInfoResponse __all__ = [ "Timeout", @@ -231,7 +230,7 @@ def download_asset( timeout: Override the client-level default timeout for this request, in seconds """ return self.post( - "/v1/app/download-asset", + "/v1/download-asset", body=maybe_transform({"url": url}, client_download_asset_params.ClientDownloadAssetParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -239,25 +238,6 @@ def download_asset( cast_to=DownloadAssetResponse, ) - def get_token_info( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> GetTokenInfoResponse: - """Returns information about the authenticated user/token""" - return self.get( - "/oauth/userinfo", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=GetTokenInfoResponse, - ) - def open( self, *, @@ -295,7 +275,7 @@ def open( timeout: Override the client-level default timeout for this request, in seconds """ return self.post( - "/v1/app/open", + "/v1/open", body=maybe_transform( { "chat_id": chat_id, @@ -551,7 +531,7 @@ async def download_asset( timeout: Override the client-level default timeout for this request, in seconds """ return await self.post( - "/v1/app/download-asset", + "/v1/download-asset", body=await async_maybe_transform({"url": url}, client_download_asset_params.ClientDownloadAssetParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -559,25 +539,6 @@ async def download_asset( cast_to=DownloadAssetResponse, ) - async def get_token_info( - self, - *, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> GetTokenInfoResponse: - """Returns information about the authenticated user/token""" - return await self.get( - "/oauth/userinfo", - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=GetTokenInfoResponse, - ) - async def open( self, *, @@ -615,7 +576,7 @@ async def open( timeout: Override the client-level default timeout for this request, in seconds """ return await self.post( - "/v1/app/open", + "/v1/open", body=await async_maybe_transform( { "chat_id": chat_id, @@ -714,9 +675,6 @@ def __init__(self, client: BeeperDesktop) -> None: self.download_asset = to_raw_response_wrapper( client.download_asset, ) - self.get_token_info = to_raw_response_wrapper( - client.get_token_info, - ) self.open = to_raw_response_wrapper( client.open, ) @@ -735,9 +693,6 @@ def __init__(self, client: AsyncBeeperDesktop) -> None: self.download_asset = async_to_raw_response_wrapper( client.download_asset, ) - self.get_token_info = async_to_raw_response_wrapper( - client.get_token_info, - ) self.open = async_to_raw_response_wrapper( client.open, ) @@ -756,9 +711,6 @@ def __init__(self, client: BeeperDesktop) -> None: self.download_asset = to_streamed_response_wrapper( client.download_asset, ) - self.get_token_info = to_streamed_response_wrapper( - client.get_token_info, - ) self.open = to_streamed_response_wrapper( client.open, ) @@ -777,9 +729,6 @@ def __init__(self, client: AsyncBeeperDesktop) -> None: self.download_asset = async_to_streamed_response_wrapper( client.download_asset, ) - self.get_token_info = async_to_streamed_response_wrapper( - client.get_token_info, - ) self.open = async_to_streamed_response_wrapper( client.open, ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index d778ad9..e577cbf 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -31,5 +31,4 @@ from .message_send_response import MessageSendResponse as MessageSendResponse from .contact_search_response import ContactSearchResponse as ContactSearchResponse from .download_asset_response import DownloadAssetResponse as DownloadAssetResponse -from .get_token_info_response import GetTokenInfoResponse as GetTokenInfoResponse from .client_download_asset_params import ClientDownloadAssetParams as ClientDownloadAssetParams diff --git a/src/beeper_desktop_api/types/get_token_info_response.py b/src/beeper_desktop_api/types/get_token_info_response.py deleted file mode 100644 index 5dcf865..0000000 --- a/src/beeper_desktop_api/types/get_token_info_response.py +++ /dev/null @@ -1,31 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Optional -from typing_extensions import Literal - -from .._models import BaseModel - -__all__ = ["GetTokenInfoResponse"] - - -class GetTokenInfoResponse(BaseModel): - iat: float - """Issued at timestamp (Unix epoch seconds)""" - - scope: str - """Granted scopes""" - - sub: str - """Subject identifier (token ID)""" - - token_use: Literal["access"] - """Token type""" - - aud: Optional[str] = None - """Audience (client ID)""" - - client_id: Optional[str] = None - """Client identifier""" - - exp: Optional[float] = None - """Expiration timestamp (Unix epoch seconds)""" diff --git a/tests/api_resources/test_client.py b/tests/api_resources/test_client.py index 45f7fd0..d5de032 100644 --- a/tests/api_resources/test_client.py +++ b/tests/api_resources/test_client.py @@ -12,7 +12,6 @@ from beeper_desktop_api.types import ( OpenResponse, SearchResponse, - GetTokenInfoResponse, DownloadAssetResponse, ) @@ -53,31 +52,6 @@ def test_streaming_response_download_asset(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True - @parametrize - def test_method_get_token_info(self, client: BeeperDesktop) -> None: - client_ = client.get_token_info() - assert_matches_type(GetTokenInfoResponse, client_, path=["response"]) - - @parametrize - def test_raw_response_get_token_info(self, client: BeeperDesktop) -> None: - response = client.with_raw_response.get_token_info() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - client_ = response.parse() - assert_matches_type(GetTokenInfoResponse, client_, path=["response"]) - - @parametrize - def test_streaming_response_get_token_info(self, client: BeeperDesktop) -> None: - with client.with_streaming_response.get_token_info() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - client_ = response.parse() - assert_matches_type(GetTokenInfoResponse, client_, path=["response"]) - - assert cast(Any, response.is_closed) is True - @parametrize def test_method_open(self, client: BeeperDesktop) -> None: client_ = client.open() @@ -181,31 +155,6 @@ async def test_streaming_response_download_asset(self, async_client: AsyncBeeper assert cast(Any, response.is_closed) is True - @parametrize - async def test_method_get_token_info(self, async_client: AsyncBeeperDesktop) -> None: - client = await async_client.get_token_info() - assert_matches_type(GetTokenInfoResponse, client, path=["response"]) - - @parametrize - async def test_raw_response_get_token_info(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.with_raw_response.get_token_info() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - client = await response.parse() - assert_matches_type(GetTokenInfoResponse, client, path=["response"]) - - @parametrize - async def test_streaming_response_get_token_info(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.with_streaming_response.get_token_info() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - client = await response.parse() - assert_matches_type(GetTokenInfoResponse, client, path=["response"]) - - assert cast(Any, response.is_closed) is True - @parametrize async def test_method_open(self, async_client: AsyncBeeperDesktop) -> None: client = await async_client.open() From 9bbaf1c5b176cb0c4a4814c852f2b487b7cb7e10 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:27:46 +0000 Subject: [PATCH 12/98] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index d64bab5..2621f51 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-953cbc1ea1fe675bf2d32b18030a3ac509c521946921cb338c0d1c2cfef89424.yml openapi_spec_hash: b4d08ca2dc21bc00245c9c9408be89ef -config_hash: d48fc12c89d2d812adf19d0508306f4a +config_hash: b43f460701263c30aba16a32385b20ed From 6c203acaf7826869af50a26ca1548399d005f4c5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:32:17 +0000 Subject: [PATCH 13/98] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 2621f51..103ad16 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-953cbc1ea1fe675bf2d32b18030a3ac509c521946921cb338c0d1c2cfef89424.yml openapi_spec_hash: b4d08ca2dc21bc00245c9c9408be89ef -config_hash: b43f460701263c30aba16a32385b20ed +config_hash: 738402ade5ac9528c8ef1677aa1d70f7 From 4a57182eaf3d28d3e918e2886f2d9b47a176ec1f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:24:15 +0000 Subject: [PATCH 14/98] feat(api): manual updates --- .stats.yml | 2 +- README.md | 79 ------------------- api.md | 4 +- src/beeper_desktop_api/pagination.py | 67 +++++++++++++++- src/beeper_desktop_api/resources/messages.py | 19 +++-- src/beeper_desktop_api/types/__init__.py | 1 + .../types/message_search_response.py | 34 ++++++++ tests/api_resources/test_messages.py | 17 ++-- 8 files changed, 121 insertions(+), 102 deletions(-) create mode 100644 src/beeper_desktop_api/types/message_search_response.py diff --git a/.stats.yml b/.stats.yml index 103ad16..134452d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-953cbc1ea1fe675bf2d32b18030a3ac509c521946921cb338c0d1c2cfef89424.yml openapi_spec_hash: b4d08ca2dc21bc00245c9c9408be89ef -config_hash: 738402ade5ac9528c8ef1677aa1d70f7 +config_hash: 4fb2010b528ce4358300ddd10e750265 diff --git a/README.md b/README.md index 9f0e7ba..0f8fafb 100644 --- a/README.md +++ b/README.md @@ -118,85 +118,6 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. -## Pagination - -List methods in the Beeper Desktop API are paginated. - -This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually: - -```python -from beeper_desktop_api import BeeperDesktop - -client = BeeperDesktop() - -all_messages = [] -# Automatically fetches more pages as needed. -for message in client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], - limit=10, - query="deployment", -): - # Do something with message here - all_messages.append(message) -print(all_messages) -``` - -Or, asynchronously: - -```python -import asyncio -from beeper_desktop_api import AsyncBeeperDesktop - -client = AsyncBeeperDesktop() - - -async def main() -> None: - all_messages = [] - # Iterate through items across all pages, issuing requests as needed. - async for message in client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], - limit=10, - query="deployment", - ): - all_messages.append(message) - print(all_messages) - - -asyncio.run(main()) -``` - -Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages: - -```python -first_page = await client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], - limit=10, - query="deployment", -) -if first_page.has_next_page(): - print(f"will fetch next page using these details: {first_page.next_page_info()}") - next_page = await first_page.get_next_page() - print(f"number of items we just fetched: {len(next_page.items)}") - -# Remove `await` for non-async usage. -``` - -Or just work directly with the returned data: - -```python -first_page = await client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], - limit=10, - query="deployment", -) - -print(f"next page cursor: {first_page.oldest_cursor}") # => "next page cursor: ..." -for message in first_page.items: - print(message.id) - -# Remove `await` for non-async usage. -``` - ## Nested params Nested parameters are dictionaries, typed using `TypedDict`, for example: diff --git a/api.md b/api.md index 529a147..e862d8e 100644 --- a/api.md +++ b/api.md @@ -70,11 +70,11 @@ Methods: Types: ```python -from beeper_desktop_api.types import MessageSendResponse +from beeper_desktop_api.types import MessageSearchResponse, MessageSendResponse ``` Methods: - client.messages.list(\*\*params) -> SyncCursor[Message] -- client.messages.search(\*\*params) -> SyncCursor[Message] +- client.messages.search(\*\*params) -> MessageSearchResponse - client.messages.send(\*\*params) -> MessageSendResponse diff --git a/src/beeper_desktop_api/pagination.py b/src/beeper_desktop_api/pagination.py index 4606312..806a7a0 100644 --- a/src/beeper_desktop_api/pagination.py +++ b/src/beeper_desktop_api/pagination.py @@ -1,13 +1,14 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List, Generic, TypeVar, Optional +from typing import Dict, List, Generic, TypeVar, Optional from typing_extensions import override from pydantic import Field as FieldInfo +from .types.chat import Chat from ._base_client import BasePage, PageInfo, BaseSyncPage, BaseAsyncPage -__all__ = ["SyncCursor", "AsyncCursor"] +__all__ = ["SyncCursor", "AsyncCursor", "SyncCursorWithChats", "AsyncCursorWithChats"] _T = TypeVar("_T") @@ -70,3 +71,65 @@ def next_page_info(self) -> Optional[PageInfo]: return None return PageInfo(params={"cursor": oldest_cursor}) + + +class SyncCursorWithChats(BaseSyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + chats: Optional[Dict[str, Chat]] = None + has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) + oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) + newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", 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]: + oldest_cursor = self.oldest_cursor + if not oldest_cursor: + return None + + return PageInfo(params={"cursor": oldest_cursor}) + + +class AsyncCursorWithChats(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + chats: Optional[Dict[str, Chat]] = None + has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) + oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) + newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", 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]: + oldest_cursor = self.oldest_cursor + if not oldest_cursor: + return None + + return PageInfo(params={"cursor": oldest_cursor}) diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index 485a86b..d7d40ef 100644 --- a/src/beeper_desktop_api/resources/messages.py +++ b/src/beeper_desktop_api/resources/messages.py @@ -23,6 +23,7 @@ from .._base_client import AsyncPaginator, make_request_options from ..types.shared.message import Message from ..types.message_send_response import MessageSendResponse +from ..types.message_search_response import MessageSearchResponse __all__ = ["MessagesResource", "AsyncMessagesResource"] @@ -129,7 +130,7 @@ def search( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursor[Message]: + ) -> MessageSearchResponse: """ Search messages across chats using Beeper's message index @@ -179,9 +180,8 @@ def search( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get_api_list( + return self._get( "/v1/messages/search", - page=SyncCursor[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -206,7 +206,7 @@ def search( message_search_params.MessageSearchParams, ), ), - model=Message, + cast_to=MessageSearchResponse, ) def send( @@ -339,7 +339,7 @@ def list( model=Message, ) - def search( + async def search( self, *, account_ids: SequenceNotStr[str] | Omit = omit, @@ -361,7 +361,7 @@ def search( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[Message, AsyncCursor[Message]]: + ) -> MessageSearchResponse: """ Search messages across chats using Beeper's message index @@ -411,15 +411,14 @@ def search( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get_api_list( + return await self._get( "/v1/messages/search", - page=AsyncCursor[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform( + query=await async_maybe_transform( { "account_ids": account_ids, "chat_ids": chat_ids, @@ -438,7 +437,7 @@ def search( message_search_params.MessageSearchParams, ), ), - model=Message, + cast_to=MessageSearchResponse, ) async def send( diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index e577cbf..83e9ef1 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -31,4 +31,5 @@ from .message_send_response import MessageSendResponse as MessageSendResponse from .contact_search_response import ContactSearchResponse as ContactSearchResponse from .download_asset_response import DownloadAssetResponse as DownloadAssetResponse +from .message_search_response import MessageSearchResponse as MessageSearchResponse from .client_download_asset_params import ClientDownloadAssetParams as ClientDownloadAssetParams diff --git a/src/beeper_desktop_api/types/message_search_response.py b/src/beeper_desktop_api/types/message_search_response.py new file mode 100644 index 0000000..51f3d6f --- /dev/null +++ b/src/beeper_desktop_api/types/message_search_response.py @@ -0,0 +1,34 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional + +from pydantic import Field as FieldInfo + +from .chat import Chat +from .._models import BaseModel +from .shared.message import Message + +__all__ = ["MessageSearchResponse"] + + +class MessageSearchResponse(BaseModel): + chats: Dict[str, Chat] + """Map of chatID -> chat details for chats referenced in items.""" + + has_more: bool = FieldInfo(alias="hasMore") + """True if additional results can be fetched using the provided cursors.""" + + items: List[Message] + """Messages matching the query and filters.""" + + newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", default=None) + """Cursor for fetching newer results (use with direction='after'). + + Opaque string; do not inspect. + """ + + oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) + """Cursor for fetching older results (use with direction='before'). + + Opaque string; do not inspect. + """ diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index 85ebebb..7853ea0 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -11,6 +11,7 @@ from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop from beeper_desktop_api.types import ( MessageSendResponse, + MessageSearchResponse, ) from beeper_desktop_api._utils import parse_datetime from beeper_desktop_api.pagination import SyncCursor, AsyncCursor @@ -66,7 +67,7 @@ def test_streaming_response_list(self, client: BeeperDesktop) -> None: @parametrize def test_method_search(self, client: BeeperDesktop) -> None: message = client.messages.search() - assert_matches_type(SyncCursor[Message], message, path=["response"]) + assert_matches_type(MessageSearchResponse, message, path=["response"]) @parametrize def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: @@ -89,7 +90,7 @@ def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: query="dinner", sender="me", ) - assert_matches_type(SyncCursor[Message], message, path=["response"]) + assert_matches_type(MessageSearchResponse, message, path=["response"]) @parametrize def test_raw_response_search(self, client: BeeperDesktop) -> None: @@ -98,7 +99,7 @@ def test_raw_response_search(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(SyncCursor[Message], message, path=["response"]) + assert_matches_type(MessageSearchResponse, message, path=["response"]) @parametrize def test_streaming_response_search(self, client: BeeperDesktop) -> None: @@ -107,7 +108,7 @@ def test_streaming_response_search(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = response.parse() - assert_matches_type(SyncCursor[Message], message, path=["response"]) + assert_matches_type(MessageSearchResponse, message, path=["response"]) assert cast(Any, response.is_closed) is True @@ -201,7 +202,7 @@ async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) - @parametrize async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.search() - assert_matches_type(AsyncCursor[Message], message, path=["response"]) + assert_matches_type(MessageSearchResponse, message, path=["response"]) @parametrize async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -224,7 +225,7 @@ async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesk query="dinner", sender="me", ) - assert_matches_type(AsyncCursor[Message], message, path=["response"]) + assert_matches_type(MessageSearchResponse, message, path=["response"]) @parametrize async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: @@ -233,7 +234,7 @@ async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = await response.parse() - assert_matches_type(AsyncCursor[Message], message, path=["response"]) + assert_matches_type(MessageSearchResponse, message, path=["response"]) @parametrize async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: @@ -242,7 +243,7 @@ async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = await response.parse() - assert_matches_type(AsyncCursor[Message], message, path=["response"]) + assert_matches_type(MessageSearchResponse, message, path=["response"]) assert cast(Any, response.is_closed) is True From 71f6e2be8de0c0503763ea8d6ebbb86f69238fd0 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:35:45 +0000 Subject: [PATCH 15/98] feat(api): manual updates --- .stats.yml | 6 +- README.md | 79 +++++++++++++++++++ api.md | 4 +- src/beeper_desktop_api/pagination.py | 67 +--------------- src/beeper_desktop_api/resources/messages.py | 19 ++--- src/beeper_desktop_api/types/__init__.py | 1 - .../types/message_search_response.py | 34 -------- tests/api_resources/test_messages.py | 17 ++-- 8 files changed, 104 insertions(+), 123 deletions(-) delete mode 100644 src/beeper_desktop_api/types/message_search_response.py diff --git a/.stats.yml b/.stats.yml index 134452d..8831e71 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-953cbc1ea1fe675bf2d32b18030a3ac509c521946921cb338c0d1c2cfef89424.yml -openapi_spec_hash: b4d08ca2dc21bc00245c9c9408be89ef -config_hash: 4fb2010b528ce4358300ddd10e750265 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-803a4423d75f7a43582319924f0770153fd5ec313b9466c290513b9a891c2653.yml +openapi_spec_hash: f32dfbf172bb043fd8c961cba5f73765 +config_hash: 738402ade5ac9528c8ef1677aa1d70f7 diff --git a/README.md b/README.md index 0f8fafb..9f0e7ba 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,85 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. +## Pagination + +List methods in the Beeper Desktop API are paginated. + +This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually: + +```python +from beeper_desktop_api import BeeperDesktop + +client = BeeperDesktop() + +all_messages = [] +# Automatically fetches more pages as needed. +for message in client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", +): + # Do something with message here + all_messages.append(message) +print(all_messages) +``` + +Or, asynchronously: + +```python +import asyncio +from beeper_desktop_api import AsyncBeeperDesktop + +client = AsyncBeeperDesktop() + + +async def main() -> None: + all_messages = [] + # Iterate through items across all pages, issuing requests as needed. + async for message in client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", + ): + all_messages.append(message) + print(all_messages) + + +asyncio.run(main()) +``` + +Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages: + +```python +first_page = await client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", +) +if first_page.has_next_page(): + print(f"will fetch next page using these details: {first_page.next_page_info()}") + next_page = await first_page.get_next_page() + print(f"number of items we just fetched: {len(next_page.items)}") + +# Remove `await` for non-async usage. +``` + +Or just work directly with the returned data: + +```python +first_page = await client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", +) + +print(f"next page cursor: {first_page.oldest_cursor}") # => "next page cursor: ..." +for message in first_page.items: + print(message.id) + +# Remove `await` for non-async usage. +``` + ## Nested params Nested parameters are dictionaries, typed using `TypedDict`, for example: diff --git a/api.md b/api.md index e862d8e..529a147 100644 --- a/api.md +++ b/api.md @@ -70,11 +70,11 @@ Methods: Types: ```python -from beeper_desktop_api.types import MessageSearchResponse, MessageSendResponse +from beeper_desktop_api.types import MessageSendResponse ``` Methods: - client.messages.list(\*\*params) -> SyncCursor[Message] -- client.messages.search(\*\*params) -> MessageSearchResponse +- client.messages.search(\*\*params) -> SyncCursor[Message] - client.messages.send(\*\*params) -> MessageSendResponse diff --git a/src/beeper_desktop_api/pagination.py b/src/beeper_desktop_api/pagination.py index 806a7a0..4606312 100644 --- a/src/beeper_desktop_api/pagination.py +++ b/src/beeper_desktop_api/pagination.py @@ -1,14 +1,13 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Dict, List, Generic, TypeVar, Optional +from typing import List, Generic, TypeVar, Optional from typing_extensions import override from pydantic import Field as FieldInfo -from .types.chat import Chat from ._base_client import BasePage, PageInfo, BaseSyncPage, BaseAsyncPage -__all__ = ["SyncCursor", "AsyncCursor", "SyncCursorWithChats", "AsyncCursorWithChats"] +__all__ = ["SyncCursor", "AsyncCursor"] _T = TypeVar("_T") @@ -71,65 +70,3 @@ def next_page_info(self) -> Optional[PageInfo]: return None return PageInfo(params={"cursor": oldest_cursor}) - - -class SyncCursorWithChats(BaseSyncPage[_T], BasePage[_T], Generic[_T]): - items: List[_T] - chats: Optional[Dict[str, Chat]] = None - has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) - oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) - newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", 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]: - oldest_cursor = self.oldest_cursor - if not oldest_cursor: - return None - - return PageInfo(params={"cursor": oldest_cursor}) - - -class AsyncCursorWithChats(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): - items: List[_T] - chats: Optional[Dict[str, Chat]] = None - has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) - oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) - newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", 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]: - oldest_cursor = self.oldest_cursor - if not oldest_cursor: - return None - - return PageInfo(params={"cursor": oldest_cursor}) diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index d7d40ef..485a86b 100644 --- a/src/beeper_desktop_api/resources/messages.py +++ b/src/beeper_desktop_api/resources/messages.py @@ -23,7 +23,6 @@ from .._base_client import AsyncPaginator, make_request_options from ..types.shared.message import Message from ..types.message_send_response import MessageSendResponse -from ..types.message_search_response import MessageSearchResponse __all__ = ["MessagesResource", "AsyncMessagesResource"] @@ -130,7 +129,7 @@ def search( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> MessageSearchResponse: + ) -> SyncCursor[Message]: """ Search messages across chats using Beeper's message index @@ -180,8 +179,9 @@ def search( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get( + return self._get_api_list( "/v1/messages/search", + page=SyncCursor[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -206,7 +206,7 @@ def search( message_search_params.MessageSearchParams, ), ), - cast_to=MessageSearchResponse, + model=Message, ) def send( @@ -339,7 +339,7 @@ def list( model=Message, ) - async def search( + def search( self, *, account_ids: SequenceNotStr[str] | Omit = omit, @@ -361,7 +361,7 @@ async def search( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> MessageSearchResponse: + ) -> AsyncPaginator[Message, AsyncCursor[Message]]: """ Search messages across chats using Beeper's message index @@ -411,14 +411,15 @@ async def search( timeout: Override the client-level default timeout for this request, in seconds """ - return await self._get( + return self._get_api_list( "/v1/messages/search", + page=AsyncCursor[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=await async_maybe_transform( + query=maybe_transform( { "account_ids": account_ids, "chat_ids": chat_ids, @@ -437,7 +438,7 @@ async def search( message_search_params.MessageSearchParams, ), ), - cast_to=MessageSearchResponse, + model=Message, ) async def send( diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index 83e9ef1..e577cbf 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -31,5 +31,4 @@ from .message_send_response import MessageSendResponse as MessageSendResponse from .contact_search_response import ContactSearchResponse as ContactSearchResponse from .download_asset_response import DownloadAssetResponse as DownloadAssetResponse -from .message_search_response import MessageSearchResponse as MessageSearchResponse from .client_download_asset_params import ClientDownloadAssetParams as ClientDownloadAssetParams diff --git a/src/beeper_desktop_api/types/message_search_response.py b/src/beeper_desktop_api/types/message_search_response.py deleted file mode 100644 index 51f3d6f..0000000 --- a/src/beeper_desktop_api/types/message_search_response.py +++ /dev/null @@ -1,34 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict, List, Optional - -from pydantic import Field as FieldInfo - -from .chat import Chat -from .._models import BaseModel -from .shared.message import Message - -__all__ = ["MessageSearchResponse"] - - -class MessageSearchResponse(BaseModel): - chats: Dict[str, Chat] - """Map of chatID -> chat details for chats referenced in items.""" - - has_more: bool = FieldInfo(alias="hasMore") - """True if additional results can be fetched using the provided cursors.""" - - items: List[Message] - """Messages matching the query and filters.""" - - newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", default=None) - """Cursor for fetching newer results (use with direction='after'). - - Opaque string; do not inspect. - """ - - oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) - """Cursor for fetching older results (use with direction='before'). - - Opaque string; do not inspect. - """ diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index 7853ea0..85ebebb 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -11,7 +11,6 @@ from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop from beeper_desktop_api.types import ( MessageSendResponse, - MessageSearchResponse, ) from beeper_desktop_api._utils import parse_datetime from beeper_desktop_api.pagination import SyncCursor, AsyncCursor @@ -67,7 +66,7 @@ def test_streaming_response_list(self, client: BeeperDesktop) -> None: @parametrize def test_method_search(self, client: BeeperDesktop) -> None: message = client.messages.search() - assert_matches_type(MessageSearchResponse, message, path=["response"]) + assert_matches_type(SyncCursor[Message], message, path=["response"]) @parametrize def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: @@ -90,7 +89,7 @@ def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: query="dinner", sender="me", ) - assert_matches_type(MessageSearchResponse, message, path=["response"]) + assert_matches_type(SyncCursor[Message], message, path=["response"]) @parametrize def test_raw_response_search(self, client: BeeperDesktop) -> None: @@ -99,7 +98,7 @@ def test_raw_response_search(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(MessageSearchResponse, message, path=["response"]) + assert_matches_type(SyncCursor[Message], message, path=["response"]) @parametrize def test_streaming_response_search(self, client: BeeperDesktop) -> None: @@ -108,7 +107,7 @@ def test_streaming_response_search(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = response.parse() - assert_matches_type(MessageSearchResponse, message, path=["response"]) + assert_matches_type(SyncCursor[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True @@ -202,7 +201,7 @@ async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) - @parametrize async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.search() - assert_matches_type(MessageSearchResponse, message, path=["response"]) + assert_matches_type(AsyncCursor[Message], message, path=["response"]) @parametrize async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -225,7 +224,7 @@ async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesk query="dinner", sender="me", ) - assert_matches_type(MessageSearchResponse, message, path=["response"]) + assert_matches_type(AsyncCursor[Message], message, path=["response"]) @parametrize async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: @@ -234,7 +233,7 @@ async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = await response.parse() - assert_matches_type(MessageSearchResponse, message, path=["response"]) + assert_matches_type(AsyncCursor[Message], message, path=["response"]) @parametrize async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: @@ -243,7 +242,7 @@ async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = await response.parse() - assert_matches_type(MessageSearchResponse, message, path=["response"]) + assert_matches_type(AsyncCursor[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True From efe15d53d93c3d1d51bb67c041976c2f9745c321 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 19:19:53 +0000 Subject: [PATCH 16/98] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 8831e71..baf1a99 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-803a4423d75f7a43582319924f0770153fd5ec313b9466c290513b9a891c2653.yml openapi_spec_hash: f32dfbf172bb043fd8c961cba5f73765 -config_hash: 738402ade5ac9528c8ef1677aa1d70f7 +config_hash: fc42f6a9efd6f34ca68f1c4328272acf From 5f9a944603e116f966c5756717ff32b289837265 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 21:17:25 +0000 Subject: [PATCH 17/98] feat(api): remove limit from list routes --- .stats.yml | 6 +- api.md | 8 +-- src/beeper_desktop_api/pagination.py | 66 ++++++++++++++++++- .../resources/chats/chats.py | 26 +++----- src/beeper_desktop_api/resources/messages.py | 30 ++++----- .../types/chat_list_params.py | 3 - .../types/message_list_params.py | 5 +- tests/api_resources/test_chats.py | 36 +++++----- tests/api_resources/test_messages.py | 36 +++++----- 9 files changed, 125 insertions(+), 91 deletions(-) diff --git a/.stats.yml b/.stats.yml index baf1a99..c2693cc 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-803a4423d75f7a43582319924f0770153fd5ec313b9466c290513b9a891c2653.yml -openapi_spec_hash: f32dfbf172bb043fd8c961cba5f73765 -config_hash: fc42f6a9efd6f34ca68f1c4328272acf +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-a3fb0de6dd98f8a51d73e3fdf51de6143f2e8e764048246392624a56b4a3a481.yml +openapi_spec_hash: 50e1001c340cb0bd3436b6329240769b +config_hash: 2e31d02f28a11ef29eb747bcf559786a diff --git a/api.md b/api.md index 529a147..dd074eb 100644 --- a/api.md +++ b/api.md @@ -54,9 +54,9 @@ Methods: - client.chats.create(\*\*params) -> ChatCreateResponse - client.chats.retrieve(chat_id, \*\*params) -> Chat -- client.chats.list(\*\*params) -> SyncCursor[ChatListResponse] +- client.chats.list(\*\*params) -> SyncCursorList[ChatListResponse] - client.chats.archive(chat_id, \*\*params) -> BaseResponse -- client.chats.search(\*\*params) -> SyncCursor[Chat] +- client.chats.search(\*\*params) -> SyncCursorSearch[Chat] ## Reminders @@ -75,6 +75,6 @@ from beeper_desktop_api.types import MessageSendResponse Methods: -- client.messages.list(\*\*params) -> SyncCursor[Message] -- client.messages.search(\*\*params) -> SyncCursor[Message] +- client.messages.list(\*\*params) -> SyncCursorList[Message] +- client.messages.search(\*\*params) -> SyncCursorSearch[Message] - client.messages.send(\*\*params) -> MessageSendResponse diff --git a/src/beeper_desktop_api/pagination.py b/src/beeper_desktop_api/pagination.py index 4606312..ee568dc 100644 --- a/src/beeper_desktop_api/pagination.py +++ b/src/beeper_desktop_api/pagination.py @@ -7,12 +7,12 @@ from ._base_client import BasePage, PageInfo, BaseSyncPage, BaseAsyncPage -__all__ = ["SyncCursor", "AsyncCursor"] +__all__ = ["SyncCursorSearch", "AsyncCursorSearch", "SyncCursorList", "AsyncCursorList"] _T = TypeVar("_T") -class SyncCursor(BaseSyncPage[_T], BasePage[_T], Generic[_T]): +class SyncCursorSearch(BaseSyncPage[_T], BasePage[_T], Generic[_T]): items: List[_T] has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) @@ -42,7 +42,67 @@ def next_page_info(self) -> Optional[PageInfo]: return PageInfo(params={"cursor": oldest_cursor}) -class AsyncCursor(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): +class AsyncCursorSearch(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) + oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) + newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", 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]: + oldest_cursor = self.oldest_cursor + if not oldest_cursor: + return None + + return PageInfo(params={"cursor": oldest_cursor}) + + +class SyncCursorList(BaseSyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) + oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) + newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", 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]: + oldest_cursor = self.oldest_cursor + if not oldest_cursor: + return None + + return PageInfo(params={"cursor": oldest_cursor}) + + +class AsyncCursorList(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): items: List[_T] has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index 93587e7..7b8d06c 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -27,7 +27,7 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from ...pagination import SyncCursor, AsyncCursor +from ...pagination import SyncCursorList, AsyncCursorList, SyncCursorSearch, AsyncCursorSearch from ...types.chat import Chat from ..._base_client import AsyncPaginator, make_request_options from ...types.chat_list_response import ChatListResponse @@ -173,14 +173,13 @@ def list( account_ids: SequenceNotStr[str] | Omit = omit, cursor: str | Omit = omit, direction: Literal["after", "before"] | Omit = omit, - limit: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursor[ChatListResponse]: + ) -> SyncCursorList[ChatListResponse]: """List all chats sorted by last activity (most recent first). Combines all @@ -195,8 +194,6 @@ def list( direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided. - limit: Maximum number of chats to return (1–200). Defaults to 50. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -207,7 +204,7 @@ def list( """ return self._get_api_list( "/v1/chats", - page=SyncCursor[ChatListResponse], + page=SyncCursorList[ChatListResponse], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -218,7 +215,6 @@ def list( "account_ids": account_ids, "cursor": cursor, "direction": direction, - "limit": limit, }, chat_list_params.ChatListParams, ), @@ -289,7 +285,7 @@ def search( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursor[Chat]: + ) -> SyncCursorSearch[Chat]: """ Search chats by title/network or participants using Beeper Desktop's renderer algorithm. @@ -338,7 +334,7 @@ def search( """ return self._get_api_list( "/v1/chats/search", - page=SyncCursor[Chat], + page=SyncCursorSearch[Chat], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -502,14 +498,13 @@ def list( account_ids: SequenceNotStr[str] | Omit = omit, cursor: str | Omit = omit, direction: Literal["after", "before"] | Omit = omit, - limit: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[ChatListResponse, AsyncCursor[ChatListResponse]]: + ) -> AsyncPaginator[ChatListResponse, AsyncCursorList[ChatListResponse]]: """List all chats sorted by last activity (most recent first). Combines all @@ -524,8 +519,6 @@ def list( direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided. - limit: Maximum number of chats to return (1–200). Defaults to 50. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -536,7 +529,7 @@ def list( """ return self._get_api_list( "/v1/chats", - page=AsyncCursor[ChatListResponse], + page=AsyncCursorList[ChatListResponse], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -547,7 +540,6 @@ def list( "account_ids": account_ids, "cursor": cursor, "direction": direction, - "limit": limit, }, chat_list_params.ChatListParams, ), @@ -618,7 +610,7 @@ def search( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[Chat, AsyncCursor[Chat]]: + ) -> AsyncPaginator[Chat, AsyncCursorSearch[Chat]]: """ Search chats by title/network or participants using Beeper Desktop's renderer algorithm. @@ -667,7 +659,7 @@ def search( """ return self._get_api_list( "/v1/chats/search", - page=AsyncCursor[Chat], + page=AsyncCursorSearch[Chat], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index 485a86b..3732473 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 SyncCursor, AsyncCursor +from ..pagination import SyncCursorList, AsyncCursorList, SyncCursorSearch, AsyncCursorSearch from .._base_client import AsyncPaginator, make_request_options from ..types.shared.message import Message from ..types.message_send_response import MessageSendResponse @@ -55,20 +55,19 @@ def list( chat_id: str, cursor: str | Omit = omit, direction: Literal["after", "before"] | Omit = omit, - limit: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursor[Message]: + ) -> SyncCursorList[Message]: """List all messages in a chat with cursor-based pagination. Sorted by timestamp. Args: - chat_id: The chat ID to list messages from + chat_id: Chat ID to list messages from cursor: Message cursor for pagination. Use with direction to navigate results. @@ -76,8 +75,6 @@ def list( 'after' fetches newer messages. Defaults to 'before' when only 'cursor' is provided. - limit: Maximum number of messages to return (1–500). Defaults to 50. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -88,7 +85,7 @@ def list( """ return self._get_api_list( "/v1/messages", - page=SyncCursor[Message], + page=SyncCursorList[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -99,7 +96,6 @@ def list( "chat_id": chat_id, "cursor": cursor, "direction": direction, - "limit": limit, }, message_list_params.MessageListParams, ), @@ -129,7 +125,7 @@ def search( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursor[Message]: + ) -> SyncCursorSearch[Message]: """ Search messages across chats using Beeper's message index @@ -181,7 +177,7 @@ def search( """ return self._get_api_list( "/v1/messages/search", - page=SyncCursor[Message], + page=SyncCursorSearch[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -287,20 +283,19 @@ def list( chat_id: str, cursor: str | Omit = omit, direction: Literal["after", "before"] | Omit = omit, - limit: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[Message, AsyncCursor[Message]]: + ) -> AsyncPaginator[Message, AsyncCursorList[Message]]: """List all messages in a chat with cursor-based pagination. Sorted by timestamp. Args: - chat_id: The chat ID to list messages from + chat_id: Chat ID to list messages from cursor: Message cursor for pagination. Use with direction to navigate results. @@ -308,8 +303,6 @@ def list( 'after' fetches newer messages. Defaults to 'before' when only 'cursor' is provided. - limit: Maximum number of messages to return (1–500). Defaults to 50. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -320,7 +313,7 @@ def list( """ return self._get_api_list( "/v1/messages", - page=AsyncCursor[Message], + page=AsyncCursorList[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -331,7 +324,6 @@ def list( "chat_id": chat_id, "cursor": cursor, "direction": direction, - "limit": limit, }, message_list_params.MessageListParams, ), @@ -361,7 +353,7 @@ def search( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[Message, AsyncCursor[Message]]: + ) -> AsyncPaginator[Message, AsyncCursorSearch[Message]]: """ Search messages across chats using Beeper's message index @@ -413,7 +405,7 @@ def search( """ return self._get_api_list( "/v1/messages/search", - page=AsyncCursor[Message], + page=AsyncCursorSearch[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, diff --git a/src/beeper_desktop_api/types/chat_list_params.py b/src/beeper_desktop_api/types/chat_list_params.py index d8e1784..e1d10b2 100644 --- a/src/beeper_desktop_api/types/chat_list_params.py +++ b/src/beeper_desktop_api/types/chat_list_params.py @@ -25,6 +25,3 @@ class ChatListParams(TypedDict, total=False): Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided. """ - - limit: int - """Maximum number of chats to return (1–200). Defaults to 50.""" diff --git a/src/beeper_desktop_api/types/message_list_params.py b/src/beeper_desktop_api/types/message_list_params.py index ca56fab..2dd8438 100644 --- a/src/beeper_desktop_api/types/message_list_params.py +++ b/src/beeper_desktop_api/types/message_list_params.py @@ -11,7 +11,7 @@ class MessageListParams(TypedDict, total=False): chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] - """The chat ID to list messages from""" + """Chat ID to list messages from""" cursor: str """Message cursor for pagination. Use with direction to navigate results.""" @@ -22,6 +22,3 @@ class MessageListParams(TypedDict, total=False): 'after' fetches newer messages. Defaults to 'before' when only 'cursor' is provided. """ - - limit: int - """Maximum number of messages to return (1–500). Defaults to 50.""" diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index 3009cc9..352bc2f 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -15,7 +15,7 @@ ChatCreateResponse, ) from beeper_desktop_api._utils import parse_datetime -from beeper_desktop_api.pagination import SyncCursor, AsyncCursor +from beeper_desktop_api.pagination import SyncCursorList, AsyncCursorList, SyncCursorSearch, AsyncCursorSearch from beeper_desktop_api.types.shared import BaseResponse base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -121,7 +121,7 @@ def test_path_params_retrieve(self, client: BeeperDesktop) -> None: @parametrize def test_method_list(self, client: BeeperDesktop) -> None: chat = client.chats.list() - assert_matches_type(SyncCursor[ChatListResponse], chat, path=["response"]) + assert_matches_type(SyncCursorList[ChatListResponse], chat, path=["response"]) @parametrize def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: @@ -133,9 +133,8 @@ def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: ], cursor="1725489123456", direction="before", - limit=1, ) - assert_matches_type(SyncCursor[ChatListResponse], chat, path=["response"]) + assert_matches_type(SyncCursorList[ChatListResponse], chat, path=["response"]) @parametrize def test_raw_response_list(self, client: BeeperDesktop) -> None: @@ -144,7 +143,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" chat = response.parse() - assert_matches_type(SyncCursor[ChatListResponse], chat, path=["response"]) + assert_matches_type(SyncCursorList[ChatListResponse], chat, path=["response"]) @parametrize def test_streaming_response_list(self, client: BeeperDesktop) -> None: @@ -153,7 +152,7 @@ def test_streaming_response_list(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = response.parse() - assert_matches_type(SyncCursor[ChatListResponse], chat, path=["response"]) + assert_matches_type(SyncCursorList[ChatListResponse], chat, path=["response"]) assert cast(Any, response.is_closed) is True @@ -206,7 +205,7 @@ def test_path_params_archive(self, client: BeeperDesktop) -> None: @parametrize def test_method_search(self, client: BeeperDesktop) -> None: chat = client.chats.search() - assert_matches_type(SyncCursor[Chat], chat, path=["response"]) + assert_matches_type(SyncCursorSearch[Chat], chat, path=["response"]) @parametrize def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: @@ -227,7 +226,7 @@ def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: type="single", unread_only=True, ) - assert_matches_type(SyncCursor[Chat], chat, path=["response"]) + assert_matches_type(SyncCursorSearch[Chat], chat, path=["response"]) @parametrize def test_raw_response_search(self, client: BeeperDesktop) -> None: @@ -236,7 +235,7 @@ def test_raw_response_search(self, client: BeeperDesktop) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = response.parse() - assert_matches_type(SyncCursor[Chat], chat, path=["response"]) + assert_matches_type(SyncCursorSearch[Chat], chat, path=["response"]) @parametrize def test_streaming_response_search(self, client: BeeperDesktop) -> None: @@ -245,7 +244,7 @@ def test_streaming_response_search(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = response.parse() - assert_matches_type(SyncCursor[Chat], chat, path=["response"]) + assert_matches_type(SyncCursorSearch[Chat], chat, path=["response"]) assert cast(Any, response.is_closed) is True @@ -352,7 +351,7 @@ async def test_path_params_retrieve(self, async_client: AsyncBeeperDesktop) -> N @parametrize async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.list() - assert_matches_type(AsyncCursor[ChatListResponse], chat, path=["response"]) + assert_matches_type(AsyncCursorList[ChatListResponse], chat, path=["response"]) @parametrize async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -364,9 +363,8 @@ async def test_method_list_with_all_params(self, async_client: AsyncBeeperDeskto ], cursor="1725489123456", direction="before", - limit=1, ) - assert_matches_type(AsyncCursor[ChatListResponse], chat, path=["response"]) + assert_matches_type(AsyncCursorList[ChatListResponse], chat, path=["response"]) @parametrize async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -375,7 +373,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" chat = await response.parse() - assert_matches_type(AsyncCursor[ChatListResponse], chat, path=["response"]) + assert_matches_type(AsyncCursorList[ChatListResponse], chat, path=["response"]) @parametrize async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -384,7 +382,7 @@ async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) - assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = await response.parse() - assert_matches_type(AsyncCursor[ChatListResponse], chat, path=["response"]) + assert_matches_type(AsyncCursorList[ChatListResponse], chat, path=["response"]) assert cast(Any, response.is_closed) is True @@ -437,7 +435,7 @@ async def test_path_params_archive(self, async_client: AsyncBeeperDesktop) -> No @parametrize async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.search() - assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) + assert_matches_type(AsyncCursorSearch[Chat], chat, path=["response"]) @parametrize async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -458,7 +456,7 @@ async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesk type="single", unread_only=True, ) - assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) + assert_matches_type(AsyncCursorSearch[Chat], chat, path=["response"]) @parametrize async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: @@ -467,7 +465,7 @@ async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = await response.parse() - assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) + assert_matches_type(AsyncCursorSearch[Chat], chat, path=["response"]) @parametrize async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: @@ -476,6 +474,6 @@ async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = await response.parse() - assert_matches_type(AsyncCursor[Chat], chat, path=["response"]) + assert_matches_type(AsyncCursorSearch[Chat], chat, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index 85ebebb..3f36468 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -13,7 +13,7 @@ MessageSendResponse, ) from beeper_desktop_api._utils import parse_datetime -from beeper_desktop_api.pagination import SyncCursor, AsyncCursor +from beeper_desktop_api.pagination import SyncCursorList, AsyncCursorList, SyncCursorSearch, AsyncCursorSearch from beeper_desktop_api.types.shared import Message base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -27,7 +27,7 @@ def test_method_list(self, client: BeeperDesktop) -> None: message = client.messages.list( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(SyncCursor[Message], message, path=["response"]) + assert_matches_type(SyncCursorList[Message], message, path=["response"]) @parametrize def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: @@ -35,9 +35,8 @@ def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", cursor="821744079", direction="before", - limit=1, ) - assert_matches_type(SyncCursor[Message], message, path=["response"]) + assert_matches_type(SyncCursorList[Message], message, path=["response"]) @parametrize def test_raw_response_list(self, client: BeeperDesktop) -> None: @@ -48,7 +47,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(SyncCursor[Message], message, path=["response"]) + assert_matches_type(SyncCursorList[Message], message, path=["response"]) @parametrize def test_streaming_response_list(self, client: BeeperDesktop) -> None: @@ -59,14 +58,14 @@ 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(SyncCursor[Message], message, path=["response"]) + assert_matches_type(SyncCursorList[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True @parametrize def test_method_search(self, client: BeeperDesktop) -> None: message = client.messages.search() - assert_matches_type(SyncCursor[Message], message, path=["response"]) + assert_matches_type(SyncCursorSearch[Message], message, path=["response"]) @parametrize def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: @@ -89,7 +88,7 @@ def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: query="dinner", sender="me", ) - assert_matches_type(SyncCursor[Message], message, path=["response"]) + assert_matches_type(SyncCursorSearch[Message], message, path=["response"]) @parametrize def test_raw_response_search(self, client: BeeperDesktop) -> None: @@ -98,7 +97,7 @@ def test_raw_response_search(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(SyncCursor[Message], message, path=["response"]) + assert_matches_type(SyncCursorSearch[Message], message, path=["response"]) @parametrize def test_streaming_response_search(self, client: BeeperDesktop) -> None: @@ -107,7 +106,7 @@ def test_streaming_response_search(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = response.parse() - assert_matches_type(SyncCursor[Message], message, path=["response"]) + assert_matches_type(SyncCursorSearch[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True @@ -162,7 +161,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(AsyncCursor[Message], message, path=["response"]) + assert_matches_type(AsyncCursorList[Message], message, path=["response"]) @parametrize async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -170,9 +169,8 @@ async def test_method_list_with_all_params(self, async_client: AsyncBeeperDeskto chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", cursor="821744079", direction="before", - limit=1, ) - assert_matches_type(AsyncCursor[Message], message, path=["response"]) + assert_matches_type(AsyncCursorList[Message], message, path=["response"]) @parametrize async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -183,7 +181,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(AsyncCursor[Message], message, path=["response"]) + assert_matches_type(AsyncCursorList[Message], message, path=["response"]) @parametrize async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -194,14 +192,14 @@ 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(AsyncCursor[Message], message, path=["response"]) + assert_matches_type(AsyncCursorList[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True @parametrize async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.search() - assert_matches_type(AsyncCursor[Message], message, path=["response"]) + assert_matches_type(AsyncCursorSearch[Message], message, path=["response"]) @parametrize async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -224,7 +222,7 @@ async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesk query="dinner", sender="me", ) - assert_matches_type(AsyncCursor[Message], message, path=["response"]) + assert_matches_type(AsyncCursorSearch[Message], message, path=["response"]) @parametrize async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: @@ -233,7 +231,7 @@ async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = await response.parse() - assert_matches_type(AsyncCursor[Message], message, path=["response"]) + assert_matches_type(AsyncCursorSearch[Message], message, path=["response"]) @parametrize async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: @@ -242,7 +240,7 @@ async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = await response.parse() - assert_matches_type(AsyncCursor[Message], message, path=["response"]) + assert_matches_type(AsyncCursorSearch[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True From 89d2525a973a1aad4c1c5f39bb0bfb41abf6fa5f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 20:00:13 +0000 Subject: [PATCH 18/98] feat(api): manual updates --- .stats.yml | 4 +- api.md | 4 +- src/beeper_desktop_api/resources/messages.py | 31 ++++++------- src/beeper_desktop_api/types/__init__.py | 1 + .../types/message_list_response.py | 21 +++++++++ .../types/message_search_params.py | 6 +-- .../types/message_send_params.py | 4 +- tests/api_resources/test_messages.py | 43 +++++++------------ 8 files changed, 59 insertions(+), 55 deletions(-) create mode 100644 src/beeper_desktop_api/types/message_list_response.py diff --git a/.stats.yml b/.stats.yml index c2693cc..7d02041 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-a3fb0de6dd98f8a51d73e3fdf51de6143f2e8e764048246392624a56b4a3a481.yml -openapi_spec_hash: 50e1001c340cb0bd3436b6329240769b +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-100e7052e74644026f594642a424e04ab306d44e6c73a1f4761cf8a7d7ee0d8f.yml +openapi_spec_hash: 3437145a74c032f2319a235bf40baa88 config_hash: 2e31d02f28a11ef29eb747bcf559786a diff --git a/api.md b/api.md index dd074eb..d2a744f 100644 --- a/api.md +++ b/api.md @@ -70,11 +70,11 @@ Methods: Types: ```python -from beeper_desktop_api.types import MessageSendResponse +from beeper_desktop_api.types import MessageListResponse, MessageSendResponse ``` Methods: -- client.messages.list(\*\*params) -> SyncCursorList[Message] +- client.messages.list(\*\*params) -> MessageListResponse - client.messages.search(\*\*params) -> SyncCursorSearch[Message] - client.messages.send(\*\*params) -> MessageSendResponse diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index 3732473..1a3bc64 100644 --- a/src/beeper_desktop_api/resources/messages.py +++ b/src/beeper_desktop_api/resources/messages.py @@ -19,9 +19,10 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from ..pagination import SyncCursorList, AsyncCursorList, SyncCursorSearch, AsyncCursorSearch +from ..pagination import SyncCursorSearch, AsyncCursorSearch from .._base_client import AsyncPaginator, make_request_options from ..types.shared.message import Message +from ..types.message_list_response import MessageListResponse from ..types.message_send_response import MessageSendResponse __all__ = ["MessagesResource", "AsyncMessagesResource"] @@ -61,7 +62,7 @@ def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursorList[Message]: + ) -> MessageListResponse: """List all messages in a chat with cursor-based pagination. Sorted by timestamp. @@ -83,9 +84,8 @@ def list( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get_api_list( + return self._get( "/v1/messages", - page=SyncCursorList[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -100,7 +100,7 @@ def list( message_list_params.MessageListParams, ), ), - model=Message, + cast_to=MessageListResponse, ) def search( @@ -153,8 +153,7 @@ def search( include_muted: Include messages in chats marked as Muted by the user, which are usually less important. Default: true. Set to false if the user wants a more refined search. - limit: Maximum number of messages to return (1–500). Defaults to 20. The current - implementation caps each page at 20 items even if a higher limit is requested. + limit: Maximum number of messages to return. media_types: Filter messages by media types. Use ['any'] for any media type, or specify exact types like ['video', 'image']. Omit for no media filtering. @@ -208,7 +207,7 @@ def search( def send( self, *, - chat_id: str, + chat_id: str | Omit = omit, reply_to_message_id: str | Omit = omit, text: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -277,7 +276,7 @@ def with_streaming_response(self) -> AsyncMessagesResourceWithStreamingResponse: """ return AsyncMessagesResourceWithStreamingResponse(self) - def list( + async def list( self, *, chat_id: str, @@ -289,7 +288,7 @@ def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[Message, AsyncCursorList[Message]]: + ) -> MessageListResponse: """List all messages in a chat with cursor-based pagination. Sorted by timestamp. @@ -311,15 +310,14 @@ def list( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get_api_list( + return await self._get( "/v1/messages", - page=AsyncCursorList[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform( + query=await async_maybe_transform( { "chat_id": chat_id, "cursor": cursor, @@ -328,7 +326,7 @@ def list( message_list_params.MessageListParams, ), ), - model=Message, + cast_to=MessageListResponse, ) def search( @@ -381,8 +379,7 @@ def search( include_muted: Include messages in chats marked as Muted by the user, which are usually less important. Default: true. Set to false if the user wants a more refined search. - limit: Maximum number of messages to return (1–500). Defaults to 20. The current - implementation caps each page at 20 items even if a higher limit is requested. + limit: Maximum number of messages to return. media_types: Filter messages by media types. Use ['any'] for any media type, or specify exact types like ['video', 'image']. Omit for no media filtering. @@ -436,7 +433,7 @@ def search( async def send( self, *, - chat_id: str, + chat_id: str | Omit = omit, reply_to_message_id: str | Omit = omit, text: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index e577cbf..28eab5d 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -27,6 +27,7 @@ from .client_search_params import ClientSearchParams as ClientSearchParams from .account_list_response import AccountListResponse as AccountListResponse from .contact_search_params import ContactSearchParams as ContactSearchParams +from .message_list_response import MessageListResponse as MessageListResponse from .message_search_params import MessageSearchParams as MessageSearchParams from .message_send_response import MessageSendResponse as MessageSendResponse from .contact_search_response import ContactSearchResponse as ContactSearchResponse diff --git a/src/beeper_desktop_api/types/message_list_response.py b/src/beeper_desktop_api/types/message_list_response.py new file mode 100644 index 0000000..a66746f --- /dev/null +++ b/src/beeper_desktop_api/types/message_list_response.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from pydantic import Field as FieldInfo + +from .._models import BaseModel +from .shared.message import Message + +__all__ = ["MessageListResponse"] + + +class MessageListResponse(BaseModel): + has_more: bool = FieldInfo(alias="hasMore") + """True if additional results can be fetched.""" + + items: List[Message] + """Messages from the chat, sorted by timestamp. + + Use message.sortKey as cursor for pagination. + """ diff --git a/src/beeper_desktop_api/types/message_search_params.py b/src/beeper_desktop_api/types/message_search_params.py index 650775f..93fbd63 100644 --- a/src/beeper_desktop_api/types/message_search_params.py +++ b/src/beeper_desktop_api/types/message_search_params.py @@ -56,11 +56,7 @@ class MessageSearchParams(TypedDict, total=False): """ limit: int - """Maximum number of messages to return (1–500). - - Defaults to 20. The current implementation caps each page at 20 items even if a - higher limit is requested. - """ + """Maximum number of messages to return.""" media_types: Annotated[List[Literal["any", "video", "image", "link", "file"]], PropertyInfo(alias="mediaTypes")] """Filter messages by media types. diff --git a/src/beeper_desktop_api/types/message_send_params.py b/src/beeper_desktop_api/types/message_send_params.py index 8b05d6a..b165b27 100644 --- a/src/beeper_desktop_api/types/message_send_params.py +++ b/src/beeper_desktop_api/types/message_send_params.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing_extensions import Required, Annotated, TypedDict +from typing_extensions import Annotated, TypedDict from .._utils import PropertyInfo @@ -10,7 +10,7 @@ class MessageSendParams(TypedDict, total=False): - chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] + chat_id: Annotated[str, PropertyInfo(alias="chatID")] """Unique identifier of the chat.""" reply_to_message_id: Annotated[str, PropertyInfo(alias="replyToMessageID")] diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index 3f36468..302e146 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -10,10 +10,11 @@ from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop from beeper_desktop_api.types import ( + MessageListResponse, MessageSendResponse, ) from beeper_desktop_api._utils import parse_datetime -from beeper_desktop_api.pagination import SyncCursorList, AsyncCursorList, SyncCursorSearch, AsyncCursorSearch +from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch from beeper_desktop_api.types.shared import Message base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -27,7 +28,7 @@ def test_method_list(self, client: BeeperDesktop) -> None: message = client.messages.list( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(SyncCursorList[Message], message, path=["response"]) + assert_matches_type(MessageListResponse, message, path=["response"]) @parametrize def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: @@ -36,7 +37,7 @@ def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: cursor="821744079", direction="before", ) - assert_matches_type(SyncCursorList[Message], message, path=["response"]) + assert_matches_type(MessageListResponse, message, path=["response"]) @parametrize def test_raw_response_list(self, client: BeeperDesktop) -> None: @@ -47,7 +48,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(SyncCursorList[Message], message, path=["response"]) + assert_matches_type(MessageListResponse, message, path=["response"]) @parametrize def test_streaming_response_list(self, client: BeeperDesktop) -> None: @@ -58,7 +59,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(SyncCursorList[Message], message, path=["response"]) + assert_matches_type(MessageListResponse, message, path=["response"]) assert cast(Any, response.is_closed) is True @@ -112,9 +113,7 @@ def test_streaming_response_search(self, client: BeeperDesktop) -> None: @parametrize def test_method_send(self, client: BeeperDesktop) -> None: - message = client.messages.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) + message = client.messages.send() assert_matches_type(MessageSendResponse, message, path=["response"]) @parametrize @@ -128,9 +127,7 @@ def test_method_send_with_all_params(self, client: BeeperDesktop) -> None: @parametrize def test_raw_response_send(self, client: BeeperDesktop) -> None: - response = client.messages.with_raw_response.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) + response = client.messages.with_raw_response.send() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -139,9 +136,7 @@ def test_raw_response_send(self, client: BeeperDesktop) -> None: @parametrize def test_streaming_response_send(self, client: BeeperDesktop) -> None: - with client.messages.with_streaming_response.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) as response: + with client.messages.with_streaming_response.send() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -161,7 +156,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(AsyncCursorList[Message], message, path=["response"]) + assert_matches_type(MessageListResponse, message, path=["response"]) @parametrize async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -170,7 +165,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncBeeperDeskto cursor="821744079", direction="before", ) - assert_matches_type(AsyncCursorList[Message], message, path=["response"]) + assert_matches_type(MessageListResponse, message, path=["response"]) @parametrize async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -181,7 +176,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(AsyncCursorList[Message], message, path=["response"]) + assert_matches_type(MessageListResponse, message, path=["response"]) @parametrize async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -192,7 +187,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(AsyncCursorList[Message], message, path=["response"]) + assert_matches_type(MessageListResponse, message, path=["response"]) assert cast(Any, response.is_closed) is True @@ -246,9 +241,7 @@ async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) @parametrize async def test_method_send(self, async_client: AsyncBeeperDesktop) -> None: - message = await async_client.messages.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) + message = await async_client.messages.send() assert_matches_type(MessageSendResponse, message, path=["response"]) @parametrize @@ -262,9 +255,7 @@ async def test_method_send_with_all_params(self, async_client: AsyncBeeperDeskto @parametrize async def test_raw_response_send(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.messages.with_raw_response.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) + response = await async_client.messages.with_raw_response.send() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -273,9 +264,7 @@ async def test_raw_response_send(self, async_client: AsyncBeeperDesktop) -> None @parametrize async def test_streaming_response_send(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.messages.with_streaming_response.send( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - ) as response: + async with async_client.messages.with_streaming_response.send() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" From 53983f139401b9ca4913bbc632ed2a70b0feae91 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 20:05:05 +0000 Subject: [PATCH 19/98] 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 7d02041..96af950 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-100e7052e74644026f594642a424e04ab306d44e6c73a1f4761cf8a7d7ee0d8f.yml openapi_spec_hash: 3437145a74c032f2319a235bf40baa88 -config_hash: 2e31d02f28a11ef29eb747bcf559786a +config_hash: 36b26d0d29548d4aa575fc337915ad42 diff --git a/README.md b/README.md index 9f0e7ba..e20984e 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,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). @@ -81,8 +78,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 9800c5b6197dc5d94c6a3e50e569cc8827e95087 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 21:09:25 +0000 Subject: [PATCH 20/98] feat(api): manual updates --- .stats.yml | 6 +- README.md | 11 +-- api.md | 8 +-- src/beeper_desktop_api/pagination.py | 35 +++++---- src/beeper_desktop_api/resources/contacts.py | 28 +++----- src/beeper_desktop_api/resources/messages.py | 49 +++++++------ src/beeper_desktop_api/types/__init__.py | 1 - .../types/contact_search_params.py | 7 +- .../types/message_list_params.py | 7 +- .../types/message_list_response.py | 21 ------ .../types/message_send_params.py | 3 - tests/api_resources/test_contacts.py | 16 +++++ tests/api_resources/test_messages.py | 71 ++++++++++++++----- 13 files changed, 147 insertions(+), 116 deletions(-) delete mode 100644 src/beeper_desktop_api/types/message_list_response.py diff --git a/.stats.yml b/.stats.yml index 96af950..d065147 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-100e7052e74644026f594642a424e04ab306d44e6c73a1f4761cf8a7d7ee0d8f.yml -openapi_spec_hash: 3437145a74c032f2319a235bf40baa88 -config_hash: 36b26d0d29548d4aa575fc337915ad42 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-f48e33c7d90ed6418a852f8d4d951d07b09f4f3f939feb395dc2aa03f522d81e.yml +openapi_spec_hash: c516120ecf51bb8425b3b9ed76c6423a +config_hash: c5ac9bd5889d27aa168f06d6d0fef0b3 diff --git a/README.md b/README.md index e20984e..9f0e7ba 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,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). @@ -78,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 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()`: diff --git a/api.md b/api.md index d2a744f..4fbc194 100644 --- a/api.md +++ b/api.md @@ -40,7 +40,7 @@ from beeper_desktop_api.types import ContactSearchResponse Methods: -- client.contacts.search(\*\*params) -> ContactSearchResponse +- client.contacts.search(account_id, \*\*params) -> ContactSearchResponse # Chats @@ -70,11 +70,11 @@ Methods: Types: ```python -from beeper_desktop_api.types import MessageListResponse, MessageSendResponse +from beeper_desktop_api.types import MessageSendResponse ``` Methods: -- client.messages.list(\*\*params) -> MessageListResponse +- client.messages.list(chat_id, \*\*params) -> SyncCursorList[Message] - client.messages.search(\*\*params) -> SyncCursorSearch[Message] -- client.messages.send(\*\*params) -> MessageSendResponse +- client.messages.send(chat_id, \*\*params) -> MessageSendResponse diff --git a/src/beeper_desktop_api/pagination.py b/src/beeper_desktop_api/pagination.py index ee568dc..7fd745f 100644 --- a/src/beeper_desktop_api/pagination.py +++ b/src/beeper_desktop_api/pagination.py @@ -1,7 +1,7 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List, Generic, TypeVar, Optional -from typing_extensions import override +from typing import Any, List, Generic, TypeVar, Optional, cast +from typing_extensions import Protocol, override, runtime_checkable from pydantic import Field as FieldInfo @@ -12,6 +12,11 @@ _T = TypeVar("_T") +@runtime_checkable +class CursorListItem(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) @@ -75,8 +80,6 @@ def next_page_info(self) -> Optional[PageInfo]: class SyncCursorList(BaseSyncPage[_T], BasePage[_T], Generic[_T]): items: List[_T] has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) - oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) - newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", default=None) @override def _get_page_items(self) -> List[_T]: @@ -95,18 +98,21 @@ def has_next_page(self) -> bool: @override def next_page_info(self) -> Optional[PageInfo]: - oldest_cursor = self.oldest_cursor - if not oldest_cursor: + items = self.items + if not items: return None - return PageInfo(params={"cursor": oldest_cursor}) + item = cast(Any, items[-1]) + if not isinstance(item, CursorListItem) or item.sort_key is None: + # TODO emit warning log + return None + + return PageInfo(params={"cursor": item.sort_key}) class AsyncCursorList(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): items: List[_T] has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) - oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) - newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", default=None) @override def _get_page_items(self) -> List[_T]: @@ -125,8 +131,13 @@ def has_next_page(self) -> bool: @override def next_page_info(self) -> Optional[PageInfo]: - oldest_cursor = self.oldest_cursor - if not oldest_cursor: + items = self.items + if not items: return None - return PageInfo(params={"cursor": oldest_cursor}) + item = cast(Any, items[-1]) + if not isinstance(item, CursorListItem) 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/contacts.py b/src/beeper_desktop_api/resources/contacts.py index db84950..50fc4f9 100644 --- a/src/beeper_desktop_api/resources/contacts.py +++ b/src/beeper_desktop_api/resources/contacts.py @@ -45,8 +45,8 @@ def with_streaming_response(self) -> ContactsResourceWithStreamingResponse: def search( self, - *, account_id: str, + *, query: str, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -72,20 +72,16 @@ def search( timeout: Override the client-level default timeout for this request, in seconds """ + if not account_id: + raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") return self._get( - "/v1/contacts/search", + f"/v1/accounts/{account_id}/contacts/search", options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform( - { - "account_id": account_id, - "query": query, - }, - contact_search_params.ContactSearchParams, - ), + query=maybe_transform({"query": query}, contact_search_params.ContactSearchParams), ), cast_to=ContactSearchResponse, ) @@ -115,8 +111,8 @@ def with_streaming_response(self) -> AsyncContactsResourceWithStreamingResponse: async def search( self, - *, account_id: str, + *, query: str, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -142,20 +138,16 @@ async def search( timeout: Override the client-level default timeout for this request, in seconds """ + if not account_id: + raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") return await self._get( - "/v1/contacts/search", + f"/v1/accounts/{account_id}/contacts/search", options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=await async_maybe_transform( - { - "account_id": account_id, - "query": query, - }, - contact_search_params.ContactSearchParams, - ), + query=await async_maybe_transform({"query": query}, contact_search_params.ContactSearchParams), ), cast_to=ContactSearchResponse, ) diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index 1a3bc64..e77b8f1 100644 --- a/src/beeper_desktop_api/resources/messages.py +++ b/src/beeper_desktop_api/resources/messages.py @@ -19,10 +19,9 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from ..pagination import SyncCursorSearch, AsyncCursorSearch +from ..pagination import SyncCursorList, AsyncCursorList, SyncCursorSearch, AsyncCursorSearch from .._base_client import AsyncPaginator, make_request_options from ..types.shared.message import Message -from ..types.message_list_response import MessageListResponse from ..types.message_send_response import MessageSendResponse __all__ = ["MessagesResource", "AsyncMessagesResource"] @@ -52,8 +51,8 @@ def with_streaming_response(self) -> MessagesResourceWithStreamingResponse: def list( self, - *, chat_id: str, + *, cursor: str | Omit = omit, direction: Literal["after", "before"] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -62,7 +61,7 @@ def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> MessageListResponse: + ) -> SyncCursorList[Message]: """List all messages in a chat with cursor-based pagination. Sorted by timestamp. @@ -84,8 +83,11 @@ def list( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get( - "/v1/messages", + 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", + page=SyncCursorList[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -93,14 +95,13 @@ def list( timeout=timeout, query=maybe_transform( { - "chat_id": chat_id, "cursor": cursor, "direction": direction, }, message_list_params.MessageListParams, ), ), - cast_to=MessageListResponse, + model=Message, ) def search( @@ -206,8 +207,8 @@ def search( def send( self, + chat_id: str, *, - chat_id: str | Omit = omit, reply_to_message_id: str | Omit = omit, text: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -237,11 +238,12 @@ def send( timeout: Override the client-level default timeout for this request, in seconds """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return self._post( - "/v1/messages", + f"/v1/chats/{chat_id}/messages", body=maybe_transform( { - "chat_id": chat_id, "reply_to_message_id": reply_to_message_id, "text": text, }, @@ -276,10 +278,10 @@ def with_streaming_response(self) -> AsyncMessagesResourceWithStreamingResponse: """ return AsyncMessagesResourceWithStreamingResponse(self) - async def list( + def list( self, - *, chat_id: str, + *, cursor: str | Omit = omit, direction: Literal["after", "before"] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -288,7 +290,7 @@ async def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> MessageListResponse: + ) -> AsyncPaginator[Message, AsyncCursorList[Message]]: """List all messages in a chat with cursor-based pagination. Sorted by timestamp. @@ -310,23 +312,25 @@ async def list( timeout: Override the client-level default timeout for this request, in seconds """ - return await self._get( - "/v1/messages", + 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", + page=AsyncCursorList[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=await async_maybe_transform( + query=maybe_transform( { - "chat_id": chat_id, "cursor": cursor, "direction": direction, }, message_list_params.MessageListParams, ), ), - cast_to=MessageListResponse, + model=Message, ) def search( @@ -432,8 +436,8 @@ def search( async def send( self, + chat_id: str, *, - chat_id: str | Omit = omit, reply_to_message_id: str | Omit = omit, text: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -463,11 +467,12 @@ async def send( timeout: Override the client-level default timeout for this request, in seconds """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return await self._post( - "/v1/messages", + f"/v1/chats/{chat_id}/messages", body=await async_maybe_transform( { - "chat_id": chat_id, "reply_to_message_id": reply_to_message_id, "text": text, }, diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index 28eab5d..e577cbf 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -27,7 +27,6 @@ from .client_search_params import ClientSearchParams as ClientSearchParams from .account_list_response import AccountListResponse as AccountListResponse from .contact_search_params import ContactSearchParams as ContactSearchParams -from .message_list_response import MessageListResponse as MessageListResponse from .message_search_params import MessageSearchParams as MessageSearchParams from .message_send_response import MessageSendResponse as MessageSendResponse from .contact_search_response import ContactSearchResponse as ContactSearchResponse diff --git a/src/beeper_desktop_api/types/contact_search_params.py b/src/beeper_desktop_api/types/contact_search_params.py index 53d052f..f9063e0 100644 --- a/src/beeper_desktop_api/types/contact_search_params.py +++ b/src/beeper_desktop_api/types/contact_search_params.py @@ -2,16 +2,11 @@ from __future__ import annotations -from typing_extensions import Required, Annotated, TypedDict - -from .._utils import PropertyInfo +from typing_extensions import Required, TypedDict __all__ = ["ContactSearchParams"] class ContactSearchParams(TypedDict, total=False): - account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] - """Account ID this resource belongs to.""" - query: Required[str] """Text to search users by. Network-specific behavior.""" diff --git a/src/beeper_desktop_api/types/message_list_params.py b/src/beeper_desktop_api/types/message_list_params.py index 2dd8438..d4e343a 100644 --- a/src/beeper_desktop_api/types/message_list_params.py +++ b/src/beeper_desktop_api/types/message_list_params.py @@ -2,17 +2,12 @@ from __future__ import annotations -from typing_extensions import Literal, Required, Annotated, TypedDict - -from .._utils import PropertyInfo +from typing_extensions import Literal, TypedDict __all__ = ["MessageListParams"] class MessageListParams(TypedDict, total=False): - chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] - """Chat ID to list messages from""" - cursor: str """Message cursor for pagination. Use with direction to navigate results.""" diff --git a/src/beeper_desktop_api/types/message_list_response.py b/src/beeper_desktop_api/types/message_list_response.py deleted file mode 100644 index a66746f..0000000 --- a/src/beeper_desktop_api/types/message_list_response.py +++ /dev/null @@ -1,21 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List - -from pydantic import Field as FieldInfo - -from .._models import BaseModel -from .shared.message import Message - -__all__ = ["MessageListResponse"] - - -class MessageListResponse(BaseModel): - has_more: bool = FieldInfo(alias="hasMore") - """True if additional results can be fetched.""" - - items: List[Message] - """Messages from the chat, sorted by timestamp. - - Use message.sortKey as cursor for pagination. - """ diff --git a/src/beeper_desktop_api/types/message_send_params.py b/src/beeper_desktop_api/types/message_send_params.py index b165b27..840e745 100644 --- a/src/beeper_desktop_api/types/message_send_params.py +++ b/src/beeper_desktop_api/types/message_send_params.py @@ -10,9 +10,6 @@ class MessageSendParams(TypedDict, total=False): - chat_id: Annotated[str, PropertyInfo(alias="chatID")] - """Unique identifier of the chat.""" - reply_to_message_id: Annotated[str, PropertyInfo(alias="replyToMessageID")] """Provide a message ID to send this as a reply to an existing message""" diff --git a/tests/api_resources/test_contacts.py b/tests/api_resources/test_contacts.py index 6308d1f..158b961 100644 --- a/tests/api_resources/test_contacts.py +++ b/tests/api_resources/test_contacts.py @@ -51,6 +51,14 @@ def test_streaming_response_search(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True + @parametrize + def test_path_params_search(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `account_id` but received ''"): + client.contacts.with_raw_response.search( + account_id="", + query="x", + ) + class TestAsyncContacts: parametrize = pytest.mark.parametrize( @@ -90,3 +98,11 @@ async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) assert_matches_type(ContactSearchResponse, contact, path=["response"]) assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_search(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `account_id` but received ''"): + await async_client.contacts.with_raw_response.search( + account_id="", + query="x", + ) diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index 302e146..dd93537 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -10,11 +10,10 @@ from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop from beeper_desktop_api.types import ( - MessageListResponse, MessageSendResponse, ) from beeper_desktop_api._utils import parse_datetime -from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch +from beeper_desktop_api.pagination import SyncCursorList, AsyncCursorList, SyncCursorSearch, AsyncCursorSearch from beeper_desktop_api.types.shared import Message base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -28,7 +27,7 @@ def test_method_list(self, client: BeeperDesktop) -> None: message = client.messages.list( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(MessageListResponse, message, path=["response"]) + assert_matches_type(SyncCursorList[Message], message, path=["response"]) @parametrize def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: @@ -37,7 +36,7 @@ def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: cursor="821744079", direction="before", ) - assert_matches_type(MessageListResponse, message, path=["response"]) + assert_matches_type(SyncCursorList[Message], message, path=["response"]) @parametrize def test_raw_response_list(self, client: BeeperDesktop) -> None: @@ -48,7 +47,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(MessageListResponse, message, path=["response"]) + assert_matches_type(SyncCursorList[Message], message, path=["response"]) @parametrize def test_streaming_response_list(self, client: BeeperDesktop) -> None: @@ -59,10 +58,17 @@ 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(MessageListResponse, message, path=["response"]) + assert_matches_type(SyncCursorList[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True + @parametrize + def test_path_params_list(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.messages.with_raw_response.list( + chat_id="", + ) + @parametrize def test_method_search(self, client: BeeperDesktop) -> None: message = client.messages.search() @@ -113,7 +119,9 @@ def test_streaming_response_search(self, client: BeeperDesktop) -> None: @parametrize def test_method_send(self, client: BeeperDesktop) -> None: - message = client.messages.send() + message = client.messages.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) assert_matches_type(MessageSendResponse, message, path=["response"]) @parametrize @@ -127,7 +135,9 @@ def test_method_send_with_all_params(self, client: BeeperDesktop) -> None: @parametrize def test_raw_response_send(self, client: BeeperDesktop) -> None: - response = client.messages.with_raw_response.send() + response = client.messages.with_raw_response.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -136,7 +146,9 @@ def test_raw_response_send(self, client: BeeperDesktop) -> None: @parametrize def test_streaming_response_send(self, client: BeeperDesktop) -> None: - with client.messages.with_streaming_response.send() as response: + with client.messages.with_streaming_response.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -145,6 +157,13 @@ def test_streaming_response_send(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True + @parametrize + def test_path_params_send(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.messages.with_raw_response.send( + chat_id="", + ) + class TestAsyncMessages: parametrize = pytest.mark.parametrize( @@ -156,7 +175,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(MessageListResponse, message, path=["response"]) + assert_matches_type(AsyncCursorList[Message], message, path=["response"]) @parametrize async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -165,7 +184,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncBeeperDeskto cursor="821744079", direction="before", ) - assert_matches_type(MessageListResponse, message, path=["response"]) + assert_matches_type(AsyncCursorList[Message], message, path=["response"]) @parametrize async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -176,7 +195,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(MessageListResponse, message, path=["response"]) + assert_matches_type(AsyncCursorList[Message], message, path=["response"]) @parametrize async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -187,10 +206,17 @@ 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(MessageListResponse, message, path=["response"]) + assert_matches_type(AsyncCursorList[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True + @parametrize + async def test_path_params_list(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.messages.with_raw_response.list( + chat_id="", + ) + @parametrize async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.search() @@ -241,7 +267,9 @@ async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) @parametrize async def test_method_send(self, async_client: AsyncBeeperDesktop) -> None: - message = await async_client.messages.send() + message = await async_client.messages.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) assert_matches_type(MessageSendResponse, message, path=["response"]) @parametrize @@ -255,7 +283,9 @@ async def test_method_send_with_all_params(self, async_client: AsyncBeeperDeskto @parametrize async def test_raw_response_send(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.messages.with_raw_response.send() + response = await async_client.messages.with_raw_response.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -264,7 +294,9 @@ async def test_raw_response_send(self, async_client: AsyncBeeperDesktop) -> None @parametrize async def test_streaming_response_send(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.messages.with_streaming_response.send() as response: + async with async_client.messages.with_streaming_response.send( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -272,3 +304,10 @@ async def test_streaming_response_send(self, async_client: AsyncBeeperDesktop) - assert_matches_type(MessageSendResponse, message, path=["response"]) assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_send(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.messages.with_raw_response.send( + chat_id="", + ) From 7fd0ed37f88fa11d8e9cf3c76076b64960822c16 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 22:25:34 +0000 Subject: [PATCH 21/98] feat(api): manual updates --- .stats.yml | 6 +- README.md | 100 +---- api.md | 14 +- src/beeper_desktop_api/_client.py | 62 +-- src/beeper_desktop_api/resources/__init__.py | 28 +- .../resources/chats/chats.py | 231 +--------- .../resources/chats/reminders.py | 12 +- src/beeper_desktop_api/resources/contacts.py | 189 --------- src/beeper_desktop_api/resources/messages.py | 22 +- src/beeper_desktop_api/resources/search.py | 397 ++++++++++++++++++ src/beeper_desktop_api/types/__init__.py | 10 +- .../types/chat_list_params.py | 5 +- ..._open_params.py => client_focus_params.py} | 4 +- .../{open_response.py => focus_response.py} | 4 +- .../types/message_list_params.py | 7 +- ...earch_params.py => search_chats_params.py} | 15 +- ...ch_params.py => search_contacts_params.py} | 4 +- ...esponse.py => search_contacts_response.py} | 4 +- .../types/shared/message.py | 11 +- tests/api_resources/test_chats.py | 99 +---- tests/api_resources/test_client.py | 50 +-- tests/api_resources/test_contacts.py | 108 ----- tests/api_resources/test_messages.py | 4 +- tests/api_resources/test_search.py | 202 +++++++++ 24 files changed, 737 insertions(+), 851 deletions(-) delete mode 100644 src/beeper_desktop_api/resources/contacts.py create mode 100644 src/beeper_desktop_api/resources/search.py rename src/beeper_desktop_api/types/{client_open_params.py => client_focus_params.py} (91%) rename src/beeper_desktop_api/types/{open_response.py => focus_response.py} (76%) rename src/beeper_desktop_api/types/{chat_search_params.py => search_chats_params.py} (87%) rename src/beeper_desktop_api/types/{contact_search_params.py => search_contacts_params.py} (75%) rename src/beeper_desktop_api/types/{contact_search_response.py => search_contacts_response.py} (71%) delete mode 100644 tests/api_resources/test_contacts.py create mode 100644 tests/api_resources/test_search.py diff --git a/.stats.yml b/.stats.yml index d065147..831b150 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-f48e33c7d90ed6418a852f8d4d951d07b09f4f3f939feb395dc2aa03f522d81e.yml -openapi_spec_hash: c516120ecf51bb8425b3b9ed76c6423a -config_hash: c5ac9bd5889d27aa168f06d6d0fef0b3 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-f48bb509412536c41fdfa537894cecd4af486099d95fe79369f2ef239fa94a75.yml +openapi_spec_hash: f8b886fdfdc5ee3d51d2cd05daee3bab +config_hash: 6f12c5e4c662e1f315b95a70389b1549 diff --git a/README.md b/README.md index 9f0e7ba..f06fa5b 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,7 @@ 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, - limit=3, - type="single", -) -print(page.items) +accounts = client.accounts.list() ``` While you can provide a `access_token` keyword argument, @@ -61,12 +56,7 @@ client = AsyncBeeperDesktop( async def main() -> None: - page = await client.chats.search( - include_muted=True, - limit=3, - type="single", - ) - print(page.items) + accounts = await client.accounts.list() asyncio.run(main()) @@ -98,12 +88,7 @@ async def main() -> None: access_token="My Access Token", http_client=DefaultAioHttpClient(), ) as client: - page = await client.chats.search( - include_muted=True, - limit=3, - type="single", - ) - print(page.items) + accounts = await client.accounts.list() asyncio.run(main()) @@ -118,85 +103,6 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. -## Pagination - -List methods in the Beeper Desktop API are paginated. - -This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually: - -```python -from beeper_desktop_api import BeeperDesktop - -client = BeeperDesktop() - -all_messages = [] -# Automatically fetches more pages as needed. -for message in client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], - limit=10, - query="deployment", -): - # Do something with message here - all_messages.append(message) -print(all_messages) -``` - -Or, asynchronously: - -```python -import asyncio -from beeper_desktop_api import AsyncBeeperDesktop - -client = AsyncBeeperDesktop() - - -async def main() -> None: - all_messages = [] - # Iterate through items across all pages, issuing requests as needed. - async for message in client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], - limit=10, - query="deployment", - ): - all_messages.append(message) - print(all_messages) - - -asyncio.run(main()) -``` - -Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages: - -```python -first_page = await client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], - limit=10, - query="deployment", -) -if first_page.has_next_page(): - print(f"will fetch next page using these details: {first_page.next_page_info()}") - next_page = await first_page.get_next_page() - print(f"number of items we just fetched: {len(next_page.items)}") - -# Remove `await` for non-async usage. -``` - -Or just work directly with the returned data: - -```python -first_page = await client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], - limit=10, - query="deployment", -) - -print(f"next page cursor: {first_page.oldest_cursor}") # => "next page cursor: ..." -for message in first_page.items: - print(message.id) - -# Remove `await` for non-async usage. -``` - ## Nested params Nested parameters are dictionaries, typed using `TypedDict`, for example: diff --git a/api.md b/api.md index 4fbc194..119d6f4 100644 --- a/api.md +++ b/api.md @@ -9,13 +9,13 @@ from beeper_desktop_api.types import Attachment, BaseResponse, Error, Message, R Types: ```python -from beeper_desktop_api.types import DownloadAssetResponse, OpenResponse, SearchResponse +from beeper_desktop_api.types import DownloadAssetResponse, FocusResponse, SearchResponse ``` Methods: - client.download_asset(\*\*params) -> DownloadAssetResponse -- client.open(\*\*params) -> OpenResponse +- client.focus(\*\*params) -> FocusResponse - client.search(\*\*params) -> SearchResponse # Accounts @@ -30,17 +30,18 @@ Methods: - client.accounts.list() -> AccountListResponse -# Contacts +# Search Types: ```python -from beeper_desktop_api.types import ContactSearchResponse +from beeper_desktop_api.types import SearchContactsResponse ``` Methods: -- client.contacts.search(account_id, \*\*params) -> ContactSearchResponse +- client.search.chats(\*\*params) -> SyncCursorSearch[Chat] +- client.search.contacts(account_id, \*\*params) -> SearchContactsResponse # Chats @@ -56,7 +57,6 @@ Methods: - client.chats.retrieve(chat_id, \*\*params) -> Chat - client.chats.list(\*\*params) -> SyncCursorList[ChatListResponse] - client.chats.archive(chat_id, \*\*params) -> BaseResponse -- client.chats.search(\*\*params) -> SyncCursorSearch[Chat] ## Reminders @@ -76,5 +76,5 @@ from beeper_desktop_api.types import MessageSendResponse Methods: - client.messages.list(chat_id, \*\*params) -> SyncCursorList[Message] -- client.messages.search(\*\*params) -> SyncCursorSearch[Message] +- client.messages.search(\*\*params) -> SyncCursorSearch[Message] - client.messages.send(chat_id, \*\*params) -> MessageSendResponse diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index f8ba71d..82c9d75 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -10,7 +10,7 @@ from . import _exceptions from ._qs import Querystring -from .types import client_open_params, client_search_params, client_download_asset_params +from .types import client_focus_params, client_search_params, client_download_asset_params from ._types import ( Body, Omit, @@ -37,7 +37,7 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from .resources import accounts, contacts, messages +from .resources import search, accounts, messages from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError, BeeperDesktopError from ._base_client import ( @@ -47,7 +47,7 @@ make_request_options, ) from .resources.chats import chats -from .types.open_response import OpenResponse +from .types.focus_response import FocusResponse from .types.search_response import SearchResponse from .types.download_asset_response import DownloadAssetResponse @@ -65,7 +65,7 @@ class BeeperDesktop(SyncAPIClient): accounts: accounts.AccountsResource - contacts: contacts.ContactsResource + search: search.SearchResource chats: chats.ChatsResource messages: messages.MessagesResource with_raw_response: BeeperDesktopWithRawResponse @@ -126,7 +126,7 @@ def __init__( ) self.accounts = accounts.AccountsResource(self) - self.contacts = contacts.ContactsResource(self) + self.search = search.SearchResource(self) self.chats = chats.ChatsResource(self) self.messages = messages.MessagesResource(self) self.with_raw_response = BeeperDesktopWithRawResponse(self) @@ -238,7 +238,7 @@ def download_asset( cast_to=DownloadAssetResponse, ) - def open( + def focus( self, *, chat_id: str | Omit = omit, @@ -251,9 +251,9 @@ def open( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> OpenResponse: + ) -> FocusResponse: """ - Open Beeper Desktop and optionally navigate to a specific chat, message, or + Focus Beeper Desktop and optionally navigate to a specific chat, message, or pre-fill draft text and attachment. Args: @@ -275,7 +275,7 @@ def open( timeout: Override the client-level default timeout for this request, in seconds """ return self.post( - "/v1/open", + "/v1/focus", body=maybe_transform( { "chat_id": chat_id, @@ -283,12 +283,12 @@ def open( "draft_text": draft_text, "message_id": message_id, }, - client_open_params.ClientOpenParams, + client_focus_params.ClientFocusParams, ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=OpenResponse, + cast_to=FocusResponse, ) def search( @@ -366,7 +366,7 @@ def _make_status_error( class AsyncBeeperDesktop(AsyncAPIClient): accounts: accounts.AsyncAccountsResource - contacts: contacts.AsyncContactsResource + search: search.AsyncSearchResource chats: chats.AsyncChatsResource messages: messages.AsyncMessagesResource with_raw_response: AsyncBeeperDesktopWithRawResponse @@ -427,7 +427,7 @@ def __init__( ) self.accounts = accounts.AsyncAccountsResource(self) - self.contacts = contacts.AsyncContactsResource(self) + self.search = search.AsyncSearchResource(self) self.chats = chats.AsyncChatsResource(self) self.messages = messages.AsyncMessagesResource(self) self.with_raw_response = AsyncBeeperDesktopWithRawResponse(self) @@ -539,7 +539,7 @@ async def download_asset( cast_to=DownloadAssetResponse, ) - async def open( + async def focus( self, *, chat_id: str | Omit = omit, @@ -552,9 +552,9 @@ async def open( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> OpenResponse: + ) -> FocusResponse: """ - Open Beeper Desktop and optionally navigate to a specific chat, message, or + Focus Beeper Desktop and optionally navigate to a specific chat, message, or pre-fill draft text and attachment. Args: @@ -576,7 +576,7 @@ async def open( timeout: Override the client-level default timeout for this request, in seconds """ return await self.post( - "/v1/open", + "/v1/focus", body=await async_maybe_transform( { "chat_id": chat_id, @@ -584,12 +584,12 @@ async def open( "draft_text": draft_text, "message_id": message_id, }, - client_open_params.ClientOpenParams, + client_focus_params.ClientFocusParams, ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=OpenResponse, + cast_to=FocusResponse, ) async def search( @@ -668,15 +668,15 @@ def _make_status_error( class BeeperDesktopWithRawResponse: def __init__(self, client: BeeperDesktop) -> None: self.accounts = accounts.AccountsResourceWithRawResponse(client.accounts) - self.contacts = contacts.ContactsResourceWithRawResponse(client.contacts) + self.search = search.SearchResourceWithRawResponse(client.search) self.chats = chats.ChatsResourceWithRawResponse(client.chats) self.messages = messages.MessagesResourceWithRawResponse(client.messages) self.download_asset = to_raw_response_wrapper( client.download_asset, ) - self.open = to_raw_response_wrapper( - client.open, + self.focus = to_raw_response_wrapper( + client.focus, ) self.search = to_raw_response_wrapper( client.search, @@ -686,15 +686,15 @@ def __init__(self, client: BeeperDesktop) -> None: class AsyncBeeperDesktopWithRawResponse: def __init__(self, client: AsyncBeeperDesktop) -> None: self.accounts = accounts.AsyncAccountsResourceWithRawResponse(client.accounts) - self.contacts = contacts.AsyncContactsResourceWithRawResponse(client.contacts) + self.search = search.AsyncSearchResourceWithRawResponse(client.search) self.chats = chats.AsyncChatsResourceWithRawResponse(client.chats) self.messages = messages.AsyncMessagesResourceWithRawResponse(client.messages) self.download_asset = async_to_raw_response_wrapper( client.download_asset, ) - self.open = async_to_raw_response_wrapper( - client.open, + self.focus = async_to_raw_response_wrapper( + client.focus, ) self.search = async_to_raw_response_wrapper( client.search, @@ -704,15 +704,15 @@ def __init__(self, client: AsyncBeeperDesktop) -> None: class BeeperDesktopWithStreamedResponse: def __init__(self, client: BeeperDesktop) -> None: self.accounts = accounts.AccountsResourceWithStreamingResponse(client.accounts) - self.contacts = contacts.ContactsResourceWithStreamingResponse(client.contacts) + self.search = search.SearchResourceWithStreamingResponse(client.search) self.chats = chats.ChatsResourceWithStreamingResponse(client.chats) self.messages = messages.MessagesResourceWithStreamingResponse(client.messages) self.download_asset = to_streamed_response_wrapper( client.download_asset, ) - self.open = to_streamed_response_wrapper( - client.open, + self.focus = to_streamed_response_wrapper( + client.focus, ) self.search = to_streamed_response_wrapper( client.search, @@ -722,15 +722,15 @@ def __init__(self, client: BeeperDesktop) -> None: class AsyncBeeperDesktopWithStreamedResponse: def __init__(self, client: AsyncBeeperDesktop) -> None: self.accounts = accounts.AsyncAccountsResourceWithStreamingResponse(client.accounts) - self.contacts = contacts.AsyncContactsResourceWithStreamingResponse(client.contacts) + self.search = search.AsyncSearchResourceWithStreamingResponse(client.search) self.chats = chats.AsyncChatsResourceWithStreamingResponse(client.chats) self.messages = messages.AsyncMessagesResourceWithStreamingResponse(client.messages) self.download_asset = async_to_streamed_response_wrapper( client.download_asset, ) - self.open = async_to_streamed_response_wrapper( - client.open, + self.focus = async_to_streamed_response_wrapper( + client.focus, ) self.search = async_to_streamed_response_wrapper( client.search, diff --git a/src/beeper_desktop_api/resources/__init__.py b/src/beeper_desktop_api/resources/__init__.py index ebf006b..eedc9bc 100644 --- a/src/beeper_desktop_api/resources/__init__.py +++ b/src/beeper_desktop_api/resources/__init__.py @@ -8,6 +8,14 @@ ChatsResourceWithStreamingResponse, AsyncChatsResourceWithStreamingResponse, ) +from .search import ( + SearchResource, + AsyncSearchResource, + SearchResourceWithRawResponse, + AsyncSearchResourceWithRawResponse, + SearchResourceWithStreamingResponse, + AsyncSearchResourceWithStreamingResponse, +) from .accounts import ( AccountsResource, AsyncAccountsResource, @@ -16,14 +24,6 @@ AccountsResourceWithStreamingResponse, AsyncAccountsResourceWithStreamingResponse, ) -from .contacts import ( - ContactsResource, - AsyncContactsResource, - ContactsResourceWithRawResponse, - AsyncContactsResourceWithRawResponse, - ContactsResourceWithStreamingResponse, - AsyncContactsResourceWithStreamingResponse, -) from .messages import ( MessagesResource, AsyncMessagesResource, @@ -40,12 +40,12 @@ "AsyncAccountsResourceWithRawResponse", "AccountsResourceWithStreamingResponse", "AsyncAccountsResourceWithStreamingResponse", - "ContactsResource", - "AsyncContactsResource", - "ContactsResourceWithRawResponse", - "AsyncContactsResourceWithRawResponse", - "ContactsResourceWithStreamingResponse", - "AsyncContactsResourceWithStreamingResponse", + "SearchResource", + "AsyncSearchResource", + "SearchResourceWithRawResponse", + "AsyncSearchResourceWithRawResponse", + "SearchResourceWithStreamingResponse", + "AsyncSearchResourceWithStreamingResponse", "ChatsResource", "AsyncChatsResource", "ChatsResourceWithRawResponse", diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index 7b8d06c..7752636 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -2,13 +2,12 @@ from __future__ import annotations -from typing import Union, Optional -from datetime import datetime +from typing import Optional from typing_extensions import Literal import httpx -from ...types import chat_list_params, chat_create_params, chat_search_params, chat_archive_params, chat_retrieve_params +from ...types import chat_list_params, chat_create_params, chat_archive_params, chat_retrieve_params from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property @@ -27,7 +26,7 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from ...pagination import SyncCursorList, AsyncCursorList, SyncCursorSearch, AsyncCursorSearch +from ...pagination import SyncCursorList, AsyncCursorList from ...types.chat import Chat from ..._base_client import AsyncPaginator, make_request_options from ...types.chat_list_response import ChatListResponse @@ -137,8 +136,7 @@ def retrieve( Retrieve chat details including metadata, participants, and latest message Args: - chat_id: Unique identifier of the chat to retrieve. Not available for iMessage chats. - Participants are limited by 'maxParticipantCount'. + chat_id: Unique identifier of the chat. max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0–500. Defaults to 20. @@ -188,8 +186,7 @@ def list( Args: account_ids: Limit to specific account IDs. If omitted, fetches from all accounts. - cursor: Timestamp cursor (milliseconds since epoch) for pagination. Use with direction - to navigate results. + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided. @@ -240,8 +237,7 @@ def archive( archived=false to move back to inbox Args: - chat_id: The identifier of the chat to archive or unarchive (accepts both chatID and - local chat ID) + chat_id: Unique identifier of the chat. archived: True to archive, false to unarchive @@ -264,103 +260,6 @@ def archive( cast_to=BaseResponse, ) - def search( - self, - *, - account_ids: SequenceNotStr[str] | Omit = omit, - cursor: str | Omit = omit, - direction: Literal["after", "before"] | Omit = omit, - inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, - include_muted: Optional[bool] | Omit = omit, - last_activity_after: Union[str, datetime] | Omit = omit, - last_activity_before: Union[str, datetime] | Omit = omit, - limit: int | Omit = omit, - query: str | Omit = omit, - scope: Literal["titles", "participants"] | Omit = omit, - type: Literal["single", "group", "any"] | Omit = omit, - unread_only: Optional[bool] | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursorSearch[Chat]: - """ - Search chats by title/network or participants using Beeper Desktop's renderer - algorithm. - - Args: - account_ids: Provide an array of account IDs to filter chats from specific messaging accounts - only - - cursor: Pagination cursor from previous response. Use with direction to navigate results - - direction: Pagination direction: "after" for newer page, "before" for older page. Defaults - to "before" when only cursor is provided. - - inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), - "low-priority", or "archive". If not specified, shows all chats. - - include_muted: Include chats marked as Muted by the user, which are usually less important. - Default: true. Set to false if the user wants a more refined search. - - last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after - this time - - last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before - this time - - limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 - - query: Literal token search (non-semantic). Use single words users type (e.g., - "dinner"). When multiple words provided, ALL must match. Case-insensitive. - - scope: Search scope: 'titles' matches title + network; 'participants' matches - participant names. - - type: Specify the type of chats to retrieve: use "single" for direct messages, "group" - for group chats, or "any" to get all types - - unread_only: Set to true to only retrieve chats that have unread messages - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get_api_list( - "/v1/chats/search", - page=SyncCursorSearch[Chat], - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform( - { - "account_ids": account_ids, - "cursor": cursor, - "direction": direction, - "inbox": inbox, - "include_muted": include_muted, - "last_activity_after": last_activity_after, - "last_activity_before": last_activity_before, - "limit": limit, - "query": query, - "scope": scope, - "type": type, - "unread_only": unread_only, - }, - chat_search_params.ChatSearchParams, - ), - ), - model=Chat, - ) - class AsyncChatsResource(AsyncAPIResource): """Chats operations""" @@ -462,8 +361,7 @@ async def retrieve( Retrieve chat details including metadata, participants, and latest message Args: - chat_id: Unique identifier of the chat to retrieve. Not available for iMessage chats. - Participants are limited by 'maxParticipantCount'. + chat_id: Unique identifier of the chat. max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0–500. Defaults to 20. @@ -513,8 +411,7 @@ def list( Args: account_ids: Limit to specific account IDs. If omitted, fetches from all accounts. - cursor: Timestamp cursor (milliseconds since epoch) for pagination. Use with direction - to navigate results. + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided. @@ -565,8 +462,7 @@ async def archive( archived=false to move back to inbox Args: - chat_id: The identifier of the chat to archive or unarchive (accepts both chatID and - local chat ID) + chat_id: Unique identifier of the chat. archived: True to archive, false to unarchive @@ -589,103 +485,6 @@ async def archive( cast_to=BaseResponse, ) - def search( - self, - *, - account_ids: SequenceNotStr[str] | Omit = omit, - cursor: str | Omit = omit, - direction: Literal["after", "before"] | Omit = omit, - inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, - include_muted: Optional[bool] | Omit = omit, - last_activity_after: Union[str, datetime] | Omit = omit, - last_activity_before: Union[str, datetime] | Omit = omit, - limit: int | Omit = omit, - query: str | Omit = omit, - scope: Literal["titles", "participants"] | Omit = omit, - type: Literal["single", "group", "any"] | Omit = omit, - unread_only: Optional[bool] | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[Chat, AsyncCursorSearch[Chat]]: - """ - Search chats by title/network or participants using Beeper Desktop's renderer - algorithm. - - Args: - account_ids: Provide an array of account IDs to filter chats from specific messaging accounts - only - - cursor: Pagination cursor from previous response. Use with direction to navigate results - - direction: Pagination direction: "after" for newer page, "before" for older page. Defaults - to "before" when only cursor is provided. - - inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), - "low-priority", or "archive". If not specified, shows all chats. - - include_muted: Include chats marked as Muted by the user, which are usually less important. - Default: true. Set to false if the user wants a more refined search. - - last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after - this time - - last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before - this time - - limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 - - query: Literal token search (non-semantic). Use single words users type (e.g., - "dinner"). When multiple words provided, ALL must match. Case-insensitive. - - scope: Search scope: 'titles' matches title + network; 'participants' matches - participant names. - - type: Specify the type of chats to retrieve: use "single" for direct messages, "group" - for group chats, or "any" to get all types - - unread_only: Set to true to only retrieve chats that have unread messages - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get_api_list( - "/v1/chats/search", - page=AsyncCursorSearch[Chat], - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform( - { - "account_ids": account_ids, - "cursor": cursor, - "direction": direction, - "inbox": inbox, - "include_muted": include_muted, - "last_activity_after": last_activity_after, - "last_activity_before": last_activity_before, - "limit": limit, - "query": query, - "scope": scope, - "type": type, - "unread_only": unread_only, - }, - chat_search_params.ChatSearchParams, - ), - ), - model=Chat, - ) - class ChatsResourceWithRawResponse: def __init__(self, chats: ChatsResource) -> None: @@ -703,9 +502,6 @@ def __init__(self, chats: ChatsResource) -> None: self.archive = to_raw_response_wrapper( chats.archive, ) - self.search = to_raw_response_wrapper( - chats.search, - ) @cached_property def reminders(self) -> RemindersResourceWithRawResponse: @@ -729,9 +525,6 @@ def __init__(self, chats: AsyncChatsResource) -> None: self.archive = async_to_raw_response_wrapper( chats.archive, ) - self.search = async_to_raw_response_wrapper( - chats.search, - ) @cached_property def reminders(self) -> AsyncRemindersResourceWithRawResponse: @@ -755,9 +548,6 @@ def __init__(self, chats: ChatsResource) -> None: self.archive = to_streamed_response_wrapper( chats.archive, ) - self.search = to_streamed_response_wrapper( - chats.search, - ) @cached_property def reminders(self) -> RemindersResourceWithStreamingResponse: @@ -781,9 +571,6 @@ def __init__(self, chats: AsyncChatsResource) -> None: self.archive = async_to_streamed_response_wrapper( chats.archive, ) - self.search = async_to_streamed_response_wrapper( - chats.search, - ) @cached_property def reminders(self) -> AsyncRemindersResourceWithStreamingResponse: diff --git a/src/beeper_desktop_api/resources/chats/reminders.py b/src/beeper_desktop_api/resources/chats/reminders.py index e9da3b4..bf628ae 100644 --- a/src/beeper_desktop_api/resources/chats/reminders.py +++ b/src/beeper_desktop_api/resources/chats/reminders.py @@ -59,8 +59,7 @@ def create( Set a reminder for a chat at a specific time Args: - chat_id: The identifier of the chat to set reminder for (accepts both chatID and local - chat ID) + chat_id: Unique identifier of the chat. reminder: Reminder configuration @@ -98,8 +97,7 @@ def delete( Clear an existing reminder from a chat Args: - chat_id: The identifier of the chat to clear reminder from (accepts both chatID and local - chat ID) + chat_id: Unique identifier of the chat. extra_headers: Send extra headers @@ -158,8 +156,7 @@ async def create( Set a reminder for a chat at a specific time Args: - chat_id: The identifier of the chat to set reminder for (accepts both chatID and local - chat ID) + chat_id: Unique identifier of the chat. reminder: Reminder configuration @@ -197,8 +194,7 @@ async def delete( Clear an existing reminder from a chat Args: - chat_id: The identifier of the chat to clear reminder from (accepts both chatID and local - chat ID) + chat_id: Unique identifier of the chat. extra_headers: Send extra headers diff --git a/src/beeper_desktop_api/resources/contacts.py b/src/beeper_desktop_api/resources/contacts.py deleted file mode 100644 index 50fc4f9..0000000 --- a/src/beeper_desktop_api/resources/contacts.py +++ /dev/null @@ -1,189 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import httpx - -from ..types import contact_search_params -from .._types import Body, Query, Headers, NotGiven, not_given -from .._utils import maybe_transform, async_maybe_transform -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from .._base_client import make_request_options -from ..types.contact_search_response import ContactSearchResponse - -__all__ = ["ContactsResource", "AsyncContactsResource"] - - -class ContactsResource(SyncAPIResource): - """Contacts operations""" - - @cached_property - def with_raw_response(self) -> ContactsResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers - """ - return ContactsResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> ContactsResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response - """ - return ContactsResourceWithStreamingResponse(self) - - def search( - self, - account_id: str, - *, - query: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ContactSearchResponse: - """ - Search contacts across on a specific account using the network's search API. - Only use for creating new chats. - - Args: - account_id: Account ID this resource belongs to. - - query: Text to search users by. Network-specific behavior. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not 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/search", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform({"query": query}, contact_search_params.ContactSearchParams), - ), - cast_to=ContactSearchResponse, - ) - - -class AsyncContactsResource(AsyncAPIResource): - """Contacts operations""" - - @cached_property - def with_raw_response(self) -> AsyncContactsResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers - """ - return AsyncContactsResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncContactsResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response - """ - return AsyncContactsResourceWithStreamingResponse(self) - - async def search( - self, - account_id: str, - *, - query: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ContactSearchResponse: - """ - Search contacts across on a specific account using the network's search API. - Only use for creating new chats. - - Args: - account_id: Account ID this resource belongs to. - - query: Text to search users by. Network-specific behavior. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not 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/search", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=await async_maybe_transform({"query": query}, contact_search_params.ContactSearchParams), - ), - cast_to=ContactSearchResponse, - ) - - -class ContactsResourceWithRawResponse: - def __init__(self, contacts: ContactsResource) -> None: - self._contacts = contacts - - self.search = to_raw_response_wrapper( - contacts.search, - ) - - -class AsyncContactsResourceWithRawResponse: - def __init__(self, contacts: AsyncContactsResource) -> None: - self._contacts = contacts - - self.search = async_to_raw_response_wrapper( - contacts.search, - ) - - -class ContactsResourceWithStreamingResponse: - def __init__(self, contacts: ContactsResource) -> None: - self._contacts = contacts - - self.search = to_streamed_response_wrapper( - contacts.search, - ) - - -class AsyncContactsResourceWithStreamingResponse: - def __init__(self, contacts: AsyncContactsResource) -> None: - self._contacts = contacts - - self.search = async_to_streamed_response_wrapper( - contacts.search, - ) diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index e77b8f1..ad07df3 100644 --- a/src/beeper_desktop_api/resources/messages.py +++ b/src/beeper_desktop_api/resources/messages.py @@ -67,13 +67,12 @@ def list( Sorted by timestamp. Args: - chat_id: Chat ID to list messages from + chat_id: Unique identifier of the chat. - cursor: Message cursor for pagination. Use with direction to navigate results. + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. - direction: Pagination direction used with 'cursor': 'before' fetches older messages, - 'after' fetches newer messages. Defaults to 'before' when only 'cursor' is - provided. + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. extra_headers: Send extra headers @@ -176,7 +175,7 @@ def search( timeout: Override the client-level default timeout for this request, in seconds """ return self._get_api_list( - "/v1/messages/search", + "/v1/search/messages", page=SyncCursorSearch[Message], options=make_request_options( extra_headers=extra_headers, @@ -296,13 +295,12 @@ def list( Sorted by timestamp. Args: - chat_id: Chat ID to list messages from + chat_id: Unique identifier of the chat. - cursor: Message cursor for pagination. Use with direction to navigate results. + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. - direction: Pagination direction used with 'cursor': 'before' fetches older messages, - 'after' fetches newer messages. Defaults to 'before' when only 'cursor' is - provided. + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. extra_headers: Send extra headers @@ -405,7 +403,7 @@ def search( timeout: Override the client-level default timeout for this request, in seconds """ return self._get_api_list( - "/v1/messages/search", + "/v1/search/messages", page=AsyncCursorSearch[Message], options=make_request_options( extra_headers=extra_headers, diff --git a/src/beeper_desktop_api/resources/search.py b/src/beeper_desktop_api/resources/search.py new file mode 100644 index 0000000..0653cfa --- /dev/null +++ b/src/beeper_desktop_api/resources/search.py @@ -0,0 +1,397 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union, Optional +from datetime import datetime +from typing_extensions import Literal + +import httpx + +from ..types import search_chats_params, search_contacts_params +from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..pagination import SyncCursorSearch, AsyncCursorSearch +from ..types.chat import Chat +from .._base_client import AsyncPaginator, make_request_options +from ..types.search_contacts_response import SearchContactsResponse + +__all__ = ["SearchResource", "AsyncSearchResource"] + + +class SearchResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> SearchResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return SearchResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> SearchResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return SearchResourceWithStreamingResponse(self) + + def chats( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, + include_muted: Optional[bool] | Omit = omit, + last_activity_after: Union[str, datetime] | Omit = omit, + last_activity_before: Union[str, datetime] | Omit = omit, + limit: int | Omit = omit, + query: str | Omit = omit, + scope: Literal["titles", "participants"] | Omit = omit, + type: Literal["single", "group", "any"] | Omit = omit, + unread_only: Optional[bool] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SyncCursorSearch[Chat]: + """ + Search chats by title/network or participants using Beeper Desktop's renderer + algorithm. + + Args: + account_ids: Provide an array of account IDs to filter chats from specific messaging accounts + only + + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. + + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + + inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), + "low-priority", or "archive". If not specified, shows all chats. + + include_muted: Include chats marked as Muted by the user, which are usually less important. + Default: true. Set to false if the user wants a more refined search. + + last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after + this time + + last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before + this time + + limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 + + query: Literal token search (non-semantic). Use single words users type (e.g., + "dinner"). When multiple words provided, ALL must match. Case-insensitive. + + scope: Search scope: 'titles' matches title + network; 'participants' matches + participant names. + + type: Specify the type of chats to retrieve: use "single" for direct messages, "group" + for group chats, or "any" to get all types + + unread_only: Set to true to only retrieve chats that have unread messages + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v1/search/chats", + page=SyncCursorSearch[Chat], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "cursor": cursor, + "direction": direction, + "inbox": inbox, + "include_muted": include_muted, + "last_activity_after": last_activity_after, + "last_activity_before": last_activity_before, + "limit": limit, + "query": query, + "scope": scope, + "type": type, + "unread_only": unread_only, + }, + search_chats_params.SearchChatsParams, + ), + ), + model=Chat, + ) + + def contacts( + self, + account_id: str, + *, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SearchContactsResponse: + """ + Search contacts across on a specific account using the network's search API. + Only use for creating new chats. + + Args: + account_id: Account ID this resource belongs to. + + query: Text to search users by. Network-specific behavior. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not account_id: + raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") + return self._get( + f"/v1/search/contacts/{account_id}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"query": query}, search_contacts_params.SearchContactsParams), + ), + cast_to=SearchContactsResponse, + ) + + +class AsyncSearchResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncSearchResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncSearchResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncSearchResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return AsyncSearchResourceWithStreamingResponse(self) + + def chats( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, + include_muted: Optional[bool] | Omit = omit, + last_activity_after: Union[str, datetime] | Omit = omit, + last_activity_before: Union[str, datetime] | Omit = omit, + limit: int | Omit = omit, + query: str | Omit = omit, + scope: Literal["titles", "participants"] | Omit = omit, + type: Literal["single", "group", "any"] | Omit = omit, + unread_only: Optional[bool] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncPaginator[Chat, AsyncCursorSearch[Chat]]: + """ + Search chats by title/network or participants using Beeper Desktop's renderer + algorithm. + + Args: + account_ids: Provide an array of account IDs to filter chats from specific messaging accounts + only + + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. + + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + + inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), + "low-priority", or "archive". If not specified, shows all chats. + + include_muted: Include chats marked as Muted by the user, which are usually less important. + Default: true. Set to false if the user wants a more refined search. + + last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after + this time + + last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before + this time + + limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 + + query: Literal token search (non-semantic). Use single words users type (e.g., + "dinner"). When multiple words provided, ALL must match. Case-insensitive. + + scope: Search scope: 'titles' matches title + network; 'participants' matches + participant names. + + type: Specify the type of chats to retrieve: use "single" for direct messages, "group" + for group chats, or "any" to get all types + + unread_only: Set to true to only retrieve chats that have unread messages + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v1/search/chats", + page=AsyncCursorSearch[Chat], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "cursor": cursor, + "direction": direction, + "inbox": inbox, + "include_muted": include_muted, + "last_activity_after": last_activity_after, + "last_activity_before": last_activity_before, + "limit": limit, + "query": query, + "scope": scope, + "type": type, + "unread_only": unread_only, + }, + search_chats_params.SearchChatsParams, + ), + ), + model=Chat, + ) + + async def contacts( + self, + account_id: str, + *, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SearchContactsResponse: + """ + Search contacts across on a specific account using the network's search API. + Only use for creating new chats. + + Args: + account_id: Account ID this resource belongs to. + + query: Text to search users by. Network-specific behavior. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not account_id: + raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") + return await self._get( + f"/v1/search/contacts/{account_id}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"query": query}, search_contacts_params.SearchContactsParams), + ), + cast_to=SearchContactsResponse, + ) + + +class SearchResourceWithRawResponse: + def __init__(self, search: SearchResource) -> None: + self._search = search + + self.chats = to_raw_response_wrapper( + search.chats, + ) + self.contacts = to_raw_response_wrapper( + search.contacts, + ) + + +class AsyncSearchResourceWithRawResponse: + def __init__(self, search: AsyncSearchResource) -> None: + self._search = search + + self.chats = async_to_raw_response_wrapper( + search.chats, + ) + self.contacts = async_to_raw_response_wrapper( + search.contacts, + ) + + +class SearchResourceWithStreamingResponse: + def __init__(self, search: SearchResource) -> None: + self._search = search + + self.chats = to_streamed_response_wrapper( + search.chats, + ) + self.contacts = to_streamed_response_wrapper( + search.contacts, + ) + + +class AsyncSearchResourceWithStreamingResponse: + def __init__(self, search: AsyncSearchResource) -> None: + self._search = search + + self.chats = async_to_streamed_response_wrapper( + search.chats, + ) + self.contacts = async_to_streamed_response_wrapper( + search.contacts, + ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index e577cbf..6aeb07d 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -12,23 +12,23 @@ BaseResponse as BaseResponse, ) from .account import Account as Account -from .open_response import OpenResponse as OpenResponse +from .focus_response import FocusResponse as FocusResponse from .search_response import SearchResponse as SearchResponse from .chat_list_params import ChatListParams as ChatListParams from .chat_create_params import ChatCreateParams as ChatCreateParams from .chat_list_response import ChatListResponse as ChatListResponse -from .chat_search_params import ChatSearchParams as ChatSearchParams -from .client_open_params import ClientOpenParams as ClientOpenParams from .chat_archive_params import ChatArchiveParams as ChatArchiveParams +from .client_focus_params import ClientFocusParams as ClientFocusParams from .message_list_params import MessageListParams as MessageListParams from .message_send_params import MessageSendParams as MessageSendParams +from .search_chats_params import SearchChatsParams as SearchChatsParams from .chat_create_response import ChatCreateResponse as ChatCreateResponse from .chat_retrieve_params import ChatRetrieveParams as ChatRetrieveParams from .client_search_params import ClientSearchParams as ClientSearchParams from .account_list_response import AccountListResponse as AccountListResponse -from .contact_search_params import ContactSearchParams as ContactSearchParams from .message_search_params import MessageSearchParams as MessageSearchParams from .message_send_response import MessageSendResponse as MessageSendResponse -from .contact_search_response import ContactSearchResponse as ContactSearchResponse +from .search_contacts_params import SearchContactsParams as SearchContactsParams from .download_asset_response import DownloadAssetResponse as DownloadAssetResponse +from .search_contacts_response import SearchContactsResponse as SearchContactsResponse from .client_download_asset_params import ClientDownloadAssetParams as ClientDownloadAssetParams diff --git a/src/beeper_desktop_api/types/chat_list_params.py b/src/beeper_desktop_api/types/chat_list_params.py index e1d10b2..d216046 100644 --- a/src/beeper_desktop_api/types/chat_list_params.py +++ b/src/beeper_desktop_api/types/chat_list_params.py @@ -15,10 +15,7 @@ class ChatListParams(TypedDict, total=False): """Limit to specific account IDs. If omitted, fetches from all accounts.""" cursor: str - """Timestamp cursor (milliseconds since epoch) for pagination. - - Use with direction to navigate results. - """ + """Opaque pagination cursor; do not inspect. Use together with 'direction'.""" direction: Literal["after", "before"] """ diff --git a/src/beeper_desktop_api/types/client_open_params.py b/src/beeper_desktop_api/types/client_focus_params.py similarity index 91% rename from src/beeper_desktop_api/types/client_open_params.py rename to src/beeper_desktop_api/types/client_focus_params.py index 84dea5f..6359eb2 100644 --- a/src/beeper_desktop_api/types/client_open_params.py +++ b/src/beeper_desktop_api/types/client_focus_params.py @@ -6,10 +6,10 @@ from .._utils import PropertyInfo -__all__ = ["ClientOpenParams"] +__all__ = ["ClientFocusParams"] -class ClientOpenParams(TypedDict, total=False): +class ClientFocusParams(TypedDict, total=False): chat_id: Annotated[str, PropertyInfo(alias="chatID")] """Optional Beeper chat ID (or local chat ID) to focus after opening the app. diff --git a/src/beeper_desktop_api/types/open_response.py b/src/beeper_desktop_api/types/focus_response.py similarity index 76% rename from src/beeper_desktop_api/types/open_response.py rename to src/beeper_desktop_api/types/focus_response.py index 970f2ba..28875b1 100644 --- a/src/beeper_desktop_api/types/open_response.py +++ b/src/beeper_desktop_api/types/focus_response.py @@ -2,9 +2,9 @@ from .._models import BaseModel -__all__ = ["OpenResponse"] +__all__ = ["FocusResponse"] -class OpenResponse(BaseModel): +class FocusResponse(BaseModel): success: bool """Whether the app was successfully opened/focused.""" diff --git a/src/beeper_desktop_api/types/message_list_params.py b/src/beeper_desktop_api/types/message_list_params.py index d4e343a..e6a04d2 100644 --- a/src/beeper_desktop_api/types/message_list_params.py +++ b/src/beeper_desktop_api/types/message_list_params.py @@ -9,11 +9,10 @@ class MessageListParams(TypedDict, total=False): cursor: str - """Message cursor for pagination. Use with direction to navigate results.""" + """Opaque pagination cursor; do not inspect. Use together with 'direction'.""" direction: Literal["after", "before"] """ - Pagination direction used with 'cursor': 'before' fetches older messages, - 'after' fetches newer messages. Defaults to 'before' when only 'cursor' is - provided. + Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. """ diff --git a/src/beeper_desktop_api/types/chat_search_params.py b/src/beeper_desktop_api/types/search_chats_params.py similarity index 87% rename from src/beeper_desktop_api/types/chat_search_params.py rename to src/beeper_desktop_api/types/search_chats_params.py index de94b8d..d393720 100644 --- a/src/beeper_desktop_api/types/chat_search_params.py +++ b/src/beeper_desktop_api/types/search_chats_params.py @@ -9,10 +9,10 @@ from .._types import SequenceNotStr from .._utils import PropertyInfo -__all__ = ["ChatSearchParams"] +__all__ = ["SearchChatsParams"] -class ChatSearchParams(TypedDict, total=False): +class SearchChatsParams(TypedDict, total=False): account_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="accountIDs")] """ Provide an array of account IDs to filter chats from specific messaging accounts @@ -20,15 +20,12 @@ class ChatSearchParams(TypedDict, total=False): """ cursor: str - """Pagination cursor from previous response. - - Use with direction to navigate results - """ + """Opaque pagination cursor; do not inspect. Use together with 'direction'.""" direction: Literal["after", "before"] - """Pagination direction: "after" for newer page, "before" for older page. - - Defaults to "before" when only cursor is provided. + """ + Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. """ inbox: Literal["primary", "low-priority", "archive"] diff --git a/src/beeper_desktop_api/types/contact_search_params.py b/src/beeper_desktop_api/types/search_contacts_params.py similarity index 75% rename from src/beeper_desktop_api/types/contact_search_params.py rename to src/beeper_desktop_api/types/search_contacts_params.py index f9063e0..3e0352b 100644 --- a/src/beeper_desktop_api/types/contact_search_params.py +++ b/src/beeper_desktop_api/types/search_contacts_params.py @@ -4,9 +4,9 @@ from typing_extensions import Required, TypedDict -__all__ = ["ContactSearchParams"] +__all__ = ["SearchContactsParams"] -class ContactSearchParams(TypedDict, total=False): +class SearchContactsParams(TypedDict, total=False): query: Required[str] """Text to search users by. Network-specific behavior.""" diff --git a/src/beeper_desktop_api/types/contact_search_response.py b/src/beeper_desktop_api/types/search_contacts_response.py similarity index 71% rename from src/beeper_desktop_api/types/contact_search_response.py rename to src/beeper_desktop_api/types/search_contacts_response.py index 71c609e..1bbf6db 100644 --- a/src/beeper_desktop_api/types/contact_search_response.py +++ b/src/beeper_desktop_api/types/search_contacts_response.py @@ -5,8 +5,8 @@ from .._models import BaseModel from .shared.user import User -__all__ = ["ContactSearchResponse"] +__all__ = ["SearchContactsResponse"] -class ContactSearchResponse(BaseModel): +class SearchContactsResponse(BaseModel): items: List[User] diff --git a/src/beeper_desktop_api/types/shared/message.py b/src/beeper_desktop_api/types/shared/message.py index b9d70ff..ff2ca3a 100644 --- a/src/beeper_desktop_api/types/shared/message.py +++ b/src/beeper_desktop_api/types/shared/message.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List, Union, Optional +from typing import List, Optional from datetime import datetime from pydantic import Field as FieldInfo @@ -14,21 +14,18 @@ class Message(BaseModel): id: str - """Stable message ID for cursor pagination.""" + """Message ID.""" account_id: str = FieldInfo(alias="accountID") """Beeper account ID the message belongs to.""" chat_id: str = FieldInfo(alias="chatID") - """Beeper chat/thread/room ID.""" - - message_id: str = FieldInfo(alias="messageID") - """Stable message ID (same as id).""" + """Unique identifier of the chat.""" sender_id: str = FieldInfo(alias="senderID") """Sender user ID.""" - sort_key: Union[str, float] = FieldInfo(alias="sortKey") + sort_key: str = FieldInfo(alias="sortKey") """A unique key used to sort messages""" timestamp: datetime diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index 352bc2f..84cfdb1 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -14,8 +14,7 @@ ChatListResponse, ChatCreateResponse, ) -from beeper_desktop_api._utils import parse_datetime -from beeper_desktop_api.pagination import SyncCursorList, AsyncCursorList, SyncCursorSearch, AsyncCursorSearch +from beeper_desktop_api.pagination import SyncCursorList, AsyncCursorList from beeper_desktop_api.types.shared import BaseResponse base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -131,7 +130,7 @@ def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", ], - cursor="1725489123456", + cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) assert_matches_type(SyncCursorList[ChatListResponse], chat, path=["response"]) @@ -202,52 +201,6 @@ def test_path_params_archive(self, client: BeeperDesktop) -> None: chat_id="", ) - @parametrize - def test_method_search(self, client: BeeperDesktop) -> None: - chat = client.chats.search() - assert_matches_type(SyncCursorSearch[Chat], chat, path=["response"]) - - @parametrize - def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: - chat = client.chats.search( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", - ], - cursor="eyJvZmZzZXQiOjE3MTk5OTk5OTl9", - direction="after", - inbox="primary", - include_muted=True, - last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), - last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), - limit=1, - query="x", - scope="titles", - type="single", - unread_only=True, - ) - assert_matches_type(SyncCursorSearch[Chat], chat, path=["response"]) - - @parametrize - def test_raw_response_search(self, client: BeeperDesktop) -> None: - response = client.chats.with_raw_response.search() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - chat = response.parse() - assert_matches_type(SyncCursorSearch[Chat], chat, path=["response"]) - - @parametrize - def test_streaming_response_search(self, client: BeeperDesktop) -> None: - with client.chats.with_streaming_response.search() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - chat = response.parse() - assert_matches_type(SyncCursorSearch[Chat], chat, path=["response"]) - - assert cast(Any, response.is_closed) is True - class TestAsyncChats: parametrize = pytest.mark.parametrize( @@ -361,7 +314,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncBeeperDeskto "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", ], - cursor="1725489123456", + cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) assert_matches_type(AsyncCursorList[ChatListResponse], chat, path=["response"]) @@ -431,49 +384,3 @@ async def test_path_params_archive(self, async_client: AsyncBeeperDesktop) -> No await async_client.chats.with_raw_response.archive( chat_id="", ) - - @parametrize - async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.search() - assert_matches_type(AsyncCursorSearch[Chat], chat, path=["response"]) - - @parametrize - async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.search( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", - ], - cursor="eyJvZmZzZXQiOjE3MTk5OTk5OTl9", - direction="after", - inbox="primary", - include_muted=True, - last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), - last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), - limit=1, - query="x", - scope="titles", - type="single", - unread_only=True, - ) - assert_matches_type(AsyncCursorSearch[Chat], chat, path=["response"]) - - @parametrize - async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.chats.with_raw_response.search() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - chat = await response.parse() - assert_matches_type(AsyncCursorSearch[Chat], chat, path=["response"]) - - @parametrize - async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.chats.with_streaming_response.search() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - chat = await response.parse() - assert_matches_type(AsyncCursorSearch[Chat], chat, path=["response"]) - - assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_client.py b/tests/api_resources/test_client.py index d5de032..54e150e 100644 --- a/tests/api_resources/test_client.py +++ b/tests/api_resources/test_client.py @@ -10,7 +10,7 @@ from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop from beeper_desktop_api.types import ( - OpenResponse, + FocusResponse, SearchResponse, DownloadAssetResponse, ) @@ -53,37 +53,37 @@ def test_streaming_response_download_asset(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True @parametrize - def test_method_open(self, client: BeeperDesktop) -> None: - client_ = client.open() - assert_matches_type(OpenResponse, client_, path=["response"]) + def test_method_focus(self, client: BeeperDesktop) -> None: + client_ = client.focus() + assert_matches_type(FocusResponse, client_, path=["response"]) @parametrize - def test_method_open_with_all_params(self, client: BeeperDesktop) -> None: - client_ = client.open( + def test_method_focus_with_all_params(self, client: BeeperDesktop) -> None: + client_ = client.focus( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", draft_attachment_path="draftAttachmentPath", draft_text="draftText", message_id="messageID", ) - assert_matches_type(OpenResponse, client_, path=["response"]) + assert_matches_type(FocusResponse, client_, path=["response"]) @parametrize - def test_raw_response_open(self, client: BeeperDesktop) -> None: - response = client.with_raw_response.open() + def test_raw_response_focus(self, client: BeeperDesktop) -> None: + response = client.with_raw_response.focus() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" client_ = response.parse() - assert_matches_type(OpenResponse, client_, path=["response"]) + assert_matches_type(FocusResponse, client_, path=["response"]) @parametrize - def test_streaming_response_open(self, client: BeeperDesktop) -> None: - with client.with_streaming_response.open() as response: + def test_streaming_response_focus(self, client: BeeperDesktop) -> None: + with client.with_streaming_response.focus() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" client_ = response.parse() - assert_matches_type(OpenResponse, client_, path=["response"]) + assert_matches_type(FocusResponse, client_, path=["response"]) assert cast(Any, response.is_closed) is True @@ -156,37 +156,37 @@ async def test_streaming_response_download_asset(self, async_client: AsyncBeeper assert cast(Any, response.is_closed) is True @parametrize - async def test_method_open(self, async_client: AsyncBeeperDesktop) -> None: - client = await async_client.open() - assert_matches_type(OpenResponse, client, path=["response"]) + async def test_method_focus(self, async_client: AsyncBeeperDesktop) -> None: + client = await async_client.focus() + assert_matches_type(FocusResponse, client, path=["response"]) @parametrize - async def test_method_open_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: - client = await async_client.open( + async def test_method_focus_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + client = await async_client.focus( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", draft_attachment_path="draftAttachmentPath", draft_text="draftText", message_id="messageID", ) - assert_matches_type(OpenResponse, client, path=["response"]) + assert_matches_type(FocusResponse, client, path=["response"]) @parametrize - async def test_raw_response_open(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.with_raw_response.open() + async def test_raw_response_focus(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.with_raw_response.focus() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" client = await response.parse() - assert_matches_type(OpenResponse, client, path=["response"]) + assert_matches_type(FocusResponse, client, path=["response"]) @parametrize - async def test_streaming_response_open(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.with_streaming_response.open() as response: + async def test_streaming_response_focus(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.with_streaming_response.focus() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" client = await response.parse() - assert_matches_type(OpenResponse, client, path=["response"]) + assert_matches_type(FocusResponse, client, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_contacts.py b/tests/api_resources/test_contacts.py deleted file mode 100644 index 158b961..0000000 --- a/tests/api_resources/test_contacts.py +++ /dev/null @@ -1,108 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from tests.utils import assert_matches_type -from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types import ContactSearchResponse - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestContacts: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @parametrize - def test_method_search(self, client: BeeperDesktop) -> None: - contact = client.contacts.search( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) - assert_matches_type(ContactSearchResponse, contact, path=["response"]) - - @parametrize - def test_raw_response_search(self, client: BeeperDesktop) -> None: - response = client.contacts.with_raw_response.search( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - contact = response.parse() - assert_matches_type(ContactSearchResponse, contact, path=["response"]) - - @parametrize - def test_streaming_response_search(self, client: BeeperDesktop) -> None: - with client.contacts.with_streaming_response.search( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - contact = response.parse() - assert_matches_type(ContactSearchResponse, contact, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - def test_path_params_search(self, client: BeeperDesktop) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `account_id` but received ''"): - client.contacts.with_raw_response.search( - account_id="", - query="x", - ) - - -class TestAsyncContacts: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @parametrize - async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: - contact = await async_client.contacts.search( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) - assert_matches_type(ContactSearchResponse, contact, path=["response"]) - - @parametrize - async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.contacts.with_raw_response.search( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - contact = await response.parse() - assert_matches_type(ContactSearchResponse, contact, path=["response"]) - - @parametrize - async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.contacts.with_streaming_response.search( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - contact = await response.parse() - assert_matches_type(ContactSearchResponse, contact, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - async def test_path_params_search(self, async_client: AsyncBeeperDesktop) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `account_id` but received ''"): - await async_client.contacts.with_raw_response.search( - account_id="", - query="x", - ) diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index dd93537..d64cf44 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -33,7 +33,7 @@ def test_method_list(self, client: BeeperDesktop) -> None: def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: message = client.messages.list( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - cursor="821744079", + cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) assert_matches_type(SyncCursorList[Message], message, path=["response"]) @@ -181,7 +181,7 @@ async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.list( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - cursor="821744079", + cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) assert_matches_type(AsyncCursorList[Message], message, path=["response"]) diff --git a/tests/api_resources/test_search.py b/tests/api_resources/test_search.py new file mode 100644 index 0000000..1c70e2d --- /dev/null +++ b/tests/api_resources/test_search.py @@ -0,0 +1,202 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import Chat, SearchContactsResponse +from beeper_desktop_api._utils import parse_datetime +from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestSearch: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_chats(self, client: BeeperDesktop) -> None: + search = client.search.chats() + assert_matches_type(SyncCursorSearch[Chat], search, path=["response"]) + + @parametrize + def test_method_chats_with_all_params(self, client: BeeperDesktop) -> None: + search = client.search.chats( + account_ids=[ + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", + ], + cursor="1725489123456|c29tZUltc2dQYWdl", + direction="before", + inbox="primary", + include_muted=True, + last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), + last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), + limit=1, + query="x", + scope="titles", + type="single", + unread_only=True, + ) + assert_matches_type(SyncCursorSearch[Chat], search, path=["response"]) + + @parametrize + def test_raw_response_chats(self, client: BeeperDesktop) -> None: + response = client.search.with_raw_response.chats() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + search = response.parse() + assert_matches_type(SyncCursorSearch[Chat], search, path=["response"]) + + @parametrize + def test_streaming_response_chats(self, client: BeeperDesktop) -> None: + with client.search.with_streaming_response.chats() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + search = response.parse() + assert_matches_type(SyncCursorSearch[Chat], search, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_contacts(self, client: BeeperDesktop) -> None: + search = client.search.contacts( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) + assert_matches_type(SearchContactsResponse, search, path=["response"]) + + @parametrize + def test_raw_response_contacts(self, client: BeeperDesktop) -> None: + response = client.search.with_raw_response.contacts( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + search = response.parse() + assert_matches_type(SearchContactsResponse, search, path=["response"]) + + @parametrize + def test_streaming_response_contacts(self, client: BeeperDesktop) -> None: + with client.search.with_streaming_response.contacts( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + search = response.parse() + assert_matches_type(SearchContactsResponse, search, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_contacts(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `account_id` but received ''"): + client.search.with_raw_response.contacts( + account_id="", + query="x", + ) + + +class TestAsyncSearch: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_chats(self, async_client: AsyncBeeperDesktop) -> None: + search = await async_client.search.chats() + assert_matches_type(AsyncCursorSearch[Chat], search, path=["response"]) + + @parametrize + async def test_method_chats_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + search = await async_client.search.chats( + account_ids=[ + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", + ], + cursor="1725489123456|c29tZUltc2dQYWdl", + direction="before", + inbox="primary", + include_muted=True, + last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), + last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), + limit=1, + query="x", + scope="titles", + type="single", + unread_only=True, + ) + assert_matches_type(AsyncCursorSearch[Chat], search, path=["response"]) + + @parametrize + async def test_raw_response_chats(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.search.with_raw_response.chats() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + search = await response.parse() + assert_matches_type(AsyncCursorSearch[Chat], search, path=["response"]) + + @parametrize + async def test_streaming_response_chats(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.search.with_streaming_response.chats() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + search = await response.parse() + assert_matches_type(AsyncCursorSearch[Chat], search, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_contacts(self, async_client: AsyncBeeperDesktop) -> None: + search = await async_client.search.contacts( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) + assert_matches_type(SearchContactsResponse, search, path=["response"]) + + @parametrize + async def test_raw_response_contacts(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.search.with_raw_response.contacts( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + search = await response.parse() + assert_matches_type(SearchContactsResponse, search, path=["response"]) + + @parametrize + async def test_streaming_response_contacts(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.search.with_streaming_response.contacts( + account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + search = await response.parse() + assert_matches_type(SearchContactsResponse, search, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_contacts(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `account_id` but received ''"): + await async_client.search.with_raw_response.contacts( + account_id="", + query="x", + ) From 6f0a2c01526ff80487999bb8576ed8ce5649d0d8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 11 Oct 2025 02:14:01 +0000 Subject: [PATCH 22/98] chore(internal): detect missing future annotations with ruff --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d3a4a85..4da43df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -224,6 +224,8 @@ select = [ "B", # remove unused imports "F401", + # check for missing future annotations + "FA102", # bare except statements "E722", # unused arguments @@ -246,6 +248,8 @@ unfixable = [ "T203", ] +extend-safe-fixes = ["FA102"] + [tool.ruff.lint.flake8-tidy-imports.banned-api] "functools.lru_cache".msg = "This function does not retain type information for the wrapped function's arguments; The `lru_cache` function from `_utils` should be used instead" From 885f124ca33d9678e12ef4a26e03e6cd137a5ab1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 16:37:28 +0000 Subject: [PATCH 23/98] feat(api): bump for new endpoints --- .stats.yml | 6 +- README.md | 108 ++++- api.md | 39 +- src/beeper_desktop_api/_client.py | 104 +---- src/beeper_desktop_api/pagination.py | 79 +++- src/beeper_desktop_api/resources/__init__.py | 26 +- .../resources/accounts/__init__.py | 33 ++ .../resources/{ => accounts}/accounts.py | 50 ++- .../resources/accounts/contacts.py | 189 +++++++++ src/beeper_desktop_api/resources/assets.py | 173 ++++++++ .../resources/chats/chats.py | 254 ++++++++++- .../resources/chats/reminders.py | 27 +- src/beeper_desktop_api/resources/messages.py | 18 +- src/beeper_desktop_api/resources/search.py | 397 ------------------ src/beeper_desktop_api/types/__init__.py | 17 +- .../types/accounts/__init__.py | 6 + .../contact_search_params.py} | 4 +- .../types/accounts/contact_search_response.py | 12 + ...set_params.py => asset_download_params.py} | 4 +- ...response.py => asset_download_response.py} | 4 +- src/beeper_desktop_api/types/chat.py | 15 +- .../types/chat_create_response.py | 10 +- .../types/chat_retrieve_params.py | 2 +- ..._chats_params.py => chat_search_params.py} | 4 +- .../types/message_send_response.py | 4 +- .../types/search_contacts_response.py | 12 - .../types/shared/__init__.py | 1 - .../types/shared/base_response.py | 13 - src/beeper_desktop_api/types/shared/error.py | 34 +- .../types/shared/message.py | 2 +- tests/api_resources/accounts/__init__.py | 1 + tests/api_resources/accounts/test_contacts.py | 108 +++++ tests/api_resources/chats/test_reminders.py | 30 +- tests/api_resources/test_assets.py | 86 ++++ tests/api_resources/test_chats.py | 144 +++++-- tests/api_resources/test_client.py | 68 +-- tests/api_resources/test_messages.py | 18 +- tests/api_resources/test_search.py | 202 --------- 38 files changed, 1330 insertions(+), 974 deletions(-) create mode 100644 src/beeper_desktop_api/resources/accounts/__init__.py rename src/beeper_desktop_api/resources/{ => accounts}/accounts.py (74%) create mode 100644 src/beeper_desktop_api/resources/accounts/contacts.py create mode 100644 src/beeper_desktop_api/resources/assets.py delete mode 100644 src/beeper_desktop_api/resources/search.py create mode 100644 src/beeper_desktop_api/types/accounts/__init__.py rename src/beeper_desktop_api/types/{search_contacts_params.py => accounts/contact_search_params.py} (75%) create mode 100644 src/beeper_desktop_api/types/accounts/contact_search_response.py rename src/beeper_desktop_api/types/{client_download_asset_params.py => asset_download_params.py} (74%) rename src/beeper_desktop_api/types/{download_asset_response.py => asset_download_response.py} (83%) rename src/beeper_desktop_api/types/{search_chats_params.py => chat_search_params.py} (96%) delete mode 100644 src/beeper_desktop_api/types/search_contacts_response.py delete mode 100644 src/beeper_desktop_api/types/shared/base_response.py create mode 100644 tests/api_resources/accounts/__init__.py create mode 100644 tests/api_resources/accounts/test_contacts.py create mode 100644 tests/api_resources/test_assets.py delete mode 100644 tests/api_resources/test_search.py diff --git a/.stats.yml b/.stats.yml index 831b150..a005806 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-f48bb509412536c41fdfa537894cecd4af486099d95fe79369f2ef239fa94a75.yml -openapi_spec_hash: f8b886fdfdc5ee3d51d2cd05daee3bab -config_hash: 6f12c5e4c662e1f315b95a70389b1549 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-2a2f6bfd48fd33b8162f97ecb46ad4568eb15b7221add1766567cb713d9609d8.yml +openapi_spec_hash: 714cbd98920316b5dddbc881743ee554 +config_hash: 15424d9ae390c7fca17dbf08619fc88b diff --git a/README.md b/README.md index f06fa5b..12f9c22 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,12 @@ client = BeeperDesktop( access_token=os.environ.get("BEEPER_ACCESS_TOKEN"), # This is the default and can be omitted ) -accounts = client.accounts.list() +page = client.chats.search( + include_muted=True, + limit=3, + type="single", +) +print(page.items) ``` While you can provide a `access_token` keyword argument, @@ -56,7 +61,12 @@ client = AsyncBeeperDesktop( async def main() -> None: - accounts = await client.accounts.list() + page = await client.chats.search( + include_muted=True, + limit=3, + type="single", + ) + print(page.items) asyncio.run(main()) @@ -88,7 +98,12 @@ async def main() -> None: access_token="My Access Token", http_client=DefaultAioHttpClient(), ) as client: - accounts = await client.accounts.list() + page = await client.chats.search( + include_muted=True, + limit=3, + type="single", + ) + print(page.items) asyncio.run(main()) @@ -103,6 +118,85 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. +## Pagination + +List methods in the Beeper Desktop API are paginated. + +This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually: + +```python +from beeper_desktop_api import BeeperDesktop + +client = BeeperDesktop() + +all_messages = [] +# Automatically fetches more pages as needed. +for message in client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", +): + # Do something with message here + all_messages.append(message) +print(all_messages) +``` + +Or, asynchronously: + +```python +import asyncio +from beeper_desktop_api import AsyncBeeperDesktop + +client = AsyncBeeperDesktop() + + +async def main() -> None: + all_messages = [] + # Iterate through items across all pages, issuing requests as needed. + async for message in client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", + ): + all_messages.append(message) + print(all_messages) + + +asyncio.run(main()) +``` + +Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages: + +```python +first_page = await client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", +) +if first_page.has_next_page(): + print(f"will fetch next page using these details: {first_page.next_page_info()}") + next_page = await first_page.get_next_page() + print(f"number of items we just fetched: {len(next_page.items)}") + +# Remove `await` for non-async usage. +``` + +Or just work directly with the returned data: + +```python +first_page = await client.messages.search( + account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + limit=10, + query="deployment", +) + +print(f"next page cursor: {first_page.oldest_cursor}") # => "next page cursor: ..." +for message in first_page.items: + print(message.id) + +# Remove `await` for non-async usage. +``` + ## Nested params Nested parameters are dictionaries, typed using `TypedDict`, for example: @@ -112,11 +206,10 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() -base_response = client.chats.reminders.create( +client.chats.reminders.create( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reminder={"remind_at_ms": 0}, ) -print(base_response.reminder) ``` ## Handling errors @@ -135,10 +228,7 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() try: - client.messages.send( - chat_id="1229391", - text="Hello! Just checking in on the project status.", - ) + client.accounts.list() except beeper_desktop_api.APIConnectionError as e: print("The server could not be reached") print(e.__cause__) # an underlying Exception, likely raised within httpx. diff --git a/api.md b/api.md index 119d6f4..48bc9b9 100644 --- a/api.md +++ b/api.md @@ -1,7 +1,7 @@ # Shared Types ```python -from beeper_desktop_api.types import Attachment, BaseResponse, Error, Message, Reaction, User +from beeper_desktop_api.types import Attachment, Error, Message, Reaction, User ``` # BeeperDesktop @@ -9,12 +9,11 @@ from beeper_desktop_api.types import Attachment, BaseResponse, Error, Message, R Types: ```python -from beeper_desktop_api.types import DownloadAssetResponse, FocusResponse, SearchResponse +from beeper_desktop_api.types import FocusResponse, SearchResponse ``` Methods: -- client.download_asset(\*\*params) -> DownloadAssetResponse - client.focus(\*\*params) -> FocusResponse - client.search(\*\*params) -> SearchResponse @@ -28,20 +27,19 @@ from beeper_desktop_api.types import Account, AccountListResponse Methods: -- client.accounts.list() -> AccountListResponse +- client.accounts.list() -> AccountListResponse -# Search +## Contacts Types: ```python -from beeper_desktop_api.types import SearchContactsResponse +from beeper_desktop_api.types.accounts import ContactSearchResponse ``` Methods: -- client.search.chats(\*\*params) -> SyncCursorSearch[Chat] -- client.search.contacts(account_id, \*\*params) -> SearchContactsResponse +- client.accounts.contacts.search(account_id, \*\*params) -> ContactSearchResponse # Chats @@ -55,15 +53,16 @@ Methods: - client.chats.create(\*\*params) -> ChatCreateResponse - client.chats.retrieve(chat_id, \*\*params) -> Chat -- client.chats.list(\*\*params) -> SyncCursorList[ChatListResponse] -- client.chats.archive(chat_id, \*\*params) -> BaseResponse +- client.chats.list(\*\*params) -> SyncCursorNoLimit[ChatListResponse] +- client.chats.archive(chat_id, \*\*params) -> None +- client.chats.search(\*\*params) -> SyncCursorSearch[Chat] ## Reminders Methods: -- client.chats.reminders.create(chat_id, \*\*params) -> BaseResponse -- client.chats.reminders.delete(chat_id) -> BaseResponse +- client.chats.reminders.create(chat_id, \*\*params) -> None +- client.chats.reminders.delete(chat_id) -> None # Messages @@ -75,6 +74,18 @@ from beeper_desktop_api.types import MessageSendResponse Methods: -- client.messages.list(chat_id, \*\*params) -> SyncCursorList[Message] -- client.messages.search(\*\*params) -> SyncCursorSearch[Message] +- client.messages.list(chat_id, \*\*params) -> SyncCursorSortKey[Message] +- client.messages.search(\*\*params) -> SyncCursorSearch[Message] - client.messages.send(chat_id, \*\*params) -> MessageSendResponse + +# Assets + +Types: + +```python +from beeper_desktop_api.types import AssetDownloadResponse +``` + +Methods: + +- client.assets.download(\*\*params) -> AssetDownloadResponse diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index 82c9d75..b5d0200 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -10,7 +10,7 @@ from . import _exceptions from ._qs import Querystring -from .types import client_focus_params, client_search_params, client_download_asset_params +from .types import client_focus_params, client_search_params from ._types import ( Body, Omit, @@ -37,7 +37,7 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from .resources import search, accounts, messages +from .resources import assets, messages from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError, BeeperDesktopError from ._base_client import ( @@ -47,9 +47,9 @@ make_request_options, ) from .resources.chats import chats +from .resources.accounts import accounts from .types.focus_response import FocusResponse from .types.search_response import SearchResponse -from .types.download_asset_response import DownloadAssetResponse __all__ = [ "Timeout", @@ -65,9 +65,9 @@ class BeeperDesktop(SyncAPIClient): accounts: accounts.AccountsResource - search: search.SearchResource chats: chats.ChatsResource messages: messages.MessagesResource + assets: assets.AssetsResource with_raw_response: BeeperDesktopWithRawResponse with_streaming_response: BeeperDesktopWithStreamedResponse @@ -126,9 +126,9 @@ def __init__( ) self.accounts = accounts.AccountsResource(self) - self.search = search.SearchResource(self) self.chats = chats.ChatsResource(self) self.messages = messages.MessagesResource(self) + self.assets = assets.AssetsResource(self) self.with_raw_response = BeeperDesktopWithRawResponse(self) self.with_streaming_response = BeeperDesktopWithStreamedResponse(self) @@ -203,41 +203,6 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy - def download_asset( - self, - *, - url: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> DownloadAssetResponse: - """ - Download a Matrix asset using its mxc:// or localmxc:// URL and return the local - file URL. - - Args: - url: Matrix content URL (mxc:// or localmxc://) for the asset to download. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self.post( - "/v1/download-asset", - body=maybe_transform({"url": url}, client_download_asset_params.ClientDownloadAssetParams), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=DownloadAssetResponse, - ) - def focus( self, *, @@ -366,9 +331,9 @@ def _make_status_error( class AsyncBeeperDesktop(AsyncAPIClient): accounts: accounts.AsyncAccountsResource - search: search.AsyncSearchResource chats: chats.AsyncChatsResource messages: messages.AsyncMessagesResource + assets: assets.AsyncAssetsResource with_raw_response: AsyncBeeperDesktopWithRawResponse with_streaming_response: AsyncBeeperDesktopWithStreamedResponse @@ -427,9 +392,9 @@ def __init__( ) self.accounts = accounts.AsyncAccountsResource(self) - self.search = search.AsyncSearchResource(self) self.chats = chats.AsyncChatsResource(self) self.messages = messages.AsyncMessagesResource(self) + self.assets = assets.AsyncAssetsResource(self) self.with_raw_response = AsyncBeeperDesktopWithRawResponse(self) self.with_streaming_response = AsyncBeeperDesktopWithStreamedResponse(self) @@ -504,41 +469,6 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy - async def download_asset( - self, - *, - url: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> DownloadAssetResponse: - """ - Download a Matrix asset using its mxc:// or localmxc:// URL and return the local - file URL. - - Args: - url: Matrix content URL (mxc:// or localmxc://) for the asset to download. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self.post( - "/v1/download-asset", - body=await async_maybe_transform({"url": url}, client_download_asset_params.ClientDownloadAssetParams), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=DownloadAssetResponse, - ) - async def focus( self, *, @@ -668,13 +598,10 @@ def _make_status_error( class BeeperDesktopWithRawResponse: def __init__(self, client: BeeperDesktop) -> None: self.accounts = accounts.AccountsResourceWithRawResponse(client.accounts) - self.search = search.SearchResourceWithRawResponse(client.search) self.chats = chats.ChatsResourceWithRawResponse(client.chats) self.messages = messages.MessagesResourceWithRawResponse(client.messages) + self.assets = assets.AssetsResourceWithRawResponse(client.assets) - self.download_asset = to_raw_response_wrapper( - client.download_asset, - ) self.focus = to_raw_response_wrapper( client.focus, ) @@ -686,13 +613,10 @@ def __init__(self, client: BeeperDesktop) -> None: class AsyncBeeperDesktopWithRawResponse: def __init__(self, client: AsyncBeeperDesktop) -> None: self.accounts = accounts.AsyncAccountsResourceWithRawResponse(client.accounts) - self.search = search.AsyncSearchResourceWithRawResponse(client.search) self.chats = chats.AsyncChatsResourceWithRawResponse(client.chats) self.messages = messages.AsyncMessagesResourceWithRawResponse(client.messages) + self.assets = assets.AsyncAssetsResourceWithRawResponse(client.assets) - self.download_asset = async_to_raw_response_wrapper( - client.download_asset, - ) self.focus = async_to_raw_response_wrapper( client.focus, ) @@ -704,13 +628,10 @@ def __init__(self, client: AsyncBeeperDesktop) -> None: class BeeperDesktopWithStreamedResponse: def __init__(self, client: BeeperDesktop) -> None: self.accounts = accounts.AccountsResourceWithStreamingResponse(client.accounts) - self.search = search.SearchResourceWithStreamingResponse(client.search) self.chats = chats.ChatsResourceWithStreamingResponse(client.chats) self.messages = messages.MessagesResourceWithStreamingResponse(client.messages) + self.assets = assets.AssetsResourceWithStreamingResponse(client.assets) - self.download_asset = to_streamed_response_wrapper( - client.download_asset, - ) self.focus = to_streamed_response_wrapper( client.focus, ) @@ -722,13 +643,10 @@ def __init__(self, client: BeeperDesktop) -> None: class AsyncBeeperDesktopWithStreamedResponse: def __init__(self, client: AsyncBeeperDesktop) -> None: self.accounts = accounts.AsyncAccountsResourceWithStreamingResponse(client.accounts) - self.search = search.AsyncSearchResourceWithStreamingResponse(client.search) self.chats = chats.AsyncChatsResourceWithStreamingResponse(client.chats) self.messages = messages.AsyncMessagesResourceWithStreamingResponse(client.messages) + self.assets = assets.AsyncAssetsResourceWithStreamingResponse(client.assets) - self.download_asset = async_to_streamed_response_wrapper( - client.download_asset, - ) self.focus = async_to_streamed_response_wrapper( client.focus, ) diff --git a/src/beeper_desktop_api/pagination.py b/src/beeper_desktop_api/pagination.py index 7fd745f..03ecb2a 100644 --- a/src/beeper_desktop_api/pagination.py +++ b/src/beeper_desktop_api/pagination.py @@ -7,13 +7,20 @@ from ._base_client import BasePage, PageInfo, BaseSyncPage, BaseAsyncPage -__all__ = ["SyncCursorSearch", "AsyncCursorSearch", "SyncCursorList", "AsyncCursorList"] +__all__ = [ + "SyncCursorSearch", + "AsyncCursorSearch", + "SyncCursorNoLimit", + "AsyncCursorNoLimit", + "SyncCursorSortKey", + "AsyncCursorSortKey", +] _T = TypeVar("_T") @runtime_checkable -class CursorListItem(Protocol): +class CursorSortKeyItem(Protocol): sort_key: Optional[str] @@ -77,7 +84,67 @@ def next_page_info(self) -> Optional[PageInfo]: return PageInfo(params={"cursor": oldest_cursor}) -class SyncCursorList(BaseSyncPage[_T], BasePage[_T], Generic[_T]): +class SyncCursorNoLimit(BaseSyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) + oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) + newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", 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]: + oldest_cursor = self.oldest_cursor + if not oldest_cursor: + return None + + return PageInfo(params={"cursor": oldest_cursor}) + + +class AsyncCursorNoLimit(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) + oldest_cursor: Optional[str] = FieldInfo(alias="oldestCursor", default=None) + newest_cursor: Optional[str] = FieldInfo(alias="newestCursor", 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]: + oldest_cursor = self.oldest_cursor + if not oldest_cursor: + 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) @@ -103,14 +170,14 @@ def next_page_info(self) -> Optional[PageInfo]: return None item = cast(Any, items[-1]) - if not isinstance(item, CursorListItem) or item.sort_key is None: + 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 AsyncCursorList(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): +class AsyncCursorSortKey(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): items: List[_T] has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) @@ -136,7 +203,7 @@ def next_page_info(self) -> Optional[PageInfo]: return None item = cast(Any, items[-1]) - if not isinstance(item, CursorListItem) or item.sort_key is None: + if not isinstance(item, CursorSortKeyItem) or item.sort_key is None: # TODO emit warning log return None diff --git a/src/beeper_desktop_api/resources/__init__.py b/src/beeper_desktop_api/resources/__init__.py index eedc9bc..391042a 100644 --- a/src/beeper_desktop_api/resources/__init__.py +++ b/src/beeper_desktop_api/resources/__init__.py @@ -8,13 +8,13 @@ ChatsResourceWithStreamingResponse, AsyncChatsResourceWithStreamingResponse, ) -from .search import ( - SearchResource, - AsyncSearchResource, - SearchResourceWithRawResponse, - AsyncSearchResourceWithRawResponse, - SearchResourceWithStreamingResponse, - AsyncSearchResourceWithStreamingResponse, +from .assets import ( + AssetsResource, + AsyncAssetsResource, + AssetsResourceWithRawResponse, + AsyncAssetsResourceWithRawResponse, + AssetsResourceWithStreamingResponse, + AsyncAssetsResourceWithStreamingResponse, ) from .accounts import ( AccountsResource, @@ -40,12 +40,6 @@ "AsyncAccountsResourceWithRawResponse", "AccountsResourceWithStreamingResponse", "AsyncAccountsResourceWithStreamingResponse", - "SearchResource", - "AsyncSearchResource", - "SearchResourceWithRawResponse", - "AsyncSearchResourceWithRawResponse", - "SearchResourceWithStreamingResponse", - "AsyncSearchResourceWithStreamingResponse", "ChatsResource", "AsyncChatsResource", "ChatsResourceWithRawResponse", @@ -58,4 +52,10 @@ "AsyncMessagesResourceWithRawResponse", "MessagesResourceWithStreamingResponse", "AsyncMessagesResourceWithStreamingResponse", + "AssetsResource", + "AsyncAssetsResource", + "AssetsResourceWithRawResponse", + "AsyncAssetsResourceWithRawResponse", + "AssetsResourceWithStreamingResponse", + "AsyncAssetsResourceWithStreamingResponse", ] diff --git a/src/beeper_desktop_api/resources/accounts/__init__.py b/src/beeper_desktop_api/resources/accounts/__init__.py new file mode 100644 index 0000000..13ef88f --- /dev/null +++ b/src/beeper_desktop_api/resources/accounts/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .accounts import ( + AccountsResource, + AsyncAccountsResource, + AccountsResourceWithRawResponse, + AsyncAccountsResourceWithRawResponse, + AccountsResourceWithStreamingResponse, + AsyncAccountsResourceWithStreamingResponse, +) +from .contacts import ( + ContactsResource, + AsyncContactsResource, + ContactsResourceWithRawResponse, + AsyncContactsResourceWithRawResponse, + ContactsResourceWithStreamingResponse, + AsyncContactsResourceWithStreamingResponse, +) + +__all__ = [ + "ContactsResource", + "AsyncContactsResource", + "ContactsResourceWithRawResponse", + "AsyncContactsResourceWithRawResponse", + "ContactsResourceWithStreamingResponse", + "AsyncContactsResourceWithStreamingResponse", + "AccountsResource", + "AsyncAccountsResource", + "AccountsResourceWithRawResponse", + "AsyncAccountsResourceWithRawResponse", + "AccountsResourceWithStreamingResponse", + "AsyncAccountsResourceWithStreamingResponse", +] diff --git a/src/beeper_desktop_api/resources/accounts.py b/src/beeper_desktop_api/resources/accounts/accounts.py similarity index 74% rename from src/beeper_desktop_api/resources/accounts.py rename to src/beeper_desktop_api/resources/accounts/accounts.py index 1210fce..a86fa76 100644 --- a/src/beeper_desktop_api/resources/accounts.py +++ b/src/beeper_desktop_api/resources/accounts/accounts.py @@ -4,17 +4,25 @@ import httpx -from .._types import Body, Query, Headers, NotGiven, not_given -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( +from ..._types import Body, Query, Headers, NotGiven, not_given +from .contacts import ( + ContactsResource, + AsyncContactsResource, + ContactsResourceWithRawResponse, + AsyncContactsResourceWithRawResponse, + ContactsResourceWithStreamingResponse, + AsyncContactsResourceWithStreamingResponse, +) +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( to_raw_response_wrapper, to_streamed_response_wrapper, async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from .._base_client import make_request_options -from ..types.account_list_response import AccountListResponse +from ..._base_client import make_request_options +from ...types.account_list_response import AccountListResponse __all__ = ["AccountsResource", "AsyncAccountsResource"] @@ -22,6 +30,11 @@ class AccountsResource(SyncAPIResource): """Manage connected chat accounts""" + @cached_property + def contacts(self) -> ContactsResource: + """Manage contacts on a specific account""" + return ContactsResource(self._client) + @cached_property def with_raw_response(self) -> AccountsResourceWithRawResponse: """ @@ -67,6 +80,11 @@ def list( class AsyncAccountsResource(AsyncAPIResource): """Manage connected chat accounts""" + @cached_property + def contacts(self) -> AsyncContactsResource: + """Manage contacts on a specific account""" + return AsyncContactsResource(self._client) + @cached_property def with_raw_response(self) -> AsyncAccountsResourceWithRawResponse: """ @@ -117,6 +135,11 @@ def __init__(self, accounts: AccountsResource) -> None: accounts.list, ) + @cached_property + def contacts(self) -> ContactsResourceWithRawResponse: + """Manage contacts on a specific account""" + return ContactsResourceWithRawResponse(self._accounts.contacts) + class AsyncAccountsResourceWithRawResponse: def __init__(self, accounts: AsyncAccountsResource) -> None: @@ -126,6 +149,11 @@ def __init__(self, accounts: AsyncAccountsResource) -> None: accounts.list, ) + @cached_property + def contacts(self) -> AsyncContactsResourceWithRawResponse: + """Manage contacts on a specific account""" + return AsyncContactsResourceWithRawResponse(self._accounts.contacts) + class AccountsResourceWithStreamingResponse: def __init__(self, accounts: AccountsResource) -> None: @@ -135,6 +163,11 @@ def __init__(self, accounts: AccountsResource) -> None: accounts.list, ) + @cached_property + def contacts(self) -> ContactsResourceWithStreamingResponse: + """Manage contacts on a specific account""" + return ContactsResourceWithStreamingResponse(self._accounts.contacts) + class AsyncAccountsResourceWithStreamingResponse: def __init__(self, accounts: AsyncAccountsResource) -> None: @@ -143,3 +176,8 @@ def __init__(self, accounts: AsyncAccountsResource) -> None: self.list = async_to_streamed_response_wrapper( accounts.list, ) + + @cached_property + def contacts(self) -> AsyncContactsResourceWithStreamingResponse: + """Manage contacts on a specific account""" + return AsyncContactsResourceWithStreamingResponse(self._accounts.contacts) diff --git a/src/beeper_desktop_api/resources/accounts/contacts.py b/src/beeper_desktop_api/resources/accounts/contacts.py new file mode 100644 index 0000000..b2a6491 --- /dev/null +++ b/src/beeper_desktop_api/resources/accounts/contacts.py @@ -0,0 +1,189 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.accounts import contact_search_params +from ...types.accounts.contact_search_response import ContactSearchResponse + +__all__ = ["ContactsResource", "AsyncContactsResource"] + + +class ContactsResource(SyncAPIResource): + """Manage contacts on a specific account""" + + @cached_property + def with_raw_response(self) -> ContactsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return ContactsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ContactsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return ContactsResourceWithStreamingResponse(self) + + def search( + self, + account_id: str, + *, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ContactSearchResponse: + """ + Search contacts across on a specific account using the network's search API. + Only use for creating new chats. + + Args: + account_id: Account ID this resource belongs to. + + query: Text to search users by. Network-specific behavior. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not 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", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"query": query}, contact_search_params.ContactSearchParams), + ), + cast_to=ContactSearchResponse, + ) + + +class AsyncContactsResource(AsyncAPIResource): + """Manage contacts on a specific account""" + + @cached_property + def with_raw_response(self) -> AsyncContactsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncContactsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncContactsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return AsyncContactsResourceWithStreamingResponse(self) + + async def search( + self, + account_id: str, + *, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ContactSearchResponse: + """ + Search contacts across on a specific account using the network's search API. + Only use for creating new chats. + + Args: + account_id: Account ID this resource belongs to. + + query: Text to search users by. Network-specific behavior. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not 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", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"query": query}, contact_search_params.ContactSearchParams), + ), + cast_to=ContactSearchResponse, + ) + + +class ContactsResourceWithRawResponse: + def __init__(self, contacts: ContactsResource) -> None: + self._contacts = contacts + + self.search = to_raw_response_wrapper( + contacts.search, + ) + + +class AsyncContactsResourceWithRawResponse: + def __init__(self, contacts: AsyncContactsResource) -> None: + self._contacts = contacts + + self.search = async_to_raw_response_wrapper( + contacts.search, + ) + + +class ContactsResourceWithStreamingResponse: + def __init__(self, contacts: ContactsResource) -> None: + self._contacts = contacts + + self.search = to_streamed_response_wrapper( + contacts.search, + ) + + +class AsyncContactsResourceWithStreamingResponse: + def __init__(self, contacts: AsyncContactsResource) -> None: + self._contacts = contacts + + self.search = async_to_streamed_response_wrapper( + contacts.search, + ) diff --git a/src/beeper_desktop_api/resources/assets.py b/src/beeper_desktop_api/resources/assets.py new file mode 100644 index 0000000..0d3a790 --- /dev/null +++ b/src/beeper_desktop_api/resources/assets.py @@ -0,0 +1,173 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import asset_download_params +from .._types import Body, Query, Headers, NotGiven, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.asset_download_response import AssetDownloadResponse + +__all__ = ["AssetsResource", "AsyncAssetsResource"] + + +class AssetsResource(SyncAPIResource): + """Manage assets in Beeper Desktop, like message attachments""" + + @cached_property + def with_raw_response(self) -> AssetsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AssetsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AssetsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return AssetsResourceWithStreamingResponse(self) + + def download( + self, + *, + url: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AssetDownloadResponse: + """ + Download a Matrix asset using its mxc:// or localmxc:// URL to the device + running Beeper Desktop and return the local file URL. + + Args: + url: Matrix content URL (mxc:// or localmxc://) for the asset to download. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/assets/download", + body=maybe_transform({"url": url}, asset_download_params.AssetDownloadParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AssetDownloadResponse, + ) + + +class AsyncAssetsResource(AsyncAPIResource): + """Manage assets in Beeper Desktop, like message attachments""" + + @cached_property + def with_raw_response(self) -> AsyncAssetsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncAssetsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAssetsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return AsyncAssetsResourceWithStreamingResponse(self) + + async def download( + self, + *, + url: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AssetDownloadResponse: + """ + Download a Matrix asset using its mxc:// or localmxc:// URL to the device + running Beeper Desktop and return the local file URL. + + Args: + url: Matrix content URL (mxc:// or localmxc://) for the asset to download. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/assets/download", + body=await async_maybe_transform({"url": url}, asset_download_params.AssetDownloadParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AssetDownloadResponse, + ) + + +class AssetsResourceWithRawResponse: + def __init__(self, assets: AssetsResource) -> None: + self._assets = assets + + self.download = to_raw_response_wrapper( + assets.download, + ) + + +class AsyncAssetsResourceWithRawResponse: + def __init__(self, assets: AsyncAssetsResource) -> None: + self._assets = assets + + self.download = async_to_raw_response_wrapper( + assets.download, + ) + + +class AssetsResourceWithStreamingResponse: + def __init__(self, assets: AssetsResource) -> None: + self._assets = assets + + self.download = to_streamed_response_wrapper( + assets.download, + ) + + +class AsyncAssetsResourceWithStreamingResponse: + def __init__(self, assets: AsyncAssetsResource) -> None: + self._assets = assets + + self.download = async_to_streamed_response_wrapper( + assets.download, + ) diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index 7752636..751cd72 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -2,13 +2,14 @@ from __future__ import annotations -from typing import Optional +from typing import Union, Optional +from datetime import datetime from typing_extensions import Literal import httpx -from ...types import chat_list_params, chat_create_params, chat_archive_params, chat_retrieve_params -from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given +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 ..._compat import cached_property from .reminders import ( @@ -26,22 +27,21 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from ...pagination import SyncCursorList, AsyncCursorList +from ...pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorNoLimit, AsyncCursorNoLimit from ...types.chat import Chat from ..._base_client import AsyncPaginator, make_request_options from ...types.chat_list_response import ChatListResponse from ...types.chat_create_response import ChatCreateResponse -from ...types.shared.base_response import BaseResponse __all__ = ["ChatsResource", "AsyncChatsResource"] class ChatsResource(SyncAPIResource): - """Chats operations""" + """Manage chats""" @cached_property def reminders(self) -> RemindersResource: - """Reminders operations""" + """Manage reminders for chats""" return RemindersResource(self._client) @cached_property @@ -139,7 +139,7 @@ def retrieve( chat_id: Unique identifier of the chat. max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0–500. - Defaults to 20. + Defaults to all (-1). extra_headers: Send extra headers @@ -177,7 +177,7 @@ def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursorList[ChatListResponse]: + ) -> SyncCursorNoLimit[ChatListResponse]: """List all chats sorted by last activity (most recent first). Combines all @@ -201,7 +201,7 @@ def list( """ return self._get_api_list( "/v1/chats", - page=SyncCursorList[ChatListResponse], + page=SyncCursorNoLimit[ChatListResponse], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -230,7 +230,7 @@ def archive( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BaseResponse: + ) -> None: """Archive or unarchive a chat. Set archived=true to move to archive, @@ -251,22 +251,120 @@ def archive( """ if not chat_id: 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", 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 ), - cast_to=BaseResponse, + cast_to=NoneType, + ) + + def search( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, + include_muted: Optional[bool] | Omit = omit, + last_activity_after: Union[str, datetime] | Omit = omit, + last_activity_before: Union[str, datetime] | Omit = omit, + limit: int | Omit = omit, + query: str | Omit = omit, + scope: Literal["titles", "participants"] | Omit = omit, + type: Literal["single", "group", "any"] | Omit = omit, + unread_only: Optional[bool] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SyncCursorSearch[Chat]: + """ + Search chats by title/network or participants using Beeper Desktop's renderer + algorithm. + + Args: + account_ids: Provide an array of account IDs to filter chats from specific messaging accounts + only + + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. + + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + + inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), + "low-priority", or "archive". If not specified, shows all chats. + + include_muted: Include chats marked as Muted by the user, which are usually less important. + Default: true. Set to false if the user wants a more refined search. + + last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after + this time + + last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before + this time + + limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 + + query: Literal token search (non-semantic). Use single words users type (e.g., + "dinner"). When multiple words provided, ALL must match. Case-insensitive. + + scope: Search scope: 'titles' matches title + network; 'participants' matches + participant names. + + type: Specify the type of chats to retrieve: use "single" for direct messages, "group" + for group chats, or "any" to get all types + + unread_only: Set to true to only retrieve chats that have unread messages + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v1/chats/search", + page=SyncCursorSearch[Chat], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "cursor": cursor, + "direction": direction, + "inbox": inbox, + "include_muted": include_muted, + "last_activity_after": last_activity_after, + "last_activity_before": last_activity_before, + "limit": limit, + "query": query, + "scope": scope, + "type": type, + "unread_only": unread_only, + }, + chat_search_params.ChatSearchParams, + ), + ), + model=Chat, ) class AsyncChatsResource(AsyncAPIResource): - """Chats operations""" + """Manage chats""" @cached_property def reminders(self) -> AsyncRemindersResource: - """Reminders operations""" + """Manage reminders for chats""" return AsyncRemindersResource(self._client) @cached_property @@ -364,7 +462,7 @@ async def retrieve( chat_id: Unique identifier of the chat. max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0–500. - Defaults to 20. + Defaults to all (-1). extra_headers: Send extra headers @@ -402,7 +500,7 @@ def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[ChatListResponse, AsyncCursorList[ChatListResponse]]: + ) -> AsyncPaginator[ChatListResponse, AsyncCursorNoLimit[ChatListResponse]]: """List all chats sorted by last activity (most recent first). Combines all @@ -426,7 +524,7 @@ def list( """ return self._get_api_list( "/v1/chats", - page=AsyncCursorList[ChatListResponse], + page=AsyncCursorNoLimit[ChatListResponse], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -455,7 +553,7 @@ async def archive( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BaseResponse: + ) -> None: """Archive or unarchive a chat. Set archived=true to move to archive, @@ -476,13 +574,111 @@ async def archive( """ if not chat_id: 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", 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 ), - cast_to=BaseResponse, + cast_to=NoneType, + ) + + def search( + self, + *, + account_ids: SequenceNotStr[str] | Omit = omit, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, + include_muted: Optional[bool] | Omit = omit, + last_activity_after: Union[str, datetime] | Omit = omit, + last_activity_before: Union[str, datetime] | Omit = omit, + limit: int | Omit = omit, + query: str | Omit = omit, + scope: Literal["titles", "participants"] | Omit = omit, + type: Literal["single", "group", "any"] | Omit = omit, + unread_only: Optional[bool] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncPaginator[Chat, AsyncCursorSearch[Chat]]: + """ + Search chats by title/network or participants using Beeper Desktop's renderer + algorithm. + + Args: + account_ids: Provide an array of account IDs to filter chats from specific messaging accounts + only + + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. + + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + + inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), + "low-priority", or "archive". If not specified, shows all chats. + + include_muted: Include chats marked as Muted by the user, which are usually less important. + Default: true. Set to false if the user wants a more refined search. + + last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after + this time + + last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before + this time + + limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 + + query: Literal token search (non-semantic). Use single words users type (e.g., + "dinner"). When multiple words provided, ALL must match. Case-insensitive. + + scope: Search scope: 'titles' matches title + network; 'participants' matches + participant names. + + type: Specify the type of chats to retrieve: use "single" for direct messages, "group" + for group chats, or "any" to get all types + + unread_only: Set to true to only retrieve chats that have unread messages + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/v1/chats/search", + page=AsyncCursorSearch[Chat], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "account_ids": account_ids, + "cursor": cursor, + "direction": direction, + "inbox": inbox, + "include_muted": include_muted, + "last_activity_after": last_activity_after, + "last_activity_before": last_activity_before, + "limit": limit, + "query": query, + "scope": scope, + "type": type, + "unread_only": unread_only, + }, + chat_search_params.ChatSearchParams, + ), + ), + model=Chat, ) @@ -502,10 +698,13 @@ def __init__(self, chats: ChatsResource) -> None: self.archive = to_raw_response_wrapper( chats.archive, ) + self.search = to_raw_response_wrapper( + chats.search, + ) @cached_property def reminders(self) -> RemindersResourceWithRawResponse: - """Reminders operations""" + """Manage reminders for chats""" return RemindersResourceWithRawResponse(self._chats.reminders) @@ -525,10 +724,13 @@ def __init__(self, chats: AsyncChatsResource) -> None: self.archive = async_to_raw_response_wrapper( chats.archive, ) + self.search = async_to_raw_response_wrapper( + chats.search, + ) @cached_property def reminders(self) -> AsyncRemindersResourceWithRawResponse: - """Reminders operations""" + """Manage reminders for chats""" return AsyncRemindersResourceWithRawResponse(self._chats.reminders) @@ -548,10 +750,13 @@ def __init__(self, chats: ChatsResource) -> None: self.archive = to_streamed_response_wrapper( chats.archive, ) + self.search = to_streamed_response_wrapper( + chats.search, + ) @cached_property def reminders(self) -> RemindersResourceWithStreamingResponse: - """Reminders operations""" + """Manage reminders for chats""" return RemindersResourceWithStreamingResponse(self._chats.reminders) @@ -571,8 +776,11 @@ def __init__(self, chats: AsyncChatsResource) -> None: self.archive = async_to_streamed_response_wrapper( chats.archive, ) + self.search = async_to_streamed_response_wrapper( + chats.search, + ) @cached_property def reminders(self) -> AsyncRemindersResourceWithStreamingResponse: - """Reminders operations""" + """Manage reminders for chats""" return AsyncRemindersResourceWithStreamingResponse(self._chats.reminders) diff --git a/src/beeper_desktop_api/resources/chats/reminders.py b/src/beeper_desktop_api/resources/chats/reminders.py index bf628ae..2096903 100644 --- a/src/beeper_desktop_api/resources/chats/reminders.py +++ b/src/beeper_desktop_api/resources/chats/reminders.py @@ -4,7 +4,7 @@ import httpx -from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._types import Body, Query, Headers, NoneType, NotGiven, not_given from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource @@ -16,13 +16,12 @@ ) from ...types.chats import reminder_create_params from ..._base_client import make_request_options -from ...types.shared.base_response import BaseResponse __all__ = ["RemindersResource", "AsyncRemindersResource"] class RemindersResource(SyncAPIResource): - """Reminders operations""" + """Manage reminders for chats""" @cached_property def with_raw_response(self) -> RemindersResourceWithRawResponse: @@ -54,7 +53,7 @@ def create( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BaseResponse: + ) -> None: """ Set a reminder for a chat at a specific time @@ -73,13 +72,14 @@ def create( """ if not chat_id: 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", 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 ), - cast_to=BaseResponse, + cast_to=NoneType, ) def delete( @@ -92,7 +92,7 @@ def delete( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BaseResponse: + ) -> None: """ Clear an existing reminder from a chat @@ -109,17 +109,18 @@ def delete( """ if not chat_id: 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", options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=BaseResponse, + cast_to=NoneType, ) class AsyncRemindersResource(AsyncAPIResource): - """Reminders operations""" + """Manage reminders for chats""" @cached_property def with_raw_response(self) -> AsyncRemindersResourceWithRawResponse: @@ -151,7 +152,7 @@ async def create( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BaseResponse: + ) -> None: """ Set a reminder for a chat at a specific time @@ -170,13 +171,14 @@ async def create( """ if not chat_id: 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", 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 ), - cast_to=BaseResponse, + cast_to=NoneType, ) async def delete( @@ -189,7 +191,7 @@ async def delete( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BaseResponse: + ) -> None: """ Clear an existing reminder from a chat @@ -206,12 +208,13 @@ async def delete( """ if not chat_id: 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", options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=BaseResponse, + cast_to=NoneType, ) diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index ad07df3..4f384f6 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 SyncCursorList, AsyncCursorList, SyncCursorSearch, AsyncCursorSearch +from ..pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorSortKey, AsyncCursorSortKey from .._base_client import AsyncPaginator, make_request_options from ..types.shared.message import Message from ..types.message_send_response import MessageSendResponse @@ -28,7 +28,7 @@ class MessagesResource(SyncAPIResource): - """Messages operations""" + """Manage messages in chats""" @cached_property def with_raw_response(self) -> MessagesResourceWithRawResponse: @@ -61,7 +61,7 @@ def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursorList[Message]: + ) -> SyncCursorSortKey[Message]: """List all messages in a chat with cursor-based pagination. Sorted by timestamp. @@ -86,7 +86,7 @@ def list( 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", - page=SyncCursorList[Message], + page=SyncCursorSortKey[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -175,7 +175,7 @@ def search( timeout: Override the client-level default timeout for this request, in seconds """ return self._get_api_list( - "/v1/search/messages", + "/v1/messages/search", page=SyncCursorSearch[Message], options=make_request_options( extra_headers=extra_headers, @@ -256,7 +256,7 @@ def send( class AsyncMessagesResource(AsyncAPIResource): - """Messages operations""" + """Manage messages in chats""" @cached_property def with_raw_response(self) -> AsyncMessagesResourceWithRawResponse: @@ -289,7 +289,7 @@ def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[Message, AsyncCursorList[Message]]: + ) -> AsyncPaginator[Message, AsyncCursorSortKey[Message]]: """List all messages in a chat with cursor-based pagination. Sorted by timestamp. @@ -314,7 +314,7 @@ def list( 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", - page=AsyncCursorList[Message], + page=AsyncCursorSortKey[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -403,7 +403,7 @@ def search( timeout: Override the client-level default timeout for this request, in seconds """ return self._get_api_list( - "/v1/search/messages", + "/v1/messages/search", page=AsyncCursorSearch[Message], options=make_request_options( extra_headers=extra_headers, diff --git a/src/beeper_desktop_api/resources/search.py b/src/beeper_desktop_api/resources/search.py deleted file mode 100644 index 0653cfa..0000000 --- a/src/beeper_desktop_api/resources/search.py +++ /dev/null @@ -1,397 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing import Union, Optional -from datetime import datetime -from typing_extensions import Literal - -import httpx - -from ..types import search_chats_params, search_contacts_params -from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given -from .._utils import maybe_transform, async_maybe_transform -from .._compat import cached_property -from .._resource import SyncAPIResource, AsyncAPIResource -from .._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from ..pagination import SyncCursorSearch, AsyncCursorSearch -from ..types.chat import Chat -from .._base_client import AsyncPaginator, make_request_options -from ..types.search_contacts_response import SearchContactsResponse - -__all__ = ["SearchResource", "AsyncSearchResource"] - - -class SearchResource(SyncAPIResource): - @cached_property - def with_raw_response(self) -> SearchResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers - """ - return SearchResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> SearchResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response - """ - return SearchResourceWithStreamingResponse(self) - - def chats( - self, - *, - account_ids: SequenceNotStr[str] | Omit = omit, - cursor: str | Omit = omit, - direction: Literal["after", "before"] | Omit = omit, - inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, - include_muted: Optional[bool] | Omit = omit, - last_activity_after: Union[str, datetime] | Omit = omit, - last_activity_before: Union[str, datetime] | Omit = omit, - limit: int | Omit = omit, - query: str | Omit = omit, - scope: Literal["titles", "participants"] | Omit = omit, - type: Literal["single", "group", "any"] | Omit = omit, - unread_only: Optional[bool] | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursorSearch[Chat]: - """ - Search chats by title/network or participants using Beeper Desktop's renderer - algorithm. - - Args: - account_ids: Provide an array of account IDs to filter chats from specific messaging accounts - only - - cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. - - direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' - fetches newer results. Defaults to 'before' when only 'cursor' is provided. - - inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), - "low-priority", or "archive". If not specified, shows all chats. - - include_muted: Include chats marked as Muted by the user, which are usually less important. - Default: true. Set to false if the user wants a more refined search. - - last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after - this time - - last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before - this time - - limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 - - query: Literal token search (non-semantic). Use single words users type (e.g., - "dinner"). When multiple words provided, ALL must match. Case-insensitive. - - scope: Search scope: 'titles' matches title + network; 'participants' matches - participant names. - - type: Specify the type of chats to retrieve: use "single" for direct messages, "group" - for group chats, or "any" to get all types - - unread_only: Set to true to only retrieve chats that have unread messages - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get_api_list( - "/v1/search/chats", - page=SyncCursorSearch[Chat], - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform( - { - "account_ids": account_ids, - "cursor": cursor, - "direction": direction, - "inbox": inbox, - "include_muted": include_muted, - "last_activity_after": last_activity_after, - "last_activity_before": last_activity_before, - "limit": limit, - "query": query, - "scope": scope, - "type": type, - "unread_only": unread_only, - }, - search_chats_params.SearchChatsParams, - ), - ), - model=Chat, - ) - - def contacts( - self, - account_id: str, - *, - query: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SearchContactsResponse: - """ - Search contacts across on a specific account using the network's search API. - Only use for creating new chats. - - Args: - account_id: Account ID this resource belongs to. - - query: Text to search users by. Network-specific behavior. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not account_id: - raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") - return self._get( - f"/v1/search/contacts/{account_id}", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform({"query": query}, search_contacts_params.SearchContactsParams), - ), - cast_to=SearchContactsResponse, - ) - - -class AsyncSearchResource(AsyncAPIResource): - @cached_property - def with_raw_response(self) -> AsyncSearchResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers - """ - return AsyncSearchResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncSearchResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response - """ - return AsyncSearchResourceWithStreamingResponse(self) - - def chats( - self, - *, - account_ids: SequenceNotStr[str] | Omit = omit, - cursor: str | Omit = omit, - direction: Literal["after", "before"] | Omit = omit, - inbox: Literal["primary", "low-priority", "archive"] | Omit = omit, - include_muted: Optional[bool] | Omit = omit, - last_activity_after: Union[str, datetime] | Omit = omit, - last_activity_before: Union[str, datetime] | Omit = omit, - limit: int | Omit = omit, - query: str | Omit = omit, - scope: Literal["titles", "participants"] | Omit = omit, - type: Literal["single", "group", "any"] | Omit = omit, - unread_only: Optional[bool] | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[Chat, AsyncCursorSearch[Chat]]: - """ - Search chats by title/network or participants using Beeper Desktop's renderer - algorithm. - - Args: - account_ids: Provide an array of account IDs to filter chats from specific messaging accounts - only - - cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. - - direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' - fetches newer results. Defaults to 'before' when only 'cursor' is provided. - - inbox: Filter by inbox type: "primary" (non-archived, non-low-priority), - "low-priority", or "archive". If not specified, shows all chats. - - include_muted: Include chats marked as Muted by the user, which are usually less important. - Default: true. Set to false if the user wants a more refined search. - - last_activity_after: Provide an ISO datetime string to only retrieve chats with last activity after - this time - - last_activity_before: Provide an ISO datetime string to only retrieve chats with last activity before - this time - - limit: Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50 - - query: Literal token search (non-semantic). Use single words users type (e.g., - "dinner"). When multiple words provided, ALL must match. Case-insensitive. - - scope: Search scope: 'titles' matches title + network; 'participants' matches - participant names. - - type: Specify the type of chats to retrieve: use "single" for direct messages, "group" - for group chats, or "any" to get all types - - unread_only: Set to true to only retrieve chats that have unread messages - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get_api_list( - "/v1/search/chats", - page=AsyncCursorSearch[Chat], - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform( - { - "account_ids": account_ids, - "cursor": cursor, - "direction": direction, - "inbox": inbox, - "include_muted": include_muted, - "last_activity_after": last_activity_after, - "last_activity_before": last_activity_before, - "limit": limit, - "query": query, - "scope": scope, - "type": type, - "unread_only": unread_only, - }, - search_chats_params.SearchChatsParams, - ), - ), - model=Chat, - ) - - async def contacts( - self, - account_id: str, - *, - query: str, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SearchContactsResponse: - """ - Search contacts across on a specific account using the network's search API. - Only use for creating new chats. - - Args: - account_id: Account ID this resource belongs to. - - query: Text to search users by. Network-specific behavior. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - if not account_id: - raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") - return await self._get( - f"/v1/search/contacts/{account_id}", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=await async_maybe_transform({"query": query}, search_contacts_params.SearchContactsParams), - ), - cast_to=SearchContactsResponse, - ) - - -class SearchResourceWithRawResponse: - def __init__(self, search: SearchResource) -> None: - self._search = search - - self.chats = to_raw_response_wrapper( - search.chats, - ) - self.contacts = to_raw_response_wrapper( - search.contacts, - ) - - -class AsyncSearchResourceWithRawResponse: - def __init__(self, search: AsyncSearchResource) -> None: - self._search = search - - self.chats = async_to_raw_response_wrapper( - search.chats, - ) - self.contacts = async_to_raw_response_wrapper( - search.contacts, - ) - - -class SearchResourceWithStreamingResponse: - def __init__(self, search: SearchResource) -> None: - self._search = search - - self.chats = to_streamed_response_wrapper( - search.chats, - ) - self.contacts = to_streamed_response_wrapper( - search.contacts, - ) - - -class AsyncSearchResourceWithStreamingResponse: - def __init__(self, search: AsyncSearchResource) -> None: - self._search = search - - self.chats = async_to_streamed_response_wrapper( - search.chats, - ) - self.contacts = async_to_streamed_response_wrapper( - search.contacts, - ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index 6aeb07d..1d77bdb 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -3,32 +3,23 @@ from __future__ import annotations from .chat import Chat as Chat -from .shared import ( - User as User, - Error as Error, - Message as Message, - Reaction as Reaction, - Attachment as Attachment, - BaseResponse as BaseResponse, -) +from .shared import User as User, Error as Error, Message as Message, Reaction as Reaction, Attachment as Attachment from .account import Account as Account from .focus_response import FocusResponse as FocusResponse from .search_response import SearchResponse as SearchResponse from .chat_list_params import ChatListParams as ChatListParams from .chat_create_params import ChatCreateParams as ChatCreateParams from .chat_list_response import ChatListResponse as ChatListResponse +from .chat_search_params import ChatSearchParams as ChatSearchParams from .chat_archive_params import ChatArchiveParams as ChatArchiveParams from .client_focus_params import ClientFocusParams as ClientFocusParams from .message_list_params import MessageListParams as MessageListParams from .message_send_params import MessageSendParams as MessageSendParams -from .search_chats_params import SearchChatsParams as SearchChatsParams from .chat_create_response import ChatCreateResponse as ChatCreateResponse from .chat_retrieve_params import ChatRetrieveParams as ChatRetrieveParams from .client_search_params import ClientSearchParams as ClientSearchParams from .account_list_response import AccountListResponse as AccountListResponse +from .asset_download_params import AssetDownloadParams as AssetDownloadParams from .message_search_params import MessageSearchParams as MessageSearchParams from .message_send_response import MessageSendResponse as MessageSendResponse -from .search_contacts_params import SearchContactsParams as SearchContactsParams -from .download_asset_response import DownloadAssetResponse as DownloadAssetResponse -from .search_contacts_response import SearchContactsResponse as SearchContactsResponse -from .client_download_asset_params import ClientDownloadAssetParams as ClientDownloadAssetParams +from .asset_download_response import AssetDownloadResponse as AssetDownloadResponse diff --git a/src/beeper_desktop_api/types/accounts/__init__.py b/src/beeper_desktop_api/types/accounts/__init__.py new file mode 100644 index 0000000..90dd1b9 --- /dev/null +++ b/src/beeper_desktop_api/types/accounts/__init__.py @@ -0,0 +1,6 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .contact_search_params import ContactSearchParams as ContactSearchParams +from .contact_search_response import ContactSearchResponse as ContactSearchResponse diff --git a/src/beeper_desktop_api/types/search_contacts_params.py b/src/beeper_desktop_api/types/accounts/contact_search_params.py similarity index 75% rename from src/beeper_desktop_api/types/search_contacts_params.py rename to src/beeper_desktop_api/types/accounts/contact_search_params.py index 3e0352b..f9063e0 100644 --- a/src/beeper_desktop_api/types/search_contacts_params.py +++ b/src/beeper_desktop_api/types/accounts/contact_search_params.py @@ -4,9 +4,9 @@ from typing_extensions import Required, TypedDict -__all__ = ["SearchContactsParams"] +__all__ = ["ContactSearchParams"] -class SearchContactsParams(TypedDict, total=False): +class ContactSearchParams(TypedDict, total=False): query: Required[str] """Text to search users by. Network-specific behavior.""" diff --git a/src/beeper_desktop_api/types/accounts/contact_search_response.py b/src/beeper_desktop_api/types/accounts/contact_search_response.py new file mode 100644 index 0000000..e86aeed --- /dev/null +++ b/src/beeper_desktop_api/types/accounts/contact_search_response.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from ..._models import BaseModel +from ..shared.user import User + +__all__ = ["ContactSearchResponse"] + + +class ContactSearchResponse(BaseModel): + items: List[User] diff --git a/src/beeper_desktop_api/types/client_download_asset_params.py b/src/beeper_desktop_api/types/asset_download_params.py similarity index 74% rename from src/beeper_desktop_api/types/client_download_asset_params.py rename to src/beeper_desktop_api/types/asset_download_params.py index fe824e0..1b3d584 100644 --- a/src/beeper_desktop_api/types/client_download_asset_params.py +++ b/src/beeper_desktop_api/types/asset_download_params.py @@ -4,9 +4,9 @@ from typing_extensions import Required, TypedDict -__all__ = ["ClientDownloadAssetParams"] +__all__ = ["AssetDownloadParams"] -class ClientDownloadAssetParams(TypedDict, total=False): +class AssetDownloadParams(TypedDict, total=False): url: Required[str] """Matrix content URL (mxc:// or localmxc://) for the asset to download.""" diff --git a/src/beeper_desktop_api/types/download_asset_response.py b/src/beeper_desktop_api/types/asset_download_response.py similarity index 83% rename from src/beeper_desktop_api/types/download_asset_response.py rename to src/beeper_desktop_api/types/asset_download_response.py index 47bc22e..3cf7729 100644 --- a/src/beeper_desktop_api/types/download_asset_response.py +++ b/src/beeper_desktop_api/types/asset_download_response.py @@ -6,10 +6,10 @@ from .._models import BaseModel -__all__ = ["DownloadAssetResponse"] +__all__ = ["AssetDownloadResponse"] -class DownloadAssetResponse(BaseModel): +class AssetDownloadResponse(BaseModel): error: Optional[str] = None """Error message if the download failed.""" diff --git a/src/beeper_desktop_api/types/chat.py b/src/beeper_desktop_api/types/chat.py index d580426..b42cf4b 100644 --- a/src/beeper_desktop_api/types/chat.py +++ b/src/beeper_desktop_api/types/chat.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List, Union, Optional +from typing import List, Optional from datetime import datetime from typing_extensions import Literal @@ -25,10 +25,10 @@ class Participants(BaseModel): class Chat(BaseModel): id: str - """Unique identifier of the chat (room/thread ID, same as id) across Beeper.""" + """Unique identifier of the chat across Beeper.""" account_id: str = FieldInfo(alias="accountID") - """Beeper account ID this chat belongs to.""" + """Account ID this chat belongs to.""" network: str """Display-only human-readable network name (e.g., 'WhatsApp', 'Messenger').""" @@ -55,13 +55,10 @@ class Chat(BaseModel): """True if chat is pinned.""" last_activity: Optional[datetime] = FieldInfo(alias="lastActivity", default=None) - """Timestamp of last activity. + """Timestamp of last activity.""" - Chats with more recent activity are often more important. - """ - - last_read_message_sort_key: Union[int, str, None] = FieldInfo(alias="lastReadMessageSortKey", default=None) - """Last read message sortKey (hsOrder). Used to compute 'isUnread'.""" + last_read_message_sort_key: Optional[str] = FieldInfo(alias="lastReadMessageSortKey", default=None) + """Last read message sortKey.""" local_chat_id: Optional[str] = FieldInfo(alias="localChatID", default=None) """Local chat ID specific to this Beeper Desktop installation.""" diff --git a/src/beeper_desktop_api/types/chat_create_response.py b/src/beeper_desktop_api/types/chat_create_response.py index 64b6981..b092bdf 100644 --- a/src/beeper_desktop_api/types/chat_create_response.py +++ b/src/beeper_desktop_api/types/chat_create_response.py @@ -1,14 +1,12 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Optional - from pydantic import Field as FieldInfo -from .shared.base_response import BaseResponse +from .._models import BaseModel __all__ = ["ChatCreateResponse"] -class ChatCreateResponse(BaseResponse): - chat_id: Optional[str] = FieldInfo(alias="chatID", default=None) - """Newly created chat if available.""" +class ChatCreateResponse(BaseModel): + chat_id: str = FieldInfo(alias="chatID") + """Newly created chat ID.""" diff --git a/src/beeper_desktop_api/types/chat_retrieve_params.py b/src/beeper_desktop_api/types/chat_retrieve_params.py index ea22752..00d4e68 100644 --- a/src/beeper_desktop_api/types/chat_retrieve_params.py +++ b/src/beeper_desktop_api/types/chat_retrieve_params.py @@ -14,5 +14,5 @@ class ChatRetrieveParams(TypedDict, total=False): max_participant_count: Annotated[Optional[int], PropertyInfo(alias="maxParticipantCount")] """Maximum number of participants to return. - Use -1 for all; otherwise 0–500. Defaults to 20. + Use -1 for all; otherwise 0–500. Defaults to all (-1). """ diff --git a/src/beeper_desktop_api/types/search_chats_params.py b/src/beeper_desktop_api/types/chat_search_params.py similarity index 96% rename from src/beeper_desktop_api/types/search_chats_params.py rename to src/beeper_desktop_api/types/chat_search_params.py index d393720..661e9de 100644 --- a/src/beeper_desktop_api/types/search_chats_params.py +++ b/src/beeper_desktop_api/types/chat_search_params.py @@ -9,10 +9,10 @@ from .._types import SequenceNotStr from .._utils import PropertyInfo -__all__ = ["SearchChatsParams"] +__all__ = ["ChatSearchParams"] -class SearchChatsParams(TypedDict, total=False): +class ChatSearchParams(TypedDict, total=False): account_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="accountIDs")] """ Provide an array of account IDs to filter chats from specific messaging accounts diff --git a/src/beeper_desktop_api/types/message_send_response.py b/src/beeper_desktop_api/types/message_send_response.py index 05cc535..93ddfc3 100644 --- a/src/beeper_desktop_api/types/message_send_response.py +++ b/src/beeper_desktop_api/types/message_send_response.py @@ -2,12 +2,12 @@ from pydantic import Field as FieldInfo -from .shared.base_response import BaseResponse +from .._models import BaseModel __all__ = ["MessageSendResponse"] -class MessageSendResponse(BaseResponse): +class MessageSendResponse(BaseModel): chat_id: str = FieldInfo(alias="chatID") """Unique identifier of the chat.""" diff --git a/src/beeper_desktop_api/types/search_contacts_response.py b/src/beeper_desktop_api/types/search_contacts_response.py deleted file mode 100644 index 1bbf6db..0000000 --- a/src/beeper_desktop_api/types/search_contacts_response.py +++ /dev/null @@ -1,12 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List - -from .._models import BaseModel -from .shared.user import User - -__all__ = ["SearchContactsResponse"] - - -class SearchContactsResponse(BaseModel): - items: List[User] diff --git a/src/beeper_desktop_api/types/shared/__init__.py b/src/beeper_desktop_api/types/shared/__init__.py index 752eee2..cb669ed 100644 --- a/src/beeper_desktop_api/types/shared/__init__.py +++ b/src/beeper_desktop_api/types/shared/__init__.py @@ -5,4 +5,3 @@ from .message import Message as Message from .reaction import Reaction as Reaction from .attachment import Attachment as Attachment -from .base_response import BaseResponse as BaseResponse diff --git a/src/beeper_desktop_api/types/shared/base_response.py b/src/beeper_desktop_api/types/shared/base_response.py deleted file mode 100644 index 9b8876d..0000000 --- a/src/beeper_desktop_api/types/shared/base_response.py +++ /dev/null @@ -1,13 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Optional - -from ..._models import BaseModel - -__all__ = ["BaseResponse"] - - -class BaseResponse(BaseModel): - success: bool - - error: Optional[str] = None diff --git a/src/beeper_desktop_api/types/shared/error.py b/src/beeper_desktop_api/types/shared/error.py index e5b5a77..10b5f9c 100644 --- a/src/beeper_desktop_api/types/shared/error.py +++ b/src/beeper_desktop_api/types/shared/error.py @@ -1,10 +1,38 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from typing import Dict, List, Union, Optional +from typing_extensions import TypeAlias + from ..._models import BaseModel -__all__ = ["Error"] +__all__ = ["Error", "Details", "DetailsIssues", "DetailsIssuesIssue"] + + +class DetailsIssuesIssue(BaseModel): + code: str + """Validation issue code""" + + message: str + """Human-readable description of the validation issue""" + + path: List[Union[str, float]] + """Path pointing to the invalid field within the payload""" + + +class DetailsIssues(BaseModel): + issues: List[DetailsIssuesIssue] + """List of validation issues""" + + +Details: TypeAlias = Union[DetailsIssues, Dict[str, Optional[object]], Optional[object]] class Error(BaseModel): - error: Error - """Error details""" + code: str + """Machine-readable error code""" + + message: str + """Error message""" + + details: Optional[Details] = None + """Additional error details for debugging""" diff --git a/src/beeper_desktop_api/types/shared/message.py b/src/beeper_desktop_api/types/shared/message.py index ff2ca3a..f87febe 100644 --- a/src/beeper_desktop_api/types/shared/message.py +++ b/src/beeper_desktop_api/types/shared/message.py @@ -26,7 +26,7 @@ class Message(BaseModel): """Sender user ID.""" sort_key: str = FieldInfo(alias="sortKey") - """A unique key used to sort messages""" + """A unique, sortable key used to sort messages.""" timestamp: datetime """Message timestamp.""" diff --git a/tests/api_resources/accounts/__init__.py b/tests/api_resources/accounts/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/api_resources/accounts/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/accounts/test_contacts.py b/tests/api_resources/accounts/test_contacts.py new file mode 100644 index 0000000..4458b5d --- /dev/null +++ b/tests/api_resources/accounts/test_contacts.py @@ -0,0 +1,108 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types.accounts import ContactSearchResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestContacts: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_search(self, client: BeeperDesktop) -> None: + contact = client.accounts.contacts.search( + account_id="accountID", + query="x", + ) + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + @parametrize + def test_raw_response_search(self, client: BeeperDesktop) -> None: + response = client.accounts.contacts.with_raw_response.search( + account_id="accountID", + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + contact = response.parse() + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + @parametrize + def test_streaming_response_search(self, client: BeeperDesktop) -> None: + with client.accounts.contacts.with_streaming_response.search( + account_id="accountID", + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + contact = response.parse() + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_search(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `account_id` but received ''"): + client.accounts.contacts.with_raw_response.search( + account_id="", + query="x", + ) + + +class TestAsyncContacts: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: + contact = await async_client.accounts.contacts.search( + account_id="accountID", + query="x", + ) + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + @parametrize + async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.accounts.contacts.with_raw_response.search( + account_id="accountID", + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + contact = await response.parse() + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + @parametrize + async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.accounts.contacts.with_streaming_response.search( + account_id="accountID", + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + contact = await response.parse() + assert_matches_type(ContactSearchResponse, contact, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_search(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `account_id` but received ''"): + await async_client.accounts.contacts.with_raw_response.search( + account_id="", + query="x", + ) diff --git a/tests/api_resources/chats/test_reminders.py b/tests/api_resources/chats/test_reminders.py index fea1bcb..ea4febb 100644 --- a/tests/api_resources/chats/test_reminders.py +++ b/tests/api_resources/chats/test_reminders.py @@ -7,9 +7,7 @@ import pytest -from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types.shared import BaseResponse base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -23,7 +21,7 @@ def test_method_create(self, client: BeeperDesktop) -> None: chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reminder={"remind_at_ms": 0}, ) - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None @parametrize def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: @@ -34,7 +32,7 @@ def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: "dismiss_on_incoming_message": True, }, ) - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None @parametrize def test_raw_response_create(self, client: BeeperDesktop) -> None: @@ -46,7 +44,7 @@ def test_raw_response_create(self, client: BeeperDesktop) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None @parametrize def test_streaming_response_create(self, client: BeeperDesktop) -> None: @@ -58,7 +56,7 @@ def test_streaming_response_create(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None assert cast(Any, response.is_closed) is True @@ -75,7 +73,7 @@ def test_method_delete(self, client: BeeperDesktop) -> None: reminder = client.chats.reminders.delete( "!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None @parametrize def test_raw_response_delete(self, client: BeeperDesktop) -> None: @@ -86,7 +84,7 @@ def test_raw_response_delete(self, client: BeeperDesktop) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None @parametrize def test_streaming_response_delete(self, client: BeeperDesktop) -> None: @@ -97,7 +95,7 @@ def test_streaming_response_delete(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None assert cast(Any, response.is_closed) is True @@ -120,7 +118,7 @@ async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reminder={"remind_at_ms": 0}, ) - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None @parametrize async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -131,7 +129,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesk "dismiss_on_incoming_message": True, }, ) - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None @parametrize async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: @@ -143,7 +141,7 @@ async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = await response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None @parametrize async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: @@ -155,7 +153,7 @@ async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = await response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None assert cast(Any, response.is_closed) is True @@ -172,7 +170,7 @@ async def test_method_delete(self, async_client: AsyncBeeperDesktop) -> None: reminder = await async_client.chats.reminders.delete( "!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None @parametrize async def test_raw_response_delete(self, async_client: AsyncBeeperDesktop) -> None: @@ -183,7 +181,7 @@ async def test_raw_response_delete(self, async_client: AsyncBeeperDesktop) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = await response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None @parametrize async def test_streaming_response_delete(self, async_client: AsyncBeeperDesktop) -> None: @@ -194,7 +192,7 @@ async def test_streaming_response_delete(self, async_client: AsyncBeeperDesktop) assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = await response.parse() - assert_matches_type(BaseResponse, reminder, path=["response"]) + assert reminder is None assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py new file mode 100644 index 0000000..f878292 --- /dev/null +++ b/tests/api_resources/test_assets.py @@ -0,0 +1,86 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import AssetDownloadResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAssets: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_download(self, client: BeeperDesktop) -> None: + asset = client.assets.download( + url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", + ) + assert_matches_type(AssetDownloadResponse, asset, path=["response"]) + + @parametrize + def test_raw_response_download(self, client: BeeperDesktop) -> None: + response = client.assets.with_raw_response.download( + url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + asset = response.parse() + assert_matches_type(AssetDownloadResponse, asset, path=["response"]) + + @parametrize + def test_streaming_response_download(self, client: BeeperDesktop) -> None: + with client.assets.with_streaming_response.download( + url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + asset = response.parse() + assert_matches_type(AssetDownloadResponse, asset, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncAssets: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_download(self, async_client: AsyncBeeperDesktop) -> None: + asset = await async_client.assets.download( + url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", + ) + assert_matches_type(AssetDownloadResponse, asset, path=["response"]) + + @parametrize + async def test_raw_response_download(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.assets.with_raw_response.download( + url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + asset = await response.parse() + assert_matches_type(AssetDownloadResponse, asset, path=["response"]) + + @parametrize + async def test_streaming_response_download(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.assets.with_streaming_response.download( + url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + asset = await response.parse() + assert_matches_type(AssetDownloadResponse, asset, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index 84cfdb1..6a59733 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -14,8 +14,8 @@ ChatListResponse, ChatCreateResponse, ) -from beeper_desktop_api.pagination import SyncCursorList, AsyncCursorList -from beeper_desktop_api.types.shared import BaseResponse +from beeper_desktop_api._utils import parse_datetime +from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorNoLimit, AsyncCursorNoLimit base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -26,7 +26,7 @@ class TestChats: @parametrize def test_method_create(self, client: BeeperDesktop) -> None: chat = client.chats.create( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + account_id="accountID", participant_ids=["string"], type="single", ) @@ -35,7 +35,7 @@ def test_method_create(self, client: BeeperDesktop) -> None: @parametrize def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: chat = client.chats.create( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + account_id="accountID", participant_ids=["string"], type="single", message_text="messageText", @@ -46,7 +46,7 @@ def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: @parametrize def test_raw_response_create(self, client: BeeperDesktop) -> None: response = client.chats.with_raw_response.create( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + account_id="accountID", participant_ids=["string"], type="single", ) @@ -59,7 +59,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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + account_id="accountID", participant_ids=["string"], type="single", ) as response: @@ -120,7 +120,7 @@ def test_path_params_retrieve(self, client: BeeperDesktop) -> None: @parametrize def test_method_list(self, client: BeeperDesktop) -> None: chat = client.chats.list() - assert_matches_type(SyncCursorList[ChatListResponse], chat, path=["response"]) + assert_matches_type(SyncCursorNoLimit[ChatListResponse], chat, path=["response"]) @parametrize def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: @@ -133,7 +133,7 @@ def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) - assert_matches_type(SyncCursorList[ChatListResponse], chat, path=["response"]) + assert_matches_type(SyncCursorNoLimit[ChatListResponse], chat, path=["response"]) @parametrize def test_raw_response_list(self, client: BeeperDesktop) -> None: @@ -142,7 +142,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" chat = response.parse() - assert_matches_type(SyncCursorList[ChatListResponse], chat, path=["response"]) + assert_matches_type(SyncCursorNoLimit[ChatListResponse], chat, path=["response"]) @parametrize def test_streaming_response_list(self, client: BeeperDesktop) -> None: @@ -151,7 +151,7 @@ def test_streaming_response_list(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = response.parse() - assert_matches_type(SyncCursorList[ChatListResponse], chat, path=["response"]) + assert_matches_type(SyncCursorNoLimit[ChatListResponse], chat, path=["response"]) assert cast(Any, response.is_closed) is True @@ -160,7 +160,7 @@ def test_method_archive(self, client: BeeperDesktop) -> None: chat = client.chats.archive( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(BaseResponse, chat, path=["response"]) + assert chat is None @parametrize def test_method_archive_with_all_params(self, client: BeeperDesktop) -> None: @@ -168,7 +168,7 @@ def test_method_archive_with_all_params(self, client: BeeperDesktop) -> None: chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", archived=True, ) - assert_matches_type(BaseResponse, chat, path=["response"]) + assert chat is None @parametrize def test_raw_response_archive(self, client: BeeperDesktop) -> None: @@ -179,7 +179,7 @@ def test_raw_response_archive(self, client: BeeperDesktop) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = response.parse() - assert_matches_type(BaseResponse, chat, path=["response"]) + assert chat is None @parametrize def test_streaming_response_archive(self, client: BeeperDesktop) -> None: @@ -190,7 +190,7 @@ def test_streaming_response_archive(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = response.parse() - assert_matches_type(BaseResponse, chat, path=["response"]) + assert chat is None assert cast(Any, response.is_closed) is True @@ -201,6 +201,52 @@ def test_path_params_archive(self, client: BeeperDesktop) -> None: chat_id="", ) + @parametrize + def test_method_search(self, client: BeeperDesktop) -> None: + chat = client.chats.search() + assert_matches_type(SyncCursorSearch[Chat], chat, path=["response"]) + + @parametrize + def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: + chat = client.chats.search( + account_ids=[ + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", + ], + cursor="1725489123456|c29tZUltc2dQYWdl", + direction="before", + inbox="primary", + include_muted=True, + last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), + last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), + limit=1, + query="x", + scope="titles", + type="single", + unread_only=True, + ) + assert_matches_type(SyncCursorSearch[Chat], chat, path=["response"]) + + @parametrize + def test_raw_response_search(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.search() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(SyncCursorSearch[Chat], chat, path=["response"]) + + @parametrize + def test_streaming_response_search(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.search() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(SyncCursorSearch[Chat], chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + class TestAsyncChats: parametrize = pytest.mark.parametrize( @@ -210,7 +256,7 @@ class TestAsyncChats: @parametrize async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.create( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + account_id="accountID", participant_ids=["string"], type="single", ) @@ -219,7 +265,7 @@ async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: @parametrize async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.create( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + account_id="accountID", participant_ids=["string"], type="single", message_text="messageText", @@ -230,7 +276,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesk @parametrize async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: response = await async_client.chats.with_raw_response.create( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + account_id="accountID", participant_ids=["string"], type="single", ) @@ -243,7 +289,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="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + account_id="accountID", participant_ids=["string"], type="single", ) as response: @@ -304,7 +350,7 @@ async def test_path_params_retrieve(self, async_client: AsyncBeeperDesktop) -> N @parametrize async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.list() - assert_matches_type(AsyncCursorList[ChatListResponse], chat, path=["response"]) + assert_matches_type(AsyncCursorNoLimit[ChatListResponse], chat, path=["response"]) @parametrize async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -317,7 +363,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncBeeperDeskto cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) - assert_matches_type(AsyncCursorList[ChatListResponse], chat, path=["response"]) + assert_matches_type(AsyncCursorNoLimit[ChatListResponse], chat, path=["response"]) @parametrize async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -326,7 +372,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" chat = await response.parse() - assert_matches_type(AsyncCursorList[ChatListResponse], chat, path=["response"]) + assert_matches_type(AsyncCursorNoLimit[ChatListResponse], chat, path=["response"]) @parametrize async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -335,7 +381,7 @@ async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) - assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = await response.parse() - assert_matches_type(AsyncCursorList[ChatListResponse], chat, path=["response"]) + assert_matches_type(AsyncCursorNoLimit[ChatListResponse], chat, path=["response"]) assert cast(Any, response.is_closed) is True @@ -344,7 +390,7 @@ async def test_method_archive(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.archive( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(BaseResponse, chat, path=["response"]) + assert chat is None @parametrize async def test_method_archive_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -352,7 +398,7 @@ async def test_method_archive_with_all_params(self, async_client: AsyncBeeperDes chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", archived=True, ) - assert_matches_type(BaseResponse, chat, path=["response"]) + assert chat is None @parametrize async def test_raw_response_archive(self, async_client: AsyncBeeperDesktop) -> None: @@ -363,7 +409,7 @@ async def test_raw_response_archive(self, async_client: AsyncBeeperDesktop) -> N assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = await response.parse() - assert_matches_type(BaseResponse, chat, path=["response"]) + assert chat is None @parametrize async def test_streaming_response_archive(self, async_client: AsyncBeeperDesktop) -> None: @@ -374,7 +420,7 @@ async def test_streaming_response_archive(self, async_client: AsyncBeeperDesktop assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = await response.parse() - assert_matches_type(BaseResponse, chat, path=["response"]) + assert chat is None assert cast(Any, response.is_closed) is True @@ -384,3 +430,49 @@ async def test_path_params_archive(self, async_client: AsyncBeeperDesktop) -> No await async_client.chats.with_raw_response.archive( chat_id="", ) + + @parametrize + async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.search() + assert_matches_type(AsyncCursorSearch[Chat], chat, path=["response"]) + + @parametrize + async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.search( + account_ids=[ + "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", + ], + cursor="1725489123456|c29tZUltc2dQYWdl", + direction="before", + inbox="primary", + include_muted=True, + last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), + last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), + limit=1, + query="x", + scope="titles", + type="single", + unread_only=True, + ) + assert_matches_type(AsyncCursorSearch[Chat], chat, path=["response"]) + + @parametrize + async def test_raw_response_search(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.search() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(AsyncCursorSearch[Chat], chat, path=["response"]) + + @parametrize + async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.search() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(AsyncCursorSearch[Chat], chat, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_client.py b/tests/api_resources/test_client.py index 54e150e..324cc52 100644 --- a/tests/api_resources/test_client.py +++ b/tests/api_resources/test_client.py @@ -9,11 +9,7 @@ from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types import ( - FocusResponse, - SearchResponse, - DownloadAssetResponse, -) +from beeper_desktop_api.types import FocusResponse, SearchResponse base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -21,37 +17,6 @@ class TestClient: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - @parametrize - def test_method_download_asset(self, client: BeeperDesktop) -> None: - client_ = client.download_asset( - url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", - ) - assert_matches_type(DownloadAssetResponse, client_, path=["response"]) - - @parametrize - def test_raw_response_download_asset(self, client: BeeperDesktop) -> None: - response = client.with_raw_response.download_asset( - url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - client_ = response.parse() - assert_matches_type(DownloadAssetResponse, client_, path=["response"]) - - @parametrize - def test_streaming_response_download_asset(self, client: BeeperDesktop) -> None: - with client.with_streaming_response.download_asset( - url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - client_ = response.parse() - assert_matches_type(DownloadAssetResponse, client_, path=["response"]) - - assert cast(Any, response.is_closed) is True - @parametrize def test_method_focus(self, client: BeeperDesktop) -> None: client_ = client.focus() @@ -124,37 +89,6 @@ class TestAsyncClient: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) - @parametrize - async def test_method_download_asset(self, async_client: AsyncBeeperDesktop) -> None: - client = await async_client.download_asset( - url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", - ) - assert_matches_type(DownloadAssetResponse, client, path=["response"]) - - @parametrize - async def test_raw_response_download_asset(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.with_raw_response.download_asset( - url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - client = await response.parse() - assert_matches_type(DownloadAssetResponse, client, path=["response"]) - - @parametrize - async def test_streaming_response_download_asset(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.with_streaming_response.download_asset( - url="mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - client = await response.parse() - assert_matches_type(DownloadAssetResponse, client, path=["response"]) - - assert cast(Any, response.is_closed) is True - @parametrize async def test_method_focus(self, async_client: AsyncBeeperDesktop) -> None: client = await async_client.focus() diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index d64cf44..0a6d9f3 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -13,7 +13,7 @@ MessageSendResponse, ) from beeper_desktop_api._utils import parse_datetime -from beeper_desktop_api.pagination import SyncCursorList, AsyncCursorList, SyncCursorSearch, AsyncCursorSearch +from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorSortKey, AsyncCursorSortKey from beeper_desktop_api.types.shared import Message base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -27,7 +27,7 @@ def test_method_list(self, client: BeeperDesktop) -> None: message = client.messages.list( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(SyncCursorList[Message], message, path=["response"]) + assert_matches_type(SyncCursorSortKey[Message], message, path=["response"]) @parametrize def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: @@ -36,7 +36,7 @@ def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) - assert_matches_type(SyncCursorList[Message], message, path=["response"]) + assert_matches_type(SyncCursorSortKey[Message], message, path=["response"]) @parametrize def test_raw_response_list(self, client: BeeperDesktop) -> None: @@ -47,7 +47,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(SyncCursorList[Message], message, path=["response"]) + assert_matches_type(SyncCursorSortKey[Message], message, path=["response"]) @parametrize def test_streaming_response_list(self, client: BeeperDesktop) -> None: @@ -58,7 +58,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(SyncCursorList[Message], message, path=["response"]) + assert_matches_type(SyncCursorSortKey[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True @@ -175,7 +175,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(AsyncCursorList[Message], message, path=["response"]) + assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"]) @parametrize async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -184,7 +184,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncBeeperDeskto cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) - assert_matches_type(AsyncCursorList[Message], message, path=["response"]) + assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"]) @parametrize async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -195,7 +195,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(AsyncCursorList[Message], message, path=["response"]) + assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"]) @parametrize async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -206,7 +206,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(AsyncCursorList[Message], message, path=["response"]) + assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_search.py b/tests/api_resources/test_search.py deleted file mode 100644 index 1c70e2d..0000000 --- a/tests/api_resources/test_search.py +++ /dev/null @@ -1,202 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -import os -from typing import Any, cast - -import pytest - -from tests.utils import assert_matches_type -from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types import Chat, SearchContactsResponse -from beeper_desktop_api._utils import parse_datetime -from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class TestSearch: - parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - - @parametrize - def test_method_chats(self, client: BeeperDesktop) -> None: - search = client.search.chats() - assert_matches_type(SyncCursorSearch[Chat], search, path=["response"]) - - @parametrize - def test_method_chats_with_all_params(self, client: BeeperDesktop) -> None: - search = client.search.chats( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", - ], - cursor="1725489123456|c29tZUltc2dQYWdl", - direction="before", - inbox="primary", - include_muted=True, - last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), - last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), - limit=1, - query="x", - scope="titles", - type="single", - unread_only=True, - ) - assert_matches_type(SyncCursorSearch[Chat], search, path=["response"]) - - @parametrize - def test_raw_response_chats(self, client: BeeperDesktop) -> None: - response = client.search.with_raw_response.chats() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - search = response.parse() - assert_matches_type(SyncCursorSearch[Chat], search, path=["response"]) - - @parametrize - def test_streaming_response_chats(self, client: BeeperDesktop) -> None: - with client.search.with_streaming_response.chats() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - search = response.parse() - assert_matches_type(SyncCursorSearch[Chat], search, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - def test_method_contacts(self, client: BeeperDesktop) -> None: - search = client.search.contacts( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) - assert_matches_type(SearchContactsResponse, search, path=["response"]) - - @parametrize - def test_raw_response_contacts(self, client: BeeperDesktop) -> None: - response = client.search.with_raw_response.contacts( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - search = response.parse() - assert_matches_type(SearchContactsResponse, search, path=["response"]) - - @parametrize - def test_streaming_response_contacts(self, client: BeeperDesktop) -> None: - with client.search.with_streaming_response.contacts( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - search = response.parse() - assert_matches_type(SearchContactsResponse, search, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - def test_path_params_contacts(self, client: BeeperDesktop) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `account_id` but received ''"): - client.search.with_raw_response.contacts( - account_id="", - query="x", - ) - - -class TestAsyncSearch: - parametrize = pytest.mark.parametrize( - "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] - ) - - @parametrize - async def test_method_chats(self, async_client: AsyncBeeperDesktop) -> None: - search = await async_client.search.chats() - assert_matches_type(AsyncCursorSearch[Chat], search, path=["response"]) - - @parametrize - async def test_method_chats_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: - search = await async_client.search.chats( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", - ], - cursor="1725489123456|c29tZUltc2dQYWdl", - direction="before", - inbox="primary", - include_muted=True, - last_activity_after=parse_datetime("2019-12-27T18:11:19.117Z"), - last_activity_before=parse_datetime("2019-12-27T18:11:19.117Z"), - limit=1, - query="x", - scope="titles", - type="single", - unread_only=True, - ) - assert_matches_type(AsyncCursorSearch[Chat], search, path=["response"]) - - @parametrize - async def test_raw_response_chats(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.search.with_raw_response.chats() - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - search = await response.parse() - assert_matches_type(AsyncCursorSearch[Chat], search, path=["response"]) - - @parametrize - async def test_streaming_response_chats(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.search.with_streaming_response.chats() as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - search = await response.parse() - assert_matches_type(AsyncCursorSearch[Chat], search, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - async def test_method_contacts(self, async_client: AsyncBeeperDesktop) -> None: - search = await async_client.search.contacts( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) - assert_matches_type(SearchContactsResponse, search, path=["response"]) - - @parametrize - async def test_raw_response_contacts(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.search.with_raw_response.contacts( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - search = await response.parse() - assert_matches_type(SearchContactsResponse, search, path=["response"]) - - @parametrize - async def test_streaming_response_contacts(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.search.with_streaming_response.contacts( - account_id="local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - query="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - search = await response.parse() - assert_matches_type(SearchContactsResponse, search, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - async def test_path_params_contacts(self, async_client: AsyncBeeperDesktop) -> None: - with pytest.raises(ValueError, match=r"Expected a non-empty value for `account_id` but received ''"): - await async_client.search.with_raw_response.contacts( - account_id="", - query="x", - ) From b9fba0a2b559b73c85b2384a110ea418aa0c6734 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 16:40:59 +0000 Subject: [PATCH 24/98] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index a005806..f053773 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-2a2f6bfd48fd33b8162f97ecb46ad4568eb15b7221add1766567cb713d9609d8.yml -openapi_spec_hash: 714cbd98920316b5dddbc881743ee554 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-0763b61997721da6f4514241bf0f7bb5f7a88c7298baf0f1b2d58036aaf7e2f1.yml +openapi_spec_hash: 5158475919c04bb52fb03c6a4582188d config_hash: 15424d9ae390c7fca17dbf08619fc88b From 9fdb20da474dff13a94923bf5115e19b0c7e415d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 22:40:01 +0000 Subject: [PATCH 25/98] 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 f053773..325712c 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-0763b61997721da6f4514241bf0f7bb5f7a88c7298baf0f1b2d58036aaf7e2f1.yml openapi_spec_hash: 5158475919c04bb52fb03c6a4582188d -config_hash: 15424d9ae390c7fca17dbf08619fc88b +config_hash: 5fa7ded4bfdffe4cc1944a819da87f9f diff --git a/README.md b/README.md index 12f9c22..33488be 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,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). @@ -81,8 +78,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 6492bcdf077d541674856d16e49356b3b7aca10c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 22:54:57 +0000 Subject: [PATCH 26/98] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/beeper_desktop_api/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1332969..8d124d8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.0.1" + ".": "4.1.295" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4da43df..9f2706e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "beeper_desktop_api" -version = "0.0.1" +version = "4.1.295" 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 3ba6273..623e1a3 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__ = "0.0.1" # x-release-please-version +__version__ = "4.1.295" # x-release-please-version From 47c5cb617e35bd9aca9d82b5b1c303e565fc4902 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Oct 2025 02:08:35 +0000 Subject: [PATCH 27/98] chore: bump `httpx-aiohttp` version to 0.1.9 --- pyproject.toml | 2 +- requirements-dev.lock | 2 +- requirements.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9f2706e..da72703 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Homepage = "https://github.com/beeper/desktop-api-python" Repository = "https://github.com/beeper/desktop-api-python" [project.optional-dependencies] -aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] [tool.rye] managed = true diff --git a/requirements-dev.lock b/requirements-dev.lock index 219c95e..22f7e57 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -56,7 +56,7 @@ httpx==0.28.1 # via beeper-desktop-api # via httpx-aiohttp # via respx -httpx-aiohttp==0.1.8 +httpx-aiohttp==0.1.9 # via beeper-desktop-api idna==3.4 # via anyio diff --git a/requirements.lock b/requirements.lock index 22d2655..afb8f46 100644 --- a/requirements.lock +++ b/requirements.lock @@ -43,7 +43,7 @@ httpcore==1.0.9 httpx==0.28.1 # via beeper-desktop-api # via httpx-aiohttp -httpx-aiohttp==0.1.8 +httpx-aiohttp==0.1.9 # via beeper-desktop-api idna==3.4 # via anyio From a8fe5cce9aa9717b728a8973fe703a537649ba92 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:08:13 +0000 Subject: [PATCH 28/98] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/beeper_desktop_api/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 8d124d8..d6a8b5d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "4.1.295" + ".": "4.1.296" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index da72703..8855e0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "beeper_desktop_api" -version = "4.1.295" +version = "4.1.296" 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 623e1a3..f18d0c4 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.1.295" # x-release-please-version +__version__ = "4.1.296" # x-release-please-version From 2d79952912ac24ce5f744a831478ee203826871e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 20:50:22 +0000 Subject: [PATCH 29/98] feat(api): add `description` field to chats, make `title` optional `title` now fallbacks to `null`, instead of an empty string --- .stats.yml | 4 ++-- src/beeper_desktop_api/types/chat.py | 13 ++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.stats.yml b/.stats.yml index 325712c..adde7c3 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-0763b61997721da6f4514241bf0f7bb5f7a88c7298baf0f1b2d58036aaf7e2f1.yml -openapi_spec_hash: 5158475919c04bb52fb03c6a4582188d +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-bea2e5f3b01053a66261a824c75c2640856d0ba00ad795ab71734c4ab9ae33b0.yml +openapi_spec_hash: d766f6e344c12ca6d23e6ef6713b38c4 config_hash: 5fa7ded4bfdffe4cc1944a819da87f9f diff --git a/src/beeper_desktop_api/types/chat.py b/src/beeper_desktop_api/types/chat.py index b42cf4b..f386d4c 100644 --- a/src/beeper_desktop_api/types/chat.py +++ b/src/beeper_desktop_api/types/chat.py @@ -36,23 +36,23 @@ class Chat(BaseModel): participants: Participants """Chat participants information.""" - title: str - """Display title of the chat as computed by the client/server.""" - type: Literal["single", "group"] """Chat type: 'single' for direct messages, 'group' for group chats.""" unread_count: int = FieldInfo(alias="unreadCount") """Number of unread messages.""" + description: Optional[str] = None + """Description of the chat.""" + is_archived: Optional[bool] = FieldInfo(alias="isArchived", default=None) """True if chat is archived.""" is_muted: Optional[bool] = FieldInfo(alias="isMuted", default=None) - """True if chat notifications are muted.""" + """True if the chat is muted.""" is_pinned: Optional[bool] = FieldInfo(alias="isPinned", default=None) - """True if chat is pinned.""" + """True if the chat is pinned.""" last_activity: Optional[datetime] = FieldInfo(alias="lastActivity", default=None) """Timestamp of last activity.""" @@ -62,3 +62,6 @@ class Chat(BaseModel): local_chat_id: Optional[str] = FieldInfo(alias="localChatID", default=None) """Local chat ID specific to this Beeper Desktop installation.""" + + title: Optional[str] = None + """Display title of the chat.""" From 88845098ad6c04c0acda171e53edeee63367feb1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 02:14:28 +0000 Subject: [PATCH 30/98] fix(client): close streams without requiring full consumption --- src/beeper_desktop_api/_streaming.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/beeper_desktop_api/_streaming.py b/src/beeper_desktop_api/_streaming.py index 3462cf4..3cc46b9 100644 --- a/src/beeper_desktop_api/_streaming.py +++ b/src/beeper_desktop_api/_streaming.py @@ -57,9 +57,8 @@ def __stream__(self) -> Iterator[_T]: for sse in iterator: yield process_data(data=sse.json(), cast_to=cast_to, response=response) - # Ensure the entire stream is consumed - for _sse in iterator: - ... + # As we might not fully consume the response stream, we need to close it explicitly + response.close() def __enter__(self) -> Self: return self @@ -121,9 +120,8 @@ async def __stream__(self) -> AsyncIterator[_T]: async for sse in iterator: yield process_data(data=sse.json(), cast_to=cast_to, response=response) - # Ensure the entire stream is consumed - async for _sse in iterator: - ... + # As we might not fully consume the response stream, we need to close it explicitly + await response.aclose() async def __aenter__(self) -> Self: return self From 3c3c0af87a162d3d6b2c5249ca297d95231a94fd Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 02:33:18 +0000 Subject: [PATCH 31/98] chore(internal/tests): avoid race condition with implicit client cleanup --- tests/test_client.py | 372 ++++++++++++++++++++++++------------------- 1 file changed, 208 insertions(+), 164 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index c9e3e9e..a1f0951 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -64,51 +64,49 @@ def _get_open_connections(client: BeeperDesktop | AsyncBeeperDesktop) -> int: class TestBeeperDesktop: - client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - def test_raw_response(self, respx_mock: MockRouter) -> None: + def test_raw_response(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + def test_raw_response_for_binary(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, client: BeeperDesktop) -> None: + copied = client.copy() + assert id(copied) != id(client) - copied = self.client.copy(access_token="another My Access Token") + copied = client.copy(access_token="another My Access Token") assert copied.access_token == "another My Access Token" - assert self.client.access_token == "My Access Token" + assert client.access_token == "My Access Token" - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, client: BeeperDesktop) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(client.timeout, httpx.Timeout) + copied = client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(client.timeout, httpx.Timeout) def test_copy_default_headers(self) -> None: client = BeeperDesktop( @@ -146,6 +144,7 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + client.close() def test_copy_default_query(self) -> None: client = BeeperDesktop( @@ -183,13 +182,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + client.close() + + def test_copy_signature(self, client: BeeperDesktop) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -200,12 +201,12 @@ def test_copy_signature(self) -> None: assert copy_param is not None, f"copy() signature is missing the {name} param" @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: + def test_copy_build_request(self, client: BeeperDesktop) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -262,14 +263,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + def test_request_timeout(self, client: BeeperDesktop) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( - FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) - ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(100.0) @@ -282,6 +281,8 @@ def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + client.close() + def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used with httpx.Client(timeout=None) as http_client: @@ -293,6 +294,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + client.close() + # no timeout given to the httpx client should not use the httpx default with httpx.Client() as http_client: client = BeeperDesktop( @@ -303,6 +306,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + client.close() + # explicitly passing the default timeout currently results in it being ignored with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = BeeperDesktop( @@ -313,6 +318,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + client.close() + async def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): async with httpx.AsyncClient() as http_client: @@ -324,17 +331,17 @@ async def test_invalid_http_client(self) -> None: ) def test_default_headers_option(self) -> None: - client = BeeperDesktop( + test_client = BeeperDesktop( base_url=base_url, access_token=access_token, _strict_response_validation=True, default_headers={"X-Foo": "bar"}, ) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = BeeperDesktop( + test_client2 = BeeperDesktop( base_url=base_url, access_token=access_token, _strict_response_validation=True, @@ -343,10 +350,13 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" + test_client.close() + test_client2.close() + def test_validate_headers(self) -> None: client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) request = client._build_request(FinalRequestOptions(method="get", url="/foo")) @@ -378,8 +388,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + client.close() + + def test_request_extra_json(self, client: BeeperDesktop) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -390,7 +402,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -401,7 +413,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -412,8 +424,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: BeeperDesktop) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -423,7 +435,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -434,8 +446,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: BeeperDesktop) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -448,7 +460,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -462,7 +474,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -505,7 +517,7 @@ def test_multipart_repeating_array(self, client: BeeperDesktop) -> None: ] @pytest.mark.respx(base_url=base_url) - def test_basic_union_response(self, respx_mock: MockRouter) -> None: + def test_basic_union_response(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: class Model1(BaseModel): name: str @@ -514,12 +526,12 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + def test_union_response_different_types(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -530,18 +542,20 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + def test_non_application_json_content_type_for_json_data( + self, respx_mock: MockRouter, client: BeeperDesktop + ) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -557,7 +571,7 @@ class Model(BaseModel): ) ) - response = self.client.get("/foo", cast_to=Model) + response = client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 @@ -571,6 +585,8 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" + client.close() + def test_base_url_env(self) -> None: with update_env(BEEPER_DESKTOP_BASE_URL="http://localhost:5000/from/env"): client = BeeperDesktop(access_token=access_token, _strict_response_validation=True) @@ -602,6 +618,7 @@ def test_base_url_trailing_slash(self, client: BeeperDesktop) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -629,6 +646,7 @@ def test_base_url_no_trailing_slash(self, client: BeeperDesktop) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -656,35 +674,36 @@ def test_absolute_request_url(self, client: BeeperDesktop) -> None: ), ) assert request.url == "https://myapi.com/foo" + client.close() def test_copied_client_does_not_close_http(self) -> None: - client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) - assert not client.is_closed() + test_client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied - assert not client.is_closed() + assert not test_client.is_closed() def test_client_context_manager(self) -> None: - client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) - with client as c2: - assert c2 is client + test_client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + def test_client_response_validation_error(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - self.client.get("/foo", cast_to=Model) + client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -709,11 +728,16 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): strict_client.get("/foo", cast_to=Model) - client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=False) + non_strict_client = BeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=False + ) - response = client.get("/foo", cast_to=Model) + response = non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + strict_client.close() + non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -736,9 +760,9 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) - + def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, client: BeeperDesktop + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) calculated = client._calculate_retry_timeout(remaining_retries, options, headers) @@ -752,7 +776,7 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, clien with pytest.raises(APITimeoutError): client.accounts.with_streaming_response.list().__enter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(client) == 0 @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -761,7 +785,7 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client with pytest.raises(APIStatusError): client.accounts.with_streaming_response.list().__enter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -863,83 +887,77 @@ def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - def test_follow_redirects(self, respx_mock: MockRouter) -> None: + def test_follow_redirects(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: # Test that the default follow_redirects=True allows following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + response = client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) assert response.status_code == 200 assert response.json() == {"status": "ok"} @pytest.mark.respx(base_url=base_url) - def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + def test_follow_redirects_disabled(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: # Test that follow_redirects=False prevents following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) with pytest.raises(APIStatusError) as exc_info: - self.client.post( - "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response - ) + client.post("/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response) assert exc_info.value.response.status_code == 302 assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" class TestAsyncBeeperDesktop: - client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response(self, respx_mock: MockRouter) -> None: + async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, async_client: AsyncBeeperDesktop) -> None: + copied = async_client.copy() + assert id(copied) != id(async_client) - copied = self.client.copy(access_token="another My Access Token") + copied = async_client.copy(access_token="another My Access Token") assert copied.access_token == "another My Access Token" - assert self.client.access_token == "My Access Token" + assert async_client.access_token == "My Access Token" - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, async_client: AsyncBeeperDesktop) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = async_client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert async_client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(async_client.timeout, httpx.Timeout) + copied = async_client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(async_client.timeout, httpx.Timeout) - def test_copy_default_headers(self) -> None: + async def test_copy_default_headers(self) -> None: client = AsyncBeeperDesktop( base_url=base_url, access_token=access_token, @@ -975,8 +993,9 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + await client.close() - def test_copy_default_query(self) -> None: + async def test_copy_default_query(self) -> None: client = AsyncBeeperDesktop( base_url=base_url, access_token=access_token, _strict_response_validation=True, default_query={"foo": "bar"} ) @@ -1012,13 +1031,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + await client.close() + + def test_copy_signature(self, async_client: AsyncBeeperDesktop) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + async_client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(async_client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -1029,12 +1050,12 @@ def test_copy_signature(self) -> None: assert copy_param is not None, f"copy() signature is missing the {name} param" @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: + def test_copy_build_request(self, async_client: AsyncBeeperDesktop) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = async_client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -1091,12 +1112,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - async def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + async def test_request_timeout(self, async_client: AsyncBeeperDesktop) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( + request = async_client._build_request( FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) ) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore @@ -1111,6 +1132,8 @@ async def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + await client.close() + async def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used async with httpx.AsyncClient(timeout=None) as http_client: @@ -1122,6 +1145,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + await client.close() + # no timeout given to the httpx client should not use the httpx default async with httpx.AsyncClient() as http_client: client = AsyncBeeperDesktop( @@ -1132,6 +1157,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + await client.close() + # explicitly passing the default timeout currently results in it being ignored async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = AsyncBeeperDesktop( @@ -1142,6 +1169,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + await client.close() + def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): with httpx.Client() as http_client: @@ -1152,18 +1181,18 @@ def test_invalid_http_client(self) -> None: http_client=cast(Any, http_client), ) - def test_default_headers_option(self) -> None: - client = AsyncBeeperDesktop( + async def test_default_headers_option(self) -> None: + test_client = AsyncBeeperDesktop( base_url=base_url, access_token=access_token, _strict_response_validation=True, default_headers={"X-Foo": "bar"}, ) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = AsyncBeeperDesktop( + test_client2 = AsyncBeeperDesktop( base_url=base_url, access_token=access_token, _strict_response_validation=True, @@ -1172,10 +1201,13 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" + await test_client.close() + await test_client2.close() + def test_validate_headers(self) -> None: client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) request = client._build_request(FinalRequestOptions(method="get", url="/foo")) @@ -1186,7 +1218,7 @@ def test_validate_headers(self) -> None: client2 = AsyncBeeperDesktop(base_url=base_url, access_token=None, _strict_response_validation=True) _ = client2 - def test_default_query_option(self) -> None: + async def test_default_query_option(self) -> None: client = AsyncBeeperDesktop( base_url=base_url, access_token=access_token, @@ -1207,8 +1239,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + await client.close() + + def test_request_extra_json(self, client: BeeperDesktop) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1219,7 +1253,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1230,7 +1264,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1241,8 +1275,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: BeeperDesktop) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1252,7 +1286,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1263,8 +1297,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: BeeperDesktop) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1277,7 +1311,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1291,7 +1325,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1334,7 +1368,7 @@ def test_multipart_repeating_array(self, async_client: AsyncBeeperDesktop) -> No ] @pytest.mark.respx(base_url=base_url) - async def test_basic_union_response(self, respx_mock: MockRouter) -> None: + async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop) -> None: class Model1(BaseModel): name: str @@ -1343,12 +1377,14 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - async def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + async def test_union_response_different_types( + self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop + ) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -1359,18 +1395,20 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + async def test_non_application_json_content_type_for_json_data( + self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop + ) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -1386,11 +1424,11 @@ class Model(BaseModel): ) ) - response = await self.client.get("/foo", cast_to=Model) + response = await async_client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 - def test_base_url_setter(self) -> None: + async def test_base_url_setter(self) -> None: client = AsyncBeeperDesktop( base_url="https://example.com/from_init", access_token=access_token, _strict_response_validation=True ) @@ -1400,7 +1438,9 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" - def test_base_url_env(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"): client = AsyncBeeperDesktop(access_token=access_token, _strict_response_validation=True) assert client.base_url == "http://localhost:5000/from/env/" @@ -1422,7 +1462,7 @@ def test_base_url_env(self) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_trailing_slash(self, client: AsyncBeeperDesktop) -> None: + async def test_base_url_trailing_slash(self, client: AsyncBeeperDesktop) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1431,6 +1471,7 @@ def test_base_url_trailing_slash(self, client: AsyncBeeperDesktop) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1449,7 +1490,7 @@ def test_base_url_trailing_slash(self, client: AsyncBeeperDesktop) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_no_trailing_slash(self, client: AsyncBeeperDesktop) -> None: + async def test_base_url_no_trailing_slash(self, client: AsyncBeeperDesktop) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1458,6 +1499,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncBeeperDesktop) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1476,7 +1518,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncBeeperDesktop) -> None: ], ids=["standard", "custom http client"], ) - def test_absolute_request_url(self, client: AsyncBeeperDesktop) -> None: + async def test_absolute_request_url(self, client: AsyncBeeperDesktop) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1485,37 +1527,39 @@ def test_absolute_request_url(self, client: AsyncBeeperDesktop) -> None: ), ) assert request.url == "https://myapi.com/foo" + await client.close() async def test_copied_client_does_not_close_http(self) -> None: - client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) - assert not client.is_closed() + test_client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied await asyncio.sleep(0.2) - assert not client.is_closed() + assert not test_client.is_closed() async def test_client_context_manager(self) -> None: - client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) - async with client as c2: - assert c2 is client + test_client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) + async with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + async def test_client_response_validation_error( + self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop + ) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - await self.client.get("/foo", cast_to=Model) + await async_client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -1529,7 +1573,6 @@ async def test_client_max_retries_validation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: class Model(BaseModel): name: str @@ -1543,11 +1586,16 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): await strict_client.get("/foo", cast_to=Model) - client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=False) + non_strict_client = AsyncBeeperDesktop( + base_url=base_url, access_token=access_token, _strict_response_validation=False + ) - response = await client.get("/foo", cast_to=Model) + response = await non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + await strict_client.close() + await non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -1570,13 +1618,12 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - @pytest.mark.asyncio - async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True) - + async def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncBeeperDesktop + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) - calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers) assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -1589,7 +1636,7 @@ async def test_retrying_timeout_errors_doesnt_leak( with pytest.raises(APITimeoutError): await async_client.accounts.with_streaming_response.list().__aenter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(async_client) == 0 @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -1600,12 +1647,11 @@ async def test_retrying_status_errors_doesnt_leak( with pytest.raises(APIStatusError): await async_client.accounts.with_streaming_response.list().__aenter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(async_client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio @pytest.mark.parametrize("failure_mode", ["status", "exception"]) async def test_retries_taken( self, @@ -1637,7 +1683,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_omit_retry_count_header( self, async_client: AsyncBeeperDesktop, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1661,7 +1706,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_overwrite_retry_count_header( self, async_client: AsyncBeeperDesktop, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1709,26 +1753,26 @@ async def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + async def test_follow_redirects(self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop) -> None: # Test that the default follow_redirects=True allows following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + response = await async_client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) assert response.status_code == 200 assert response.json() == {"status": "ok"} @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + async def test_follow_redirects_disabled(self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop) -> None: # Test that follow_redirects=False prevents following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) with pytest.raises(APIStatusError) as exc_info: - await self.client.post( + await async_client.post( "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response ) From e5a1038ad16754242860c58ed59982e432f980d3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 03:45:12 +0000 Subject: [PATCH 32/98] chore(internal): grammar fix (it's -> its) --- src/beeper_desktop_api/_utils/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/beeper_desktop_api/_utils/_utils.py b/src/beeper_desktop_api/_utils/_utils.py index 50d5926..eec7f4a 100644 --- a/src/beeper_desktop_api/_utils/_utils.py +++ b/src/beeper_desktop_api/_utils/_utils.py @@ -133,7 +133,7 @@ def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: # Type safe methods for narrowing types with TypeVars. # The default narrowing for isinstance(obj, dict) is dict[unknown, unknown], # however this cause Pyright to rightfully report errors. As we know we don't -# care about the contained types we can safely use `object` in it's place. +# care about the contained types we can safely use `object` in its place. # # There are two separate functions defined, `is_*` and `is_*_t` for different use cases. # `is_*` is for when you're dealing with an unknown input From 598e7294db043d04bd896099a339af65805d6f4e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 03:40:46 +0000 Subject: [PATCH 33/98] chore(package): drop Python 3.8 support --- README.md | 4 +-- pyproject.toml | 5 ++-- src/beeper_desktop_api/_utils/_sync.py | 34 +++----------------------- 3 files changed, 7 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 33488be..9fe7bda 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![PyPI version](https://img.shields.io/pypi/v/beeper_desktop_api.svg?label=pypi%20(stable))](https://pypi.org/project/beeper_desktop_api/) -The Beeper Desktop Python library provides convenient access to the Beeper Desktop REST API from any Python 3.8+ +The Beeper Desktop Python library provides convenient access to the Beeper Desktop REST API from any Python 3.9+ 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). @@ -460,7 +460,7 @@ print(beeper_desktop_api.__version__) ## Requirements -Python 3.8 or higher. +Python 3.9 or higher. ## Contributing diff --git a/pyproject.toml b/pyproject.toml index 8855e0f..53a1eea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,11 +15,10 @@ dependencies = [ "distro>=1.7.0, <2", "sniffio", ] -requires-python = ">= 3.8" +requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", "Intended Audience :: Developers", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -141,7 +140,7 @@ filterwarnings = [ # there are a couple of flags that are still disabled by # default in strict mode as they are experimental and niche. typeCheckingMode = "strict" -pythonVersion = "3.8" +pythonVersion = "3.9" exclude = [ "_dev", diff --git a/src/beeper_desktop_api/_utils/_sync.py b/src/beeper_desktop_api/_utils/_sync.py index ad7ec71..f6027c1 100644 --- a/src/beeper_desktop_api/_utils/_sync.py +++ b/src/beeper_desktop_api/_utils/_sync.py @@ -1,10 +1,8 @@ from __future__ import annotations -import sys import asyncio import functools -import contextvars -from typing import Any, TypeVar, Callable, Awaitable +from typing import TypeVar, Callable, Awaitable from typing_extensions import ParamSpec import anyio @@ -15,34 +13,11 @@ T_ParamSpec = ParamSpec("T_ParamSpec") -if sys.version_info >= (3, 9): - _asyncio_to_thread = asyncio.to_thread -else: - # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread - # for Python 3.8 support - async def _asyncio_to_thread( - func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs - ) -> Any: - """Asynchronously run function *func* in a separate thread. - - Any *args and **kwargs supplied for this function are directly passed - to *func*. Also, the current :class:`contextvars.Context` is propagated, - allowing context variables from the main thread to be accessed in the - separate thread. - - Returns a coroutine that can be awaited to get the eventual result of *func*. - """ - loop = asyncio.events.get_running_loop() - ctx = contextvars.copy_context() - func_call = functools.partial(ctx.run, func, *args, **kwargs) - return await loop.run_in_executor(None, func_call) - - async def to_thread( func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs ) -> T_Retval: if sniffio.current_async_library() == "asyncio": - return await _asyncio_to_thread(func, *args, **kwargs) + return await asyncio.to_thread(func, *args, **kwargs) return await anyio.to_thread.run_sync( functools.partial(func, *args, **kwargs), @@ -53,10 +28,7 @@ async def to_thread( def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: """ Take a blocking function and create an async one that receives the same - positional and keyword arguments. For python version 3.9 and above, it uses - asyncio.to_thread to run the function in a separate thread. For python version - 3.8, it uses locally defined copy of the asyncio.to_thread function which was - introduced in python 3.9. + positional and keyword arguments. Usage: From 4f3ed35a39695c318e89dccc7cca70bff5aa2252 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 03:41:43 +0000 Subject: [PATCH 34/98] fix: compat with Python 3.14 --- src/beeper_desktop_api/_models.py | 11 ++++++++--- tests/test_models.py | 8 ++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/beeper_desktop_api/_models.py b/src/beeper_desktop_api/_models.py index 6a3cd1d..fcec2cf 100644 --- a/src/beeper_desktop_api/_models.py +++ b/src/beeper_desktop_api/_models.py @@ -2,6 +2,7 @@ import os import inspect +import weakref from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast from datetime import date, datetime from typing_extensions import ( @@ -573,6 +574,9 @@ class CachedDiscriminatorType(Protocol): __discriminator__: DiscriminatorDetails +DISCRIMINATOR_CACHE: weakref.WeakKeyDictionary[type, DiscriminatorDetails] = weakref.WeakKeyDictionary() + + class DiscriminatorDetails: field_name: str """The name of the discriminator field in the variant class, e.g. @@ -615,8 +619,9 @@ def __init__( def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None: - if isinstance(union, CachedDiscriminatorType): - return union.__discriminator__ + cached = DISCRIMINATOR_CACHE.get(union) + if cached is not None: + return cached discriminator_field_name: str | None = None @@ -669,7 +674,7 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, discriminator_field=discriminator_field_name, discriminator_alias=discriminator_alias, ) - cast(CachedDiscriminatorType, union).__discriminator__ = details + DISCRIMINATOR_CACHE.setdefault(union, details) return details diff --git a/tests/test_models.py b/tests/test_models.py index b275b2c..e71da85 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,7 +9,7 @@ from beeper_desktop_api._utils import PropertyInfo from beeper_desktop_api._compat import PYDANTIC_V1, parse_obj, model_dump, model_json -from beeper_desktop_api._models import BaseModel, construct_type +from beeper_desktop_api._models import DISCRIMINATOR_CACHE, BaseModel, construct_type class BasicModel(BaseModel): @@ -809,7 +809,7 @@ class B(BaseModel): UnionType = cast(Any, Union[A, B]) - assert not hasattr(UnionType, "__discriminator__") + assert not DISCRIMINATOR_CACHE.get(UnionType) m = construct_type( value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) @@ -818,7 +818,7 @@ class B(BaseModel): assert m.type == "b" assert m.data == "foo" # type: ignore[comparison-overlap] - discriminator = UnionType.__discriminator__ + discriminator = DISCRIMINATOR_CACHE.get(UnionType) assert discriminator is not None m = construct_type( @@ -830,7 +830,7 @@ class B(BaseModel): # if the discriminator details object stays the same between invocations then # we hit the cache - assert UnionType.__discriminator__ is discriminator + assert DISCRIMINATOR_CACHE.get(UnionType) is discriminator @pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") From 4690b40a4c0bf58bc4af276744226793a12776b1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 03:33:58 +0000 Subject: [PATCH 35/98] fix(compat): update signatures of `model_dump` and `model_dump_json` for Pydantic v1 --- src/beeper_desktop_api/_models.py | 41 ++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/beeper_desktop_api/_models.py b/src/beeper_desktop_api/_models.py index fcec2cf..ca9500b 100644 --- a/src/beeper_desktop_api/_models.py +++ b/src/beeper_desktop_api/_models.py @@ -257,15 +257,16 @@ def model_dump( mode: Literal["json", "python"] | str = "python", include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, - serialize_as_any: bool = False, fallback: Callable[[Any], Any] | None = None, + serialize_as_any: bool = False, ) -> dict[str, Any]: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump @@ -273,16 +274,24 @@ def model_dump( Args: mode: The mode in which `to_python` should run. - If mode is 'json', the dictionary will only contain JSON serializable types. - If mode is 'python', the dictionary may contain any Python objects. - include: A list of fields to include in the output. - exclude: A list of fields to exclude from the output. + If mode is 'json', the output will only contain JSON serializable types. + If mode is 'python', the output may contain non-JSON-serializable Python objects. + include: A set of fields to include in the output. + exclude: A set of fields to exclude from the output. + context: Additional context to pass to the serializer. by_alias: Whether to use the field's alias in the dictionary key if defined. - exclude_unset: Whether to exclude fields that are unset or None from the output. - exclude_defaults: Whether to exclude fields that are set to their default value from the output. - exclude_none: Whether to exclude fields that have a value of `None` from the output. - round_trip: Whether to enable serialization and deserialization round-trip support. - warnings: Whether to log warnings when invalid fields are encountered. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value. + exclude_none: Whether to exclude fields that have a value of `None`. + exclude_computed_fields: Whether to exclude computed fields. + While this can be useful for round-tripping, it is usually recommended to use the dedicated + `round_trip` parameter instead. + round_trip: If True, dumped values should be valid as input for non-idempotent types such as Json[T]. + warnings: How to handle serialization errors. False/"none" ignores them, True/"warn" logs errors, + "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError]. + fallback: A function to call when an unknown value is encountered. If not provided, + a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised. + serialize_as_any: Whether to serialize fields with duck-typing serialization behavior. Returns: A dictionary representation of the model. @@ -299,6 +308,8 @@ def model_dump( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") dumped = super().dict( # pyright: ignore[reportDeprecated] include=include, exclude=exclude, @@ -315,15 +326,17 @@ def model_dump_json( self, *, indent: int | None = None, + ensure_ascii: bool = False, include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, ) -> str: @@ -355,6 +368,10 @@ def model_dump_json( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if ensure_ascii != False: + raise ValueError("ensure_ascii is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") return super().json( # type: ignore[reportDeprecated] indent=indent, include=include, From 53b64bc35ab5b22c53d1f1b2b2eff20b26cce36e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 22 Nov 2025 03:27:52 +0000 Subject: [PATCH 36/98] chore: add Python 3.14 classifier and testing --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 53a1eea..cb0e85c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", From 891a4bb7eb1b882f59df00fe55c9642bcffd1daa Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 03:10:02 +0000 Subject: [PATCH 37/98] fix: ensure streams are always closed --- src/beeper_desktop_api/_streaming.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/beeper_desktop_api/_streaming.py b/src/beeper_desktop_api/_streaming.py index 3cc46b9..55409b8 100644 --- a/src/beeper_desktop_api/_streaming.py +++ b/src/beeper_desktop_api/_streaming.py @@ -54,11 +54,12 @@ def __stream__(self) -> Iterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # As we might not fully consume the response stream, we need to close it explicitly - response.close() + try: + for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + response.close() def __enter__(self) -> Self: return self @@ -117,11 +118,12 @@ async def __stream__(self) -> AsyncIterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - async for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # As we might not fully consume the response stream, we need to close it explicitly - await response.aclose() + try: + async for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + await response.aclose() async def __aenter__(self) -> Self: return self From 3fa6de6a71f9ae59676117262304afaa64edddb4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 03:11:36 +0000 Subject: [PATCH 38/98] chore(deps): mypy 1.18.1 has a regression, pin to 1.17 --- pyproject.toml | 2 +- requirements-dev.lock | 4 +++- requirements.lock | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cb0e85c..b4e45db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ managed = true # version pins are in requirements-dev.lock dev-dependencies = [ "pyright==1.1.399", - "mypy", + "mypy==1.17", "respx", "pytest", "pytest-asyncio", diff --git a/requirements-dev.lock b/requirements-dev.lock index 22f7e57..c1841a3 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -72,7 +72,7 @@ mdurl==0.1.2 multidict==6.4.4 # via aiohttp # via yarl -mypy==1.14.1 +mypy==1.17.0 mypy-extensions==1.0.0 # via mypy nodeenv==1.8.0 @@ -81,6 +81,8 @@ nox==2023.4.22 packaging==23.2 # via nox # via pytest +pathspec==0.12.1 + # via mypy platformdirs==3.11.0 # via virtualenv pluggy==1.5.0 diff --git a/requirements.lock b/requirements.lock index afb8f46..ea29d30 100644 --- a/requirements.lock +++ b/requirements.lock @@ -55,21 +55,21 @@ multidict==6.4.4 propcache==0.3.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via beeper-desktop-api -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic sniffio==1.3.0 # via anyio # via beeper-desktop-api -typing-extensions==4.12.2 +typing-extensions==4.15.0 # via anyio # via beeper-desktop-api # via multidict # via pydantic # via pydantic-core # via typing-inspection -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via pydantic yarl==1.20.0 # via aiohttp From d564b6001dd7394accf35e7c4208af730e4fdbef Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 04:00:41 +0000 Subject: [PATCH 39/98] chore: update lockfile --- pyproject.toml | 14 +++--- requirements-dev.lock | 108 +++++++++++++++++++++++------------------- requirements.lock | 31 ++++++------ 3 files changed, 83 insertions(+), 70 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b4e45db..e8cf3e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,16 @@ license = "MIT" authors = [ { name = "Beeper Desktop", email = "help@beeper.com" }, ] + dependencies = [ - "httpx>=0.23.0, <1", - "pydantic>=1.9.0, <3", - "typing-extensions>=4.10, <5", - "anyio>=3.5.0, <5", - "distro>=1.7.0, <2", - "sniffio", + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.10, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", ] + requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", diff --git a/requirements-dev.lock b/requirements-dev.lock index c1841a3..2e7f1c5 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -12,40 +12,45 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via beeper-desktop-api # via httpx-aiohttp -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via beeper-desktop-api # via httpx -argcomplete==3.1.2 +argcomplete==3.6.3 # via nox async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 + # via nox +backports-asyncio-runner==1.2.0 + # via pytest-asyncio +certifi==2025.11.12 # via httpcore # via httpx -colorlog==6.7.0 +colorlog==6.10.1 + # via nox +dependency-groups==1.3.1 # via nox -dirty-equals==0.6.0 -distlib==0.3.7 +dirty-equals==0.11 +distlib==0.4.0 # via virtualenv -distro==1.8.0 +distro==1.9.0 # via beeper-desktop-api -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio # via pytest -execnet==2.1.1 +execnet==2.1.2 # via pytest-xdist -filelock==3.12.4 +filelock==3.19.1 # via virtualenv -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -58,82 +63,87 @@ httpx==0.28.1 # via respx httpx-aiohttp==0.1.9 # via beeper-desktop-api -idna==3.4 +humanize==4.13.0 + # via nox +idna==3.11 # via anyio # via httpx # via yarl -importlib-metadata==7.0.0 -iniconfig==2.0.0 +importlib-metadata==8.7.0 +iniconfig==2.1.0 # via pytest markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl mypy==1.17.0 -mypy-extensions==1.0.0 +mypy-extensions==1.1.0 # via mypy -nodeenv==1.8.0 +nodeenv==1.9.1 # via pyright -nox==2023.4.22 -packaging==23.2 +nox==2025.11.12 +packaging==25.0 + # via dependency-groups # via nox # via pytest pathspec==0.12.1 # via mypy -platformdirs==3.11.0 +platformdirs==4.4.0 # via virtualenv -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via beeper-desktop-api -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic -pygments==2.18.0 +pygments==2.19.2 + # via pytest # via rich pyright==1.1.399 -pytest==8.3.3 +pytest==8.4.2 # via pytest-asyncio # via pytest-xdist -pytest-asyncio==0.24.0 -pytest-xdist==3.7.0 -python-dateutil==2.8.2 +pytest-asyncio==1.2.0 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 # via time-machine -pytz==2023.3.post1 - # via dirty-equals respx==0.22.0 -rich==13.7.1 -ruff==0.9.4 -setuptools==68.2.2 - # via nodeenv -six==1.16.0 +rich==14.2.0 +ruff==0.14.7 +six==1.17.0 # via python-dateutil -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via beeper-desktop-api -time-machine==2.9.0 -tomli==2.0.2 +time-machine==2.19.0 +tomli==2.3.0 + # via dependency-groups # via mypy + # via nox # via pytest -typing-extensions==4.12.2 +typing-extensions==4.15.0 + # via aiosignal # via anyio # via beeper-desktop-api + # via exceptiongroup # via multidict # via mypy # via pydantic # via pydantic-core # via pyright + # via pytest-asyncio # via typing-inspection -typing-inspection==0.4.1 + # via virtualenv +typing-inspection==0.4.2 # via pydantic -virtualenv==20.24.5 +virtualenv==20.35.4 # via nox -yarl==1.20.0 +yarl==1.22.0 # via aiohttp -zipp==3.17.0 +zipp==3.23.0 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index ea29d30..9b061dd 100644 --- a/requirements.lock +++ b/requirements.lock @@ -12,28 +12,28 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via beeper-desktop-api # via httpx-aiohttp -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via beeper-desktop-api # via httpx async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 +certifi==2025.11.12 # via httpcore # via httpx -distro==1.8.0 +distro==1.9.0 # via beeper-desktop-api -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -45,31 +45,32 @@ httpx==0.28.1 # via httpx-aiohttp httpx-aiohttp==0.1.9 # via beeper-desktop-api -idna==3.4 +idna==3.11 # via anyio # via httpx # via yarl -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl pydantic==2.12.5 # via beeper-desktop-api pydantic-core==2.41.5 # via pydantic -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via beeper-desktop-api typing-extensions==4.15.0 + # via aiosignal # via anyio # via beeper-desktop-api + # via exceptiongroup # via multidict # via pydantic # via pydantic-core # via typing-inspection typing-inspection==0.4.2 # via pydantic -yarl==1.20.0 +yarl==1.22.0 # via aiohttp From 6792829c9655d6edcdb5e691328006ab43ddf732 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 04:11:39 +0000 Subject: [PATCH 40/98] chore(docs): use environment variables for authentication in code snippets --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9fe7bda..ea1795d 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ pip install beeper_desktop_api[aiohttp] 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 @@ -92,7 +93,9 @@ from beeper_desktop_api import AsyncBeeperDesktop async def main() -> None: async with AsyncBeeperDesktop( - access_token="My Access Token", + 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 1eebf461a554dea1a27a8ced5eed2770e50f1570 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 03:36:21 +0000 Subject: [PATCH 41/98] fix(types): allow pyright to infer TypedDict types within SequenceNotStr --- src/beeper_desktop_api/_types.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/beeper_desktop_api/_types.py b/src/beeper_desktop_api/_types.py index d3c2e24..b9af983 100644 --- a/src/beeper_desktop_api/_types.py +++ b/src/beeper_desktop_api/_types.py @@ -243,6 +243,9 @@ class HttpxSendArgs(TypedDict, total=False): if TYPE_CHECKING: # This works because str.__contains__ does not accept object (either in typeshed or at runtime) # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + # + # Note: index() and count() methods are intentionally omitted to allow pyright to properly + # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr. class SequenceNotStr(Protocol[_T_co]): @overload def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... @@ -251,8 +254,6 @@ def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... def __contains__(self, value: object, /) -> bool: ... def __len__(self) -> int: ... def __iter__(self) -> Iterator[_T_co]: ... - def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... - def count(self, value: Any, /) -> int: ... def __reversed__(self) -> Iterator[_T_co]: ... else: # just point this to a normal `Sequence` at runtime to avoid having to special case From 35c51875a943ca7255ae5bdf3066c6b7e179b612 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 03:38:08 +0000 Subject: [PATCH 42/98] chore: add missing docstrings --- src/beeper_desktop_api/types/account.py | 2 ++ src/beeper_desktop_api/types/chat.py | 2 ++ src/beeper_desktop_api/types/chats/reminder_create_params.py | 2 ++ src/beeper_desktop_api/types/focus_response.py | 2 ++ src/beeper_desktop_api/types/shared/attachment.py | 2 ++ src/beeper_desktop_api/types/shared/error.py | 2 ++ src/beeper_desktop_api/types/shared/user.py | 2 ++ 7 files changed, 14 insertions(+) diff --git a/src/beeper_desktop_api/types/account.py b/src/beeper_desktop_api/types/account.py index 97336b7..011c64e 100644 --- a/src/beeper_desktop_api/types/account.py +++ b/src/beeper_desktop_api/types/account.py @@ -9,6 +9,8 @@ class Account(BaseModel): + """A chat account added to Beeper""" + account_id: str = FieldInfo(alias="accountID") """Chat account added to Beeper. Use this to route account-scoped actions.""" diff --git a/src/beeper_desktop_api/types/chat.py b/src/beeper_desktop_api/types/chat.py index f386d4c..f340953 100644 --- a/src/beeper_desktop_api/types/chat.py +++ b/src/beeper_desktop_api/types/chat.py @@ -13,6 +13,8 @@ class Participants(BaseModel): + """Chat participants information.""" + has_more: bool = FieldInfo(alias="hasMore") """True if there are more participants than included in items.""" diff --git a/src/beeper_desktop_api/types/chats/reminder_create_params.py b/src/beeper_desktop_api/types/chats/reminder_create_params.py index 810263e..3a92906 100644 --- a/src/beeper_desktop_api/types/chats/reminder_create_params.py +++ b/src/beeper_desktop_api/types/chats/reminder_create_params.py @@ -15,6 +15,8 @@ class ReminderCreateParams(TypedDict, total=False): class Reminder(TypedDict, total=False): + """Reminder configuration""" + remind_at_ms: Required[Annotated[float, PropertyInfo(alias="remindAtMs")]] """Unix timestamp in milliseconds when reminder should trigger""" diff --git a/src/beeper_desktop_api/types/focus_response.py b/src/beeper_desktop_api/types/focus_response.py index 28875b1..c6a0262 100644 --- a/src/beeper_desktop_api/types/focus_response.py +++ b/src/beeper_desktop_api/types/focus_response.py @@ -6,5 +6,7 @@ class FocusResponse(BaseModel): + """Response indicating successful app focus action.""" + success: bool """Whether the app was successfully opened/focused.""" diff --git a/src/beeper_desktop_api/types/shared/attachment.py b/src/beeper_desktop_api/types/shared/attachment.py index 4964307..8049b84 100644 --- a/src/beeper_desktop_api/types/shared/attachment.py +++ b/src/beeper_desktop_api/types/shared/attachment.py @@ -11,6 +11,8 @@ class Size(BaseModel): + """Pixel dimensions of the attachment: width/height in px.""" + height: Optional[float] = None width: Optional[float] = None diff --git a/src/beeper_desktop_api/types/shared/error.py b/src/beeper_desktop_api/types/shared/error.py index 10b5f9c..df9fdbb 100644 --- a/src/beeper_desktop_api/types/shared/error.py +++ b/src/beeper_desktop_api/types/shared/error.py @@ -20,6 +20,8 @@ class DetailsIssuesIssue(BaseModel): class DetailsIssues(BaseModel): + """Validation error details""" + issues: List[DetailsIssuesIssue] """List of validation issues""" diff --git a/src/beeper_desktop_api/types/shared/user.py b/src/beeper_desktop_api/types/shared/user.py index c05827b..d990c5f 100644 --- a/src/beeper_desktop_api/types/shared/user.py +++ b/src/beeper_desktop_api/types/shared/user.py @@ -10,6 +10,8 @@ class User(BaseModel): + """User the account belongs to.""" + id: str """Stable Beeper user ID. Use as the primary key when referencing a person.""" From a013149a3cdd241ce5ceb339ba753a0fec86cfad Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 03:30:08 +0000 Subject: [PATCH 43/98] chore(internal): add missing files argument to base client --- src/beeper_desktop_api/_base_client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/beeper_desktop_api/_base_client.py b/src/beeper_desktop_api/_base_client.py index 16539a1..e111a81 100644 --- a/src/beeper_desktop_api/_base_client.py +++ b/src/beeper_desktop_api/_base_client.py @@ -1247,9 +1247,12 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + ) return self.request(cast_to, opts) def put( @@ -1767,9 +1770,12 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + ) return await self.request(cast_to, opts) async def put( From 95ae505136c8f76c5268ad0d1376289387970f18 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 03:58:38 +0000 Subject: [PATCH 44/98] chore: speedup initial import --- src/beeper_desktop_api/_client.py | 249 ++++++++++++++++++++++++------ 1 file changed, 203 insertions(+), 46 deletions(-) diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index b5d0200..a7ca185 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, Mapping +from typing import TYPE_CHECKING, Any, Mapping from typing_extensions import Self, override import httpx @@ -30,6 +30,7 @@ get_async_library, async_maybe_transform, ) +from ._compat import cached_property from ._version import __version__ from ._response import ( to_raw_response_wrapper, @@ -37,7 +38,6 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from .resources import assets, messages from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError, BeeperDesktopError from ._base_client import ( @@ -46,11 +46,16 @@ AsyncAPIClient, make_request_options, ) -from .resources.chats import chats -from .resources.accounts import accounts from .types.focus_response import FocusResponse from .types.search_response import SearchResponse +if TYPE_CHECKING: + from .resources import chats, assets, accounts, messages + from .resources.assets import AssetsResource, AsyncAssetsResource + from .resources.messages import MessagesResource, AsyncMessagesResource + from .resources.chats.chats import ChatsResource, AsyncChatsResource + from .resources.accounts.accounts import AccountsResource, AsyncAccountsResource + __all__ = [ "Timeout", "Transport", @@ -64,13 +69,6 @@ class BeeperDesktop(SyncAPIClient): - accounts: accounts.AccountsResource - chats: chats.ChatsResource - messages: messages.MessagesResource - assets: assets.AssetsResource - with_raw_response: BeeperDesktopWithRawResponse - with_streaming_response: BeeperDesktopWithStreamedResponse - # client options access_token: str @@ -125,12 +123,41 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.accounts = accounts.AccountsResource(self) - self.chats = chats.ChatsResource(self) - self.messages = messages.MessagesResource(self) - self.assets = assets.AssetsResource(self) - self.with_raw_response = BeeperDesktopWithRawResponse(self) - self.with_streaming_response = BeeperDesktopWithStreamedResponse(self) + @cached_property + def accounts(self) -> AccountsResource: + """Manage connected chat accounts""" + from .resources.accounts import AccountsResource + + return AccountsResource(self) + + @cached_property + def chats(self) -> ChatsResource: + """Manage chats""" + from .resources.chats import ChatsResource + + return ChatsResource(self) + + @cached_property + def messages(self) -> MessagesResource: + """Manage messages in chats""" + from .resources.messages import MessagesResource + + return MessagesResource(self) + + @cached_property + def assets(self) -> AssetsResource: + """Manage assets in Beeper Desktop, like message attachments""" + from .resources.assets import AssetsResource + + return AssetsResource(self) + + @cached_property + def with_raw_response(self) -> BeeperDesktopWithRawResponse: + return BeeperDesktopWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> BeeperDesktopWithStreamedResponse: + return BeeperDesktopWithStreamedResponse(self) @property @override @@ -330,13 +357,6 @@ def _make_status_error( class AsyncBeeperDesktop(AsyncAPIClient): - accounts: accounts.AsyncAccountsResource - chats: chats.AsyncChatsResource - messages: messages.AsyncMessagesResource - assets: assets.AsyncAssetsResource - with_raw_response: AsyncBeeperDesktopWithRawResponse - with_streaming_response: AsyncBeeperDesktopWithStreamedResponse - # client options access_token: str @@ -391,12 +411,41 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.accounts = accounts.AsyncAccountsResource(self) - self.chats = chats.AsyncChatsResource(self) - self.messages = messages.AsyncMessagesResource(self) - self.assets = assets.AsyncAssetsResource(self) - self.with_raw_response = AsyncBeeperDesktopWithRawResponse(self) - self.with_streaming_response = AsyncBeeperDesktopWithStreamedResponse(self) + @cached_property + def accounts(self) -> AsyncAccountsResource: + """Manage connected chat accounts""" + from .resources.accounts import AsyncAccountsResource + + return AsyncAccountsResource(self) + + @cached_property + def chats(self) -> AsyncChatsResource: + """Manage chats""" + from .resources.chats import AsyncChatsResource + + return AsyncChatsResource(self) + + @cached_property + def messages(self) -> AsyncMessagesResource: + """Manage messages in chats""" + from .resources.messages import AsyncMessagesResource + + return AsyncMessagesResource(self) + + @cached_property + def assets(self) -> AsyncAssetsResource: + """Manage assets in Beeper Desktop, like message attachments""" + from .resources.assets import AsyncAssetsResource + + return AsyncAssetsResource(self) + + @cached_property + def with_raw_response(self) -> AsyncBeeperDesktopWithRawResponse: + return AsyncBeeperDesktopWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncBeeperDesktopWithStreamedResponse: + return AsyncBeeperDesktopWithStreamedResponse(self) @property @override @@ -596,11 +645,10 @@ def _make_status_error( class BeeperDesktopWithRawResponse: + _client: BeeperDesktop + def __init__(self, client: BeeperDesktop) -> None: - self.accounts = accounts.AccountsResourceWithRawResponse(client.accounts) - self.chats = chats.ChatsResourceWithRawResponse(client.chats) - self.messages = messages.MessagesResourceWithRawResponse(client.messages) - self.assets = assets.AssetsResourceWithRawResponse(client.assets) + self._client = client self.focus = to_raw_response_wrapper( client.focus, @@ -609,13 +657,40 @@ def __init__(self, client: BeeperDesktop) -> None: client.search, ) + @cached_property + def accounts(self) -> accounts.AccountsResourceWithRawResponse: + """Manage connected chat accounts""" + from .resources.accounts import AccountsResourceWithRawResponse + + return AccountsResourceWithRawResponse(self._client.accounts) + + @cached_property + def chats(self) -> chats.ChatsResourceWithRawResponse: + """Manage chats""" + from .resources.chats import ChatsResourceWithRawResponse + + return ChatsResourceWithRawResponse(self._client.chats) + + @cached_property + def messages(self) -> messages.MessagesResourceWithRawResponse: + """Manage messages in chats""" + from .resources.messages import MessagesResourceWithRawResponse + + return MessagesResourceWithRawResponse(self._client.messages) + + @cached_property + def assets(self) -> assets.AssetsResourceWithRawResponse: + """Manage assets in Beeper Desktop, like message attachments""" + from .resources.assets import AssetsResourceWithRawResponse + + return AssetsResourceWithRawResponse(self._client.assets) + class AsyncBeeperDesktopWithRawResponse: + _client: AsyncBeeperDesktop + def __init__(self, client: AsyncBeeperDesktop) -> None: - self.accounts = accounts.AsyncAccountsResourceWithRawResponse(client.accounts) - self.chats = chats.AsyncChatsResourceWithRawResponse(client.chats) - self.messages = messages.AsyncMessagesResourceWithRawResponse(client.messages) - self.assets = assets.AsyncAssetsResourceWithRawResponse(client.assets) + self._client = client self.focus = async_to_raw_response_wrapper( client.focus, @@ -624,13 +699,40 @@ def __init__(self, client: AsyncBeeperDesktop) -> None: client.search, ) + @cached_property + def accounts(self) -> accounts.AsyncAccountsResourceWithRawResponse: + """Manage connected chat accounts""" + from .resources.accounts import AsyncAccountsResourceWithRawResponse + + return AsyncAccountsResourceWithRawResponse(self._client.accounts) + + @cached_property + def chats(self) -> chats.AsyncChatsResourceWithRawResponse: + """Manage chats""" + from .resources.chats import AsyncChatsResourceWithRawResponse + + return AsyncChatsResourceWithRawResponse(self._client.chats) + + @cached_property + def messages(self) -> messages.AsyncMessagesResourceWithRawResponse: + """Manage messages in chats""" + from .resources.messages import AsyncMessagesResourceWithRawResponse + + return AsyncMessagesResourceWithRawResponse(self._client.messages) + + @cached_property + def assets(self) -> assets.AsyncAssetsResourceWithRawResponse: + """Manage assets in Beeper Desktop, like message attachments""" + from .resources.assets import AsyncAssetsResourceWithRawResponse + + return AsyncAssetsResourceWithRawResponse(self._client.assets) + class BeeperDesktopWithStreamedResponse: + _client: BeeperDesktop + def __init__(self, client: BeeperDesktop) -> None: - self.accounts = accounts.AccountsResourceWithStreamingResponse(client.accounts) - self.chats = chats.ChatsResourceWithStreamingResponse(client.chats) - self.messages = messages.MessagesResourceWithStreamingResponse(client.messages) - self.assets = assets.AssetsResourceWithStreamingResponse(client.assets) + self._client = client self.focus = to_streamed_response_wrapper( client.focus, @@ -639,13 +741,40 @@ def __init__(self, client: BeeperDesktop) -> None: client.search, ) + @cached_property + def accounts(self) -> accounts.AccountsResourceWithStreamingResponse: + """Manage connected chat accounts""" + from .resources.accounts import AccountsResourceWithStreamingResponse + + return AccountsResourceWithStreamingResponse(self._client.accounts) + + @cached_property + def chats(self) -> chats.ChatsResourceWithStreamingResponse: + """Manage chats""" + from .resources.chats import ChatsResourceWithStreamingResponse + + return ChatsResourceWithStreamingResponse(self._client.chats) + + @cached_property + def messages(self) -> messages.MessagesResourceWithStreamingResponse: + """Manage messages in chats""" + from .resources.messages import MessagesResourceWithStreamingResponse + + return MessagesResourceWithStreamingResponse(self._client.messages) + + @cached_property + def assets(self) -> assets.AssetsResourceWithStreamingResponse: + """Manage assets in Beeper Desktop, like message attachments""" + from .resources.assets import AssetsResourceWithStreamingResponse + + return AssetsResourceWithStreamingResponse(self._client.assets) + class AsyncBeeperDesktopWithStreamedResponse: + _client: AsyncBeeperDesktop + def __init__(self, client: AsyncBeeperDesktop) -> None: - self.accounts = accounts.AsyncAccountsResourceWithStreamingResponse(client.accounts) - self.chats = chats.AsyncChatsResourceWithStreamingResponse(client.chats) - self.messages = messages.AsyncMessagesResourceWithStreamingResponse(client.messages) - self.assets = assets.AsyncAssetsResourceWithStreamingResponse(client.assets) + self._client = client self.focus = async_to_streamed_response_wrapper( client.focus, @@ -654,6 +783,34 @@ def __init__(self, client: AsyncBeeperDesktop) -> None: client.search, ) + @cached_property + def accounts(self) -> accounts.AsyncAccountsResourceWithStreamingResponse: + """Manage connected chat accounts""" + from .resources.accounts import AsyncAccountsResourceWithStreamingResponse + + return AsyncAccountsResourceWithStreamingResponse(self._client.accounts) + + @cached_property + def chats(self) -> chats.AsyncChatsResourceWithStreamingResponse: + """Manage chats""" + from .resources.chats import AsyncChatsResourceWithStreamingResponse + + return AsyncChatsResourceWithStreamingResponse(self._client.chats) + + @cached_property + def messages(self) -> messages.AsyncMessagesResourceWithStreamingResponse: + """Manage messages in chats""" + from .resources.messages import AsyncMessagesResourceWithStreamingResponse + + return AsyncMessagesResourceWithStreamingResponse(self._client.messages) + + @cached_property + def assets(self) -> assets.AsyncAssetsResourceWithStreamingResponse: + """Manage assets in Beeper Desktop, like message attachments""" + from .resources.assets import AsyncAssetsResourceWithStreamingResponse + + return AsyncAssetsResourceWithStreamingResponse(self._client.assets) + Client = BeeperDesktop From db9bdb3f96d6c4691b35317cf6f5bf579d1793b0 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 04:15:45 +0000 Subject: [PATCH 45/98] fix: use async_to_httpx_files in patch method --- src/beeper_desktop_api/_base_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/beeper_desktop_api/_base_client.py b/src/beeper_desktop_api/_base_client.py index e111a81..4ff9096 100644 --- a/src/beeper_desktop_api/_base_client.py +++ b/src/beeper_desktop_api/_base_client.py @@ -1774,7 +1774,7 @@ async def patch( options: RequestOptions = {}, ) -> ResponseT: opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + method="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts) From 91d29ba730bce942a4f27686b955a971c7d25644 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 04:00:27 +0000 Subject: [PATCH 46/98] chore(internal): add `--fix` argument to lint script --- scripts/lint | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/lint b/scripts/lint index 8d20626..ca0f8cf 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,8 +4,13 @@ set -e cd "$(dirname "$0")/.." -echo "==> Running lints" -rye run lint +if [ "$1" = "--fix" ]; then + echo "==> Running lints with --fix" + rye run fix:ruff +else + echo "==> Running lints" + rye run lint +fi echo "==> Making sure it imports" rye run python -c 'import beeper_desktop_api' From 835b0e78a8188810abd20427efdecb992b6b8c4c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 03:44:51 +0000 Subject: [PATCH 47/98] chore(internal): codegen related update --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 59424c8..76a908d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2025 beeperdesktop +Copyright 2026 beeperdesktop Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: From 87d0781d7f2356a684c5100637735aaed9d333b7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 03:54:41 +0000 Subject: [PATCH 48/98] docs: prominently feature MCP server setup in root SDK readmes --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index ea1795d..b143ef5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,15 @@ 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). +## 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-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1tY3AiXX0) +[![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%7D) + +> Note: You may need to set environment variables in your MCP client. + ## Documentation The REST API documentation can be found on [developers.beeper.com](https://developers.beeper.com/desktop-api/). The full API of this library can be found in [api.md](api.md). From e0b6f4139244c05f609bb6e51811dff495c2508c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 05:08:10 +0000 Subject: [PATCH 49/98] feat(client): add support for binary request streaming --- src/beeper_desktop_api/_base_client.py | 145 +++++++++++++++++-- src/beeper_desktop_api/_models.py | 17 ++- src/beeper_desktop_api/_types.py | 9 ++ tests/test_client.py | 187 ++++++++++++++++++++++++- 4 files changed, 344 insertions(+), 14 deletions(-) diff --git a/src/beeper_desktop_api/_base_client.py b/src/beeper_desktop_api/_base_client.py index 4ff9096..19b20e8 100644 --- a/src/beeper_desktop_api/_base_client.py +++ b/src/beeper_desktop_api/_base_client.py @@ -9,6 +9,7 @@ import inspect import logging import platform +import warnings import email.utils from types import TracebackType from random import random @@ -51,9 +52,11 @@ ResponseT, AnyMapping, PostParser, + BinaryTypes, RequestFiles, HttpxSendArgs, RequestOptions, + AsyncBinaryTypes, HttpxRequestFiles, ModelBuilderProtocol, not_given, @@ -477,8 +480,19 @@ def _build_request( retries_taken: int = 0, ) -> httpx.Request: if log.isEnabledFor(logging.DEBUG): - log.debug("Request options: %s", model_dump(options, exclude_unset=True)) - + log.debug( + "Request options: %s", + model_dump( + options, + exclude_unset=True, + # Pydantic v1 can't dump every type we support in content, so we exclude it for now. + exclude={ + "content", + } + if PYDANTIC_V1 + else {}, + ), + ) kwargs: dict[str, Any] = {} json_data = options.json_data @@ -532,7 +546,13 @@ def _build_request( is_body_allowed = options.method.lower() != "get" if is_body_allowed: - if isinstance(json_data, bytes): + if options.content is not None and json_data is not None: + raise TypeError("Passing both `content` and `json_data` is not supported") + if options.content is not None and files is not None: + raise TypeError("Passing both `content` and `files` is not supported") + if options.content is not None: + kwargs["content"] = options.content + elif isinstance(json_data, bytes): kwargs["content"] = json_data else: kwargs["json"] = json_data if is_given(json_data) else None @@ -1194,6 +1214,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[False] = False, @@ -1206,6 +1227,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[True], @@ -1219,6 +1241,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool, @@ -1231,13 +1254,25 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool = False, stream_cls: type[_StreamT] | None = None, ) -> ResponseT | _StreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) @@ -1247,11 +1282,23 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1261,11 +1308,23 @@ def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1275,9 +1334,19 @@ def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return self.request(cast_to, opts) def get_api_list( @@ -1717,6 +1786,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[False] = False, @@ -1729,6 +1799,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[True], @@ -1742,6 +1813,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool, @@ -1754,13 +1826,25 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool = False, stream_cls: type[_AsyncStreamT] | None = None, ) -> ResponseT | _AsyncStreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) @@ -1770,11 +1854,28 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="patch", + url=path, + json_data=body, + content=content, + files=await async_to_httpx_files(files), + **options, ) return await self.request(cast_to, opts) @@ -1784,11 +1885,23 @@ async def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts) @@ -1798,9 +1911,19 @@ async def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return await self.request(cast_to, opts) def get_api_list( diff --git a/src/beeper_desktop_api/_models.py b/src/beeper_desktop_api/_models.py index ca9500b..29070e0 100644 --- a/src/beeper_desktop_api/_models.py +++ b/src/beeper_desktop_api/_models.py @@ -3,7 +3,20 @@ import os import inspect import weakref -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast +from typing import ( + IO, + TYPE_CHECKING, + Any, + Type, + Union, + Generic, + TypeVar, + Callable, + Iterable, + Optional, + AsyncIterable, + cast, +) from datetime import date, datetime from typing_extensions import ( List, @@ -787,6 +800,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): timeout: float | Timeout | None files: HttpxRequestFiles | None idempotency_key: str + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] json_data: Body extra_json: AnyMapping follow_redirects: bool @@ -805,6 +819,7 @@ class FinalRequestOptions(pydantic.BaseModel): post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() follow_redirects: Union[bool, None] = None + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. json_data: Union[Body, None] = None diff --git a/src/beeper_desktop_api/_types.py b/src/beeper_desktop_api/_types.py index b9af983..2880d78 100644 --- a/src/beeper_desktop_api/_types.py +++ b/src/beeper_desktop_api/_types.py @@ -13,9 +13,11 @@ Mapping, TypeVar, Callable, + Iterable, Iterator, Optional, Sequence, + AsyncIterable, ) from typing_extensions import ( Set, @@ -56,6 +58,13 @@ else: Base64FileInput = Union[IO[bytes], PathLike] FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. + + +# Used for sending raw binary data / streaming data in request bodies +# e.g. for file uploads without multipart encoding +BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]] +AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]] + FileTypes = Union[ # file (or bytes) FileContent, diff --git a/tests/test_client.py b/tests/test_client.py index a1f0951..5cbca10 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,10 +8,11 @@ import json import asyncio import inspect +import dataclasses import tracemalloc -from typing import Any, Union, cast +from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast from unittest import mock -from typing_extensions import Literal +from typing_extensions import Literal, AsyncIterator, override import httpx import pytest @@ -41,6 +42,7 @@ from .utils import update_env +T = TypeVar("T") base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") access_token = "My Access Token" @@ -55,6 +57,57 @@ def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: return 0.1 +def mirror_request_content(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=request.content) + + +# note: we can't use the httpx.MockTransport class as it consumes the request +# body itself, which means we can't test that the body is read lazily +class MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): + def __init__( + self, + handler: Callable[[httpx.Request], httpx.Response] + | Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]], + ) -> None: + self.handler = handler + + @override + def handle_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert not inspect.iscoroutinefunction(self.handler), "handler must not be a coroutine function" + assert inspect.isfunction(self.handler), "handler must be a function" + return self.handler(request) + + @override + async def handle_async_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert inspect.iscoroutinefunction(self.handler), "handler must be a coroutine function" + return await self.handler(request) + + +@dataclasses.dataclass +class Counter: + value: int = 0 + + +def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + +async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> AsyncIterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + def _get_open_connections(client: BeeperDesktop | AsyncBeeperDesktop) -> int: transport = client._client._transport assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) @@ -516,6 +569,70 @@ def test_multipart_repeating_array(self, client: BeeperDesktop) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + def test_binary_content_upload_with_iterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_sync_iterator([file_content], counter=counter) + + def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=request.read()) + + with BeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + http_client=httpx.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + response = client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload_with_body_is_deprecated(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) def test_basic_union_response(self, respx_mock: MockRouter, client: BeeperDesktop) -> None: class Model1(BaseModel): @@ -1367,6 +1484,72 @@ def test_multipart_repeating_array(self, async_client: AsyncBeeperDesktop) -> No b"", ] + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = await async_client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + async def test_binary_content_upload_with_asynciterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_async_iterator([file_content], counter=counter) + + async def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=await request.aread()) + + async with AsyncBeeperDesktop( + base_url=base_url, + access_token=access_token, + _strict_response_validation=True, + http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + response = await client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload_with_body_is_deprecated( + self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop + ) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = await async_client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop) -> None: class Model1(BaseModel): From 4ac2aec0dc4d974c6ae7969ab2cbe11438c06363 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 04:26:17 +0000 Subject: [PATCH 50/98] chore(internal): update `actions/checkout` version --- .github/workflows/ci.yml | 6 +++--- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/release-doctor.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb758a6..825e98a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: 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 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -44,7 +44,7 @@ jobs: id-token: write runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -81,7 +81,7 @@ jobs: 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 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index c22364e..08d08f6 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index c6b3e44..4bccf2f 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'beeper/desktop-api-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check release environment run: | From 9d1bdbdc4cc97e316f93a515c7fcce7767c54bcb Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 06:33:30 +0000 Subject: [PATCH 51/98] 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 adde7c3..6ed1432 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-bea2e5f3b01053a66261a824c75c2640856d0ba00ad795ab71734c4ab9ae33b0.yml openapi_spec_hash: d766f6e344c12ca6d23e6ef6713b38c4 -config_hash: 5fa7ded4bfdffe4cc1944a819da87f9f +config_hash: c1e9e27b4965dd90c14c8e88c9036be4 diff --git a/README.md b/README.md index b143ef5..b884bb0 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=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1tY3AiXX0) -[![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%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=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1hcGktbWNwIl19) +[![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%7D) > Note: You may need to set environment variables in your MCP client. From f2ba049acefcce338617edeaac456ca88c497c2b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 07:28:34 +0000 Subject: [PATCH 52/98] feat(api): add upload asset and edit message endpoints --- .stats.yml | 8 +- api.md | 6 +- src/beeper_desktop_api/_client.py | 4 +- .../resources/accounts/contacts.py | 14 +- src/beeper_desktop_api/resources/assets.py | 135 +++++++++++++++++- src/beeper_desktop_api/resources/messages.py | 119 ++++++++++++++- src/beeper_desktop_api/types/__init__.py | 4 + .../types/asset_upload_params.py | 20 +++ .../types/asset_upload_response.py | 38 +++++ src/beeper_desktop_api/types/chat.py | 13 +- .../types/client_search_params.py | 2 +- .../types/message_search_params.py | 2 +- .../types/message_send_params.py | 40 +++++- .../types/message_update_params.py | 17 +++ .../types/message_update_response.py | 18 +++ .../types/shared/attachment.py | 6 + .../types/shared/message.py | 13 ++ tests/api_resources/test_assets.py | 85 ++++++++++- tests/api_resources/test_chats.py | 2 - tests/api_resources/test_messages.py | 131 ++++++++++++++++- 20 files changed, 638 insertions(+), 39 deletions(-) create mode 100644 src/beeper_desktop_api/types/asset_upload_params.py create mode 100644 src/beeper_desktop_api/types/asset_upload_response.py create mode 100644 src/beeper_desktop_api/types/message_update_params.py create mode 100644 src/beeper_desktop_api/types/message_update_response.py diff --git a/.stats.yml b/.stats.yml index 6ed1432..1646e75 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 15 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-bea2e5f3b01053a66261a824c75c2640856d0ba00ad795ab71734c4ab9ae33b0.yml -openapi_spec_hash: d766f6e344c12ca6d23e6ef6713b38c4 -config_hash: c1e9e27b4965dd90c14c8e88c9036be4 +configured_endpoints: 17 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-0e4d333e81e670e605a6706dbb365bc96957a59331fdc87dd1550c59880cb130.yml +openapi_spec_hash: 564146e6d318ecb486ff7c0d7983b398 +config_hash: e3418e22e2ca1df0f8a9fcaf7153285a diff --git a/api.md b/api.md index 48bc9b9..dfa1b30 100644 --- a/api.md +++ b/api.md @@ -69,11 +69,12 @@ Methods: Types: ```python -from beeper_desktop_api.types import MessageSendResponse +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.search(\*\*params) -> SyncCursorSearch[Message] - client.messages.send(chat_id, \*\*params) -> MessageSendResponse @@ -83,9 +84,10 @@ Methods: Types: ```python -from beeper_desktop_api.types import AssetDownloadResponse +from beeper_desktop_api.types import AssetDownloadResponse, AssetUploadResponse ``` Methods: - client.assets.download(\*\*params) -> AssetDownloadResponse +- client.assets.upload(\*\*params) -> AssetUploadResponse diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index a7ca185..7d0cb94 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -300,7 +300,7 @@ def search( via search-chats. Uses the same sorting as the chat search in the app. Args: - query: User-typed search text. Literal word matching (NOT semantic). + query: User-typed search text. Literal word matching (non-semantic). extra_headers: Send extra headers @@ -588,7 +588,7 @@ async def search( via search-chats. Uses the same sorting as the chat search in the app. Args: - query: User-typed search text. Literal word matching (NOT semantic). + query: User-typed search text. Literal word matching (non-semantic). extra_headers: Send extra headers diff --git a/src/beeper_desktop_api/resources/accounts/contacts.py b/src/beeper_desktop_api/resources/accounts/contacts.py index b2a6491..5719d18 100644 --- a/src/beeper_desktop_api/resources/accounts/contacts.py +++ b/src/beeper_desktop_api/resources/accounts/contacts.py @@ -55,9 +55,10 @@ def search( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ContactSearchResponse: - """ - Search contacts across on a specific account using the network's search API. - Only use for creating new chats. + """Search contacts on a specific account using the network's search API. + + Only use + for creating new chats. Args: account_id: Account ID this resource belongs to. @@ -121,9 +122,10 @@ async def search( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ContactSearchResponse: - """ - Search contacts across on a specific account using the network's search API. - Only use for creating new chats. + """Search contacts on a specific account using the network's search API. + + Only use + for creating new chats. Args: account_id: Account ID this resource belongs to. diff --git a/src/beeper_desktop_api/resources/assets.py b/src/beeper_desktop_api/resources/assets.py index 0d3a790..7e9973d 100644 --- a/src/beeper_desktop_api/resources/assets.py +++ b/src/beeper_desktop_api/resources/assets.py @@ -2,11 +2,13 @@ from __future__ import annotations +from typing import Mapping, cast + import httpx -from ..types import asset_download_params -from .._types import Body, Query, Headers, NotGiven, not_given -from .._utils import maybe_transform, async_maybe_transform +from ..types import asset_upload_params, asset_download_params +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -16,6 +18,7 @@ async_to_streamed_response_wrapper, ) from .._base_client import make_request_options +from ..types.asset_upload_response import AssetUploadResponse from ..types.asset_download_response import AssetDownloadResponse __all__ = ["AssetsResource", "AsyncAssetsResource"] @@ -78,6 +81,63 @@ def download( cast_to=AssetDownloadResponse, ) + def upload( + self, + *, + content: str, + file_name: str | Omit = omit, + mime_type: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AssetUploadResponse: + """Upload a file to a temporary location. + + Supports JSON body with base64 `content` + field, or multipart/form-data with `file` field. Returns a local file URL that + can be used when sending messages with attachments. + + Args: + content: Base64-encoded file content (max ~500MB decoded) + + file_name: Original filename. Generated if omitted + + mime_type: MIME type. Auto-detected from magic bytes if omitted + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + body = deepcopy_minimal( + { + "content": content, + "file_name": file_name, + "mime_type": mime_type, + } + ) + files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) + if files: + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + return self._post( + "/v1/assets/upload", + body=maybe_transform(body, asset_upload_params.AssetUploadParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AssetUploadResponse, + ) + class AsyncAssetsResource(AsyncAPIResource): """Manage assets in Beeper Desktop, like message attachments""" @@ -136,6 +196,63 @@ async def download( cast_to=AssetDownloadResponse, ) + async def upload( + self, + *, + content: str, + file_name: str | Omit = omit, + mime_type: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AssetUploadResponse: + """Upload a file to a temporary location. + + Supports JSON body with base64 `content` + field, or multipart/form-data with `file` field. Returns a local file URL that + can be used when sending messages with attachments. + + Args: + content: Base64-encoded file content (max ~500MB decoded) + + file_name: Original filename. Generated if omitted + + mime_type: MIME type. Auto-detected from magic bytes if omitted + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + body = deepcopy_minimal( + { + "content": content, + "file_name": file_name, + "mime_type": mime_type, + } + ) + files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) + if files: + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + return await self._post( + "/v1/assets/upload", + body=await async_maybe_transform(body, asset_upload_params.AssetUploadParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AssetUploadResponse, + ) + class AssetsResourceWithRawResponse: def __init__(self, assets: AssetsResource) -> None: @@ -144,6 +261,9 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_raw_response_wrapper( assets.download, ) + self.upload = to_raw_response_wrapper( + assets.upload, + ) class AsyncAssetsResourceWithRawResponse: @@ -153,6 +273,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_raw_response_wrapper( assets.download, ) + self.upload = async_to_raw_response_wrapper( + assets.upload, + ) class AssetsResourceWithStreamingResponse: @@ -162,6 +285,9 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_streamed_response_wrapper( assets.download, ) + self.upload = to_streamed_response_wrapper( + assets.upload, + ) class AsyncAssetsResourceWithStreamingResponse: @@ -171,3 +297,6 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_streamed_response_wrapper( assets.download, ) + self.upload = async_to_streamed_response_wrapper( + assets.upload, + ) diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index 4f384f6..63ad8e1 100644 --- a/src/beeper_desktop_api/resources/messages.py +++ b/src/beeper_desktop_api/resources/messages.py @@ -8,7 +8,7 @@ import httpx -from ..types import message_list_params, message_send_params, message_search_params +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 .._compat import cached_property @@ -23,6 +23,7 @@ from .._base_client import AsyncPaginator, make_request_options from ..types.shared.message import Message from ..types.message_send_response import MessageSendResponse +from ..types.message_update_response import MessageUpdateResponse __all__ = ["MessagesResource", "AsyncMessagesResource"] @@ -49,6 +50,50 @@ def with_streaming_response(self) -> MessagesResourceWithStreamingResponse: """ return MessagesResourceWithStreamingResponse(self) + def update( + self, + message_id: str, + *, + chat_id: str, + text: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> MessageUpdateResponse: + """Edit the text content of an existing message. + + Messages with attachments cannot + be edited. + + Args: + chat_id: Unique identifier of the chat. + + text: New text content for the message + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + 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}", + 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 + ), + cast_to=MessageUpdateResponse, + ) + def list( self, chat_id: str, @@ -158,7 +203,7 @@ def search( media_types: Filter messages by media types. Use ['any'] for any media type, or specify exact types like ['video', 'image']. Omit for no media filtering. - query: Literal word search (NOT semantic). Finds messages containing these EXACT words + query: Literal word search (non-semantic). Finds messages containing these EXACT words in any order. Use single words users actually type, not concepts or phrases. Example: use "dinner" not "dinner plans", use "sick" not "health issues". If omitted, returns results filtered only by other parameters. @@ -208,6 +253,7 @@ def send( self, chat_id: str, *, + attachment: message_send_params.Attachment | Omit = omit, reply_to_message_id: str | Omit = omit, text: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -220,11 +266,13 @@ def send( """Send a text message to a specific chat. Supports replying to existing messages. - Returns the sent message ID. + Returns a pending message ID. Args: chat_id: Unique identifier of the chat. + attachment: Single attachment to send with the message + reply_to_message_id: Provide a message ID to send this as a reply to an existing message text: Text content of the message you want to send. You may use markdown. @@ -243,6 +291,7 @@ def send( f"/v1/chats/{chat_id}/messages", body=maybe_transform( { + "attachment": attachment, "reply_to_message_id": reply_to_message_id, "text": text, }, @@ -277,6 +326,50 @@ def with_streaming_response(self) -> AsyncMessagesResourceWithStreamingResponse: """ return AsyncMessagesResourceWithStreamingResponse(self) + async def update( + self, + message_id: str, + *, + chat_id: str, + text: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> MessageUpdateResponse: + """Edit the text content of an existing message. + + Messages with attachments cannot + be edited. + + Args: + chat_id: Unique identifier of the chat. + + text: New text content for the message + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + 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}", + 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 + ), + cast_to=MessageUpdateResponse, + ) + def list( self, chat_id: str, @@ -386,7 +479,7 @@ def search( media_types: Filter messages by media types. Use ['any'] for any media type, or specify exact types like ['video', 'image']. Omit for no media filtering. - query: Literal word search (NOT semantic). Finds messages containing these EXACT words + query: Literal word search (non-semantic). Finds messages containing these EXACT words in any order. Use single words users actually type, not concepts or phrases. Example: use "dinner" not "dinner plans", use "sick" not "health issues". If omitted, returns results filtered only by other parameters. @@ -436,6 +529,7 @@ async def send( self, chat_id: str, *, + attachment: message_send_params.Attachment | Omit = omit, reply_to_message_id: str | Omit = omit, text: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -448,11 +542,13 @@ async def send( """Send a text message to a specific chat. Supports replying to existing messages. - Returns the sent message ID. + Returns a pending message ID. Args: chat_id: Unique identifier of the chat. + attachment: Single attachment to send with the message + reply_to_message_id: Provide a message ID to send this as a reply to an existing message text: Text content of the message you want to send. You may use markdown. @@ -471,6 +567,7 @@ async def send( f"/v1/chats/{chat_id}/messages", body=await async_maybe_transform( { + "attachment": attachment, "reply_to_message_id": reply_to_message_id, "text": text, }, @@ -487,6 +584,9 @@ class MessagesResourceWithRawResponse: def __init__(self, messages: MessagesResource) -> None: self._messages = messages + self.update = to_raw_response_wrapper( + messages.update, + ) self.list = to_raw_response_wrapper( messages.list, ) @@ -502,6 +602,9 @@ class AsyncMessagesResourceWithRawResponse: def __init__(self, messages: AsyncMessagesResource) -> None: self._messages = messages + self.update = async_to_raw_response_wrapper( + messages.update, + ) self.list = async_to_raw_response_wrapper( messages.list, ) @@ -517,6 +620,9 @@ class MessagesResourceWithStreamingResponse: def __init__(self, messages: MessagesResource) -> None: self._messages = messages + self.update = to_streamed_response_wrapper( + messages.update, + ) self.list = to_streamed_response_wrapper( messages.list, ) @@ -532,6 +638,9 @@ class AsyncMessagesResourceWithStreamingResponse: def __init__(self, messages: AsyncMessagesResource) -> None: self._messages = messages + self.update = async_to_streamed_response_wrapper( + messages.update, + ) self.list = async_to_streamed_response_wrapper( messages.list, ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index 1d77bdb..93aa4b5 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -11,6 +11,7 @@ from .chat_create_params import ChatCreateParams as ChatCreateParams from .chat_list_response import ChatListResponse as ChatListResponse from .chat_search_params import ChatSearchParams as ChatSearchParams +from .asset_upload_params import AssetUploadParams as AssetUploadParams from .chat_archive_params import ChatArchiveParams as ChatArchiveParams from .client_focus_params import ClientFocusParams as ClientFocusParams from .message_list_params import MessageListParams as MessageListParams @@ -20,6 +21,9 @@ from .client_search_params import ClientSearchParams as ClientSearchParams from .account_list_response import AccountListResponse as AccountListResponse from .asset_download_params import AssetDownloadParams as AssetDownloadParams +from .asset_upload_response import AssetUploadResponse as AssetUploadResponse from .message_search_params import MessageSearchParams as MessageSearchParams from .message_send_response import MessageSendResponse as MessageSendResponse +from .message_update_params import MessageUpdateParams as MessageUpdateParams from .asset_download_response import AssetDownloadResponse as AssetDownloadResponse +from .message_update_response import MessageUpdateResponse as MessageUpdateResponse diff --git a/src/beeper_desktop_api/types/asset_upload_params.py b/src/beeper_desktop_api/types/asset_upload_params.py new file mode 100644 index 0000000..d495ad3 --- /dev/null +++ b/src/beeper_desktop_api/types/asset_upload_params.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["AssetUploadParams"] + + +class AssetUploadParams(TypedDict, total=False): + content: Required[str] + """Base64-encoded file content (max ~500MB decoded)""" + + file_name: Annotated[str, PropertyInfo(alias="fileName")] + """Original filename. Generated if omitted""" + + mime_type: Annotated[str, PropertyInfo(alias="mimeType")] + """MIME type. Auto-detected from magic bytes if omitted""" diff --git a/src/beeper_desktop_api/types/asset_upload_response.py b/src/beeper_desktop_api/types/asset_upload_response.py new file mode 100644 index 0000000..571d81e --- /dev/null +++ b/src/beeper_desktop_api/types/asset_upload_response.py @@ -0,0 +1,38 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["AssetUploadResponse"] + + +class AssetUploadResponse(BaseModel): + duration: Optional[float] = None + """Duration in seconds (audio/videos)""" + + error: Optional[str] = None + """Error message if upload failed""" + + file_name: Optional[str] = FieldInfo(alias="fileName", default=None) + """Resolved filename""" + + file_size: Optional[float] = FieldInfo(alias="fileSize", default=None) + """File size in bytes""" + + height: Optional[float] = None + """Height in pixels (images/videos)""" + + mime_type: Optional[str] = FieldInfo(alias="mimeType", default=None) + """Detected or provided MIME type""" + + src_url: Optional[str] = FieldInfo(alias="srcURL", default=None) + """Local file URL (file://) for the uploaded asset""" + + upload_id: Optional[str] = FieldInfo(alias="uploadID", default=None) + """Unique upload ID for this asset""" + + width: Optional[float] = None + """Width in pixels (images/videos)""" diff --git a/src/beeper_desktop_api/types/chat.py b/src/beeper_desktop_api/types/chat.py index f340953..837da86 100644 --- a/src/beeper_desktop_api/types/chat.py +++ b/src/beeper_desktop_api/types/chat.py @@ -38,23 +38,23 @@ class Chat(BaseModel): participants: Participants """Chat participants information.""" + title: str + """Display title of the chat as computed by the client/server.""" + type: Literal["single", "group"] """Chat type: 'single' for direct messages, 'group' for group chats.""" unread_count: int = FieldInfo(alias="unreadCount") """Number of unread messages.""" - description: Optional[str] = None - """Description of the chat.""" - is_archived: Optional[bool] = FieldInfo(alias="isArchived", default=None) """True if chat is archived.""" is_muted: Optional[bool] = FieldInfo(alias="isMuted", default=None) - """True if the chat is muted.""" + """True if chat notifications are muted.""" is_pinned: Optional[bool] = FieldInfo(alias="isPinned", default=None) - """True if the chat is pinned.""" + """True if chat is pinned.""" last_activity: Optional[datetime] = FieldInfo(alias="lastActivity", default=None) """Timestamp of last activity.""" @@ -64,6 +64,3 @@ class Chat(BaseModel): local_chat_id: Optional[str] = FieldInfo(alias="localChatID", default=None) """Local chat ID specific to this Beeper Desktop installation.""" - - title: Optional[str] = None - """Display title of the chat.""" diff --git a/src/beeper_desktop_api/types/client_search_params.py b/src/beeper_desktop_api/types/client_search_params.py index 06d58e4..6135164 100644 --- a/src/beeper_desktop_api/types/client_search_params.py +++ b/src/beeper_desktop_api/types/client_search_params.py @@ -9,4 +9,4 @@ class ClientSearchParams(TypedDict, total=False): query: Required[str] - """User-typed search text. Literal word matching (NOT semantic).""" + """User-typed search text. Literal word matching (non-semantic).""" diff --git a/src/beeper_desktop_api/types/message_search_params.py b/src/beeper_desktop_api/types/message_search_params.py index 93fbd63..3ba609d 100644 --- a/src/beeper_desktop_api/types/message_search_params.py +++ b/src/beeper_desktop_api/types/message_search_params.py @@ -66,7 +66,7 @@ class MessageSearchParams(TypedDict, total=False): """ query: str - """Literal word search (NOT semantic). + """Literal word search (non-semantic). Finds messages containing these EXACT words in any order. Use single words users actually type, not concepts or phrases. Example: use "dinner" not "dinner diff --git a/src/beeper_desktop_api/types/message_send_params.py b/src/beeper_desktop_api/types/message_send_params.py index 840e745..b3f390a 100644 --- a/src/beeper_desktop_api/types/message_send_params.py +++ b/src/beeper_desktop_api/types/message_send_params.py @@ -2,16 +2,52 @@ from __future__ import annotations -from typing_extensions import Annotated, TypedDict +from typing_extensions import Literal, Required, Annotated, TypedDict from .._utils import PropertyInfo -__all__ = ["MessageSendParams"] +__all__ = ["MessageSendParams", "Attachment", "AttachmentSize"] class MessageSendParams(TypedDict, total=False): + attachment: Attachment + """Single attachment to send with the message""" + reply_to_message_id: Annotated[str, PropertyInfo(alias="replyToMessageID")] """Provide a message ID to send this as a reply to an existing message""" text: str """Text content of the message you want to send. You may use markdown.""" + + +class AttachmentSize(TypedDict, total=False): + """Dimensions (optional override of cached value)""" + + height: Required[float] + + width: Required[float] + + +class Attachment(TypedDict, total=False): + """Single attachment to send with the message""" + + upload_id: Required[Annotated[str, PropertyInfo(alias="uploadID")]] + """Upload ID from uploadAsset endpoint. Required to reference uploaded files.""" + + duration: float + """Duration in seconds (optional override of cached value)""" + + file_name: Annotated[str, PropertyInfo(alias="fileName")] + """Filename (optional override of cached value)""" + + mime_type: Annotated[str, PropertyInfo(alias="mimeType")] + """MIME type (optional override of cached value)""" + + size: AttachmentSize + """Dimensions (optional override of cached value)""" + + type: Literal["gif", "voiceNote", "sticker"] + """Special attachment type (gif, voiceNote, sticker). + + If omitted, auto-detected from mimeType + """ diff --git a/src/beeper_desktop_api/types/message_update_params.py b/src/beeper_desktop_api/types/message_update_params.py new file mode 100644 index 0000000..663d6e8 --- /dev/null +++ b/src/beeper_desktop_api/types/message_update_params.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["MessageUpdateParams"] + + +class MessageUpdateParams(TypedDict, total=False): + chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] + """Unique identifier of the chat.""" + + text: Required[str] + """New text content for the message""" diff --git a/src/beeper_desktop_api/types/message_update_response.py b/src/beeper_desktop_api/types/message_update_response.py new file mode 100644 index 0000000..41e0383 --- /dev/null +++ b/src/beeper_desktop_api/types/message_update_response.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["MessageUpdateResponse"] + + +class MessageUpdateResponse(BaseModel): + chat_id: str = FieldInfo(alias="chatID") + """Unique identifier of the chat.""" + + message_id: str = FieldInfo(alias="messageID") + """Message ID.""" + + success: bool + """Whether the message was successfully edited""" diff --git a/src/beeper_desktop_api/types/shared/attachment.py b/src/beeper_desktop_api/types/shared/attachment.py index 8049b84..e1b7b7b 100644 --- a/src/beeper_desktop_api/types/shared/attachment.py +++ b/src/beeper_desktop_api/types/shared/attachment.py @@ -22,6 +22,12 @@ class Attachment(BaseModel): type: Literal["unknown", "img", "video", "audio"] """Attachment type.""" + id: Optional[str] = None + """Attachment identifier (typically an mxc:// URL). + + Use with /v1/assets/download to get a local file path. + """ + duration: Optional[float] = None """Duration in seconds (audio/video).""" diff --git a/src/beeper_desktop_api/types/shared/message.py b/src/beeper_desktop_api/types/shared/message.py index f87febe..dc3ad4f 100644 --- a/src/beeper_desktop_api/types/shared/message.py +++ b/src/beeper_desktop_api/types/shared/message.py @@ -2,6 +2,7 @@ from typing import List, Optional from datetime import datetime +from typing_extensions import Literal from pydantic import Field as FieldInfo @@ -40,6 +41,9 @@ class Message(BaseModel): is_unread: Optional[bool] = FieldInfo(alias="isUnread", default=None) """True if the message is unread for the authenticated user. May be omitted.""" + linked_message_id: Optional[str] = FieldInfo(alias="linkedMessageID", default=None) + """ID of the message this is a reply to, if any.""" + reactions: Optional[List[Reaction]] = None """Reactions to the message, if any.""" @@ -53,3 +57,12 @@ class Message(BaseModel): May include a JSON fallback with text entities for rich messages. """ + + type: Optional[ + Literal["TEXT", "NOTICE", "IMAGE", "VIDEO", "VOICE", "AUDIO", "FILE", "STICKER", "LOCATION", "REACTION"] + ] = None + """Message content type. + + Useful for distinguishing reactions, media messages, and state events from + regular text messages. + """ diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py index f878292..8560041 100644 --- a/tests/api_resources/test_assets.py +++ b/tests/api_resources/test_assets.py @@ -9,7 +9,10 @@ from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types import AssetDownloadResponse +from beeper_desktop_api.types import ( + AssetUploadResponse, + AssetDownloadResponse, +) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -48,6 +51,46 @@ def test_streaming_response_download(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True + @parametrize + def test_method_upload(self, client: BeeperDesktop) -> None: + asset = client.assets.upload( + content="x", + ) + assert_matches_type(AssetUploadResponse, asset, path=["response"]) + + @parametrize + def test_method_upload_with_all_params(self, client: BeeperDesktop) -> None: + asset = client.assets.upload( + content="x", + file_name="fileName", + mime_type="mimeType", + ) + assert_matches_type(AssetUploadResponse, asset, path=["response"]) + + @parametrize + def test_raw_response_upload(self, client: BeeperDesktop) -> None: + response = client.assets.with_raw_response.upload( + content="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + asset = response.parse() + assert_matches_type(AssetUploadResponse, asset, path=["response"]) + + @parametrize + def test_streaming_response_upload(self, client: BeeperDesktop) -> None: + with client.assets.with_streaming_response.upload( + content="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + asset = response.parse() + assert_matches_type(AssetUploadResponse, asset, path=["response"]) + + assert cast(Any, response.is_closed) is True + class TestAsyncAssets: parametrize = pytest.mark.parametrize( @@ -84,3 +127,43 @@ async def test_streaming_response_download(self, async_client: AsyncBeeperDeskto assert_matches_type(AssetDownloadResponse, asset, path=["response"]) assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_upload(self, async_client: AsyncBeeperDesktop) -> None: + asset = await async_client.assets.upload( + content="x", + ) + 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( + content="x", + file_name="fileName", + mime_type="mimeType", + ) + assert_matches_type(AssetUploadResponse, asset, path=["response"]) + + @parametrize + async def test_raw_response_upload(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.assets.with_raw_response.upload( + content="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + asset = await response.parse() + assert_matches_type(AssetUploadResponse, asset, path=["response"]) + + @parametrize + async def test_streaming_response_upload(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.assets.with_streaming_response.upload( + content="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + asset = await response.parse() + assert_matches_type(AssetUploadResponse, asset, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index 6a59733..786ae9b 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -126,7 +126,6 @@ def test_method_list(self, client: BeeperDesktop) -> None: def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: chat = client.chats.list( account_ids=[ - "whatsapp", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", ], @@ -356,7 +355,6 @@ async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.list( account_ids=[ - "whatsapp", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", ], diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index 0a6d9f3..b6c3900 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -11,6 +11,7 @@ from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop from beeper_desktop_api.types import ( MessageSendResponse, + MessageUpdateResponse, ) from beeper_desktop_api._utils import parse_datetime from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorSortKey, AsyncCursorSortKey @@ -22,6 +23,59 @@ class TestMessages: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + @parametrize + def test_method_update(self, client: BeeperDesktop) -> None: + message = client.messages.update( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + text="x", + ) + assert_matches_type(MessageUpdateResponse, message, path=["response"]) + + @parametrize + def test_raw_response_update(self, client: BeeperDesktop) -> None: + response = client.messages.with_raw_response.update( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + text="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = response.parse() + assert_matches_type(MessageUpdateResponse, message, path=["response"]) + + @parametrize + def test_streaming_response_update(self, client: BeeperDesktop) -> None: + with client.messages.with_streaming_response.update( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + text="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = response.parse() + assert_matches_type(MessageUpdateResponse, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_update(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.messages.with_raw_response.update( + message_id="messageID", + chat_id="", + text="x", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + client.messages.with_raw_response.update( + message_id="", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + text="x", + ) + @parametrize def test_method_list(self, client: BeeperDesktop) -> None: message = client.messages.list( @@ -78,7 +132,6 @@ def test_method_search(self, client: BeeperDesktop) -> None: def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: message = client.messages.search( account_ids=[ - "whatsapp", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", ], @@ -128,6 +181,17 @@ def test_method_send(self, client: BeeperDesktop) -> None: def test_method_send_with_all_params(self, client: BeeperDesktop) -> None: message = client.messages.send( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + attachment={ + "upload_id": "uploadID", + "duration": 0, + "file_name": "fileName", + "mime_type": "mimeType", + "size": { + "height": 0, + "width": 0, + }, + "type": "gif", + }, reply_to_message_id="replyToMessageID", text="text", ) @@ -170,6 +234,59 @@ class TestAsyncMessages: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) + @parametrize + async def test_method_update(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.update( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + text="x", + ) + assert_matches_type(MessageUpdateResponse, message, path=["response"]) + + @parametrize + async def test_raw_response_update(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.messages.with_raw_response.update( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + text="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = await response.parse() + assert_matches_type(MessageUpdateResponse, message, path=["response"]) + + @parametrize + async def test_streaming_response_update(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.messages.with_streaming_response.update( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + text="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = await response.parse() + assert_matches_type(MessageUpdateResponse, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_update(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.messages.with_raw_response.update( + message_id="messageID", + chat_id="", + text="x", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + await async_client.messages.with_raw_response.update( + message_id="", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + text="x", + ) + @parametrize async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.list( @@ -226,7 +343,6 @@ async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.search( account_ids=[ - "whatsapp", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", ], @@ -276,6 +392,17 @@ async def test_method_send(self, async_client: AsyncBeeperDesktop) -> None: async def test_method_send_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.send( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + attachment={ + "upload_id": "uploadID", + "duration": 0, + "file_name": "fileName", + "mime_type": "mimeType", + "size": { + "height": 0, + "width": 0, + }, + "type": "gif", + }, reply_to_message_id="replyToMessageID", text="text", ) From 624f143063fddc7af7a0dde59b6933515eab7ea8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 08:09:01 +0000 Subject: [PATCH 53/98] feat(api): manual updates --- .stats.yml | 8 +- README.md | 32 +++- api.md | 7 +- src/beeper_desktop_api/_files.py | 2 +- src/beeper_desktop_api/resources/assets.py | 165 +++++++++++++++--- src/beeper_desktop_api/types/__init__.py | 2 + .../types/asset_upload_base64_params.py | 20 +++ .../types/asset_upload_base64_response.py | 38 ++++ .../types/asset_upload_params.py | 7 +- tests/api_resources/test_assets.py | 97 +++++++++- 10 files changed, 327 insertions(+), 51 deletions(-) create mode 100644 src/beeper_desktop_api/types/asset_upload_base64_params.py create mode 100644 src/beeper_desktop_api/types/asset_upload_base64_response.py diff --git a/.stats.yml b/.stats.yml index 1646e75..cf2ebf1 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 17 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-0e4d333e81e670e605a6706dbb365bc96957a59331fdc87dd1550c59880cb130.yml -openapi_spec_hash: 564146e6d318ecb486ff7c0d7983b398 -config_hash: e3418e22e2ca1df0f8a9fcaf7153285a +configured_endpoints: 18 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5fb80d7f97f2428d1826b9c381476f0d46117fc694140175dbc15920b1884f1f.yml +openapi_spec_hash: 06f8538bc0a27163d33a80c00fb16e86 +config_hash: d0b0effc89b6d81f98400536657b6876 diff --git a/README.md b/README.md index b884bb0..4221bd7 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=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1hcGktbWNwIl19) -[![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%7D) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1tY3AiXX0) +[![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%7D) > Note: You may need to set environment variables in your MCP client. @@ -23,10 +23,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). @@ -87,8 +90,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()`: @@ -221,6 +224,23 @@ client.chats.reminders.create( ) ``` +## File uploads + +Request parameters that correspond to file uploads can be passed as `bytes`, or a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance or a tuple of `(filename, contents, media type)`. + +```python +from pathlib import Path +from beeper_desktop_api import BeeperDesktop + +client = BeeperDesktop() + +client.assets.upload( + file=Path("/path/to/file"), +) +``` + +The async client uses the exact same interface. If you pass a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance, the file contents will be read asynchronously automatically. + ## Handling errors When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `beeper_desktop_api.APIConnectionError` is raised. diff --git a/api.md b/api.md index dfa1b30..c309128 100644 --- a/api.md +++ b/api.md @@ -84,10 +84,15 @@ Methods: Types: ```python -from beeper_desktop_api.types import AssetDownloadResponse, AssetUploadResponse +from beeper_desktop_api.types import ( + AssetDownloadResponse, + AssetUploadResponse, + AssetUploadBase64Response, +) ``` Methods: - client.assets.download(\*\*params) -> AssetDownloadResponse - client.assets.upload(\*\*params) -> AssetUploadResponse +- client.assets.upload_base64(\*\*params) -> AssetUploadBase64Response diff --git a/src/beeper_desktop_api/_files.py b/src/beeper_desktop_api/_files.py index cc14c14..e0ef7aa 100644 --- a/src/beeper_desktop_api/_files.py +++ b/src/beeper_desktop_api/_files.py @@ -34,7 +34,7 @@ def assert_is_file_content(obj: object, *, key: str | None = None) -> None: if not is_file_content(obj): prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`" raise RuntimeError( - f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead." + f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead. See https://github.com/beeper/desktop-api-python/tree/main#file-uploads" ) from None diff --git a/src/beeper_desktop_api/resources/assets.py b/src/beeper_desktop_api/resources/assets.py index 7e9973d..a4d8ce7 100644 --- a/src/beeper_desktop_api/resources/assets.py +++ b/src/beeper_desktop_api/resources/assets.py @@ -6,8 +6,8 @@ import httpx -from ..types import asset_upload_params, asset_download_params -from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..types import asset_upload_params, asset_download_params, asset_upload_base64_params +from .._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -20,6 +20,7 @@ from .._base_client import make_request_options from ..types.asset_upload_response import AssetUploadResponse from ..types.asset_download_response import AssetDownloadResponse +from ..types.asset_upload_base64_response import AssetUploadBase64Response __all__ = ["AssetsResource", "AsyncAssetsResource"] @@ -84,7 +85,7 @@ def download( def upload( self, *, - content: str, + file: FileTypes, file_name: str | Omit = omit, mime_type: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -94,16 +95,15 @@ def upload( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AssetUploadResponse: - """Upload a file to a temporary location. + """Upload a file to a temporary location using multipart/form-data. - Supports JSON body with base64 `content` - field, or multipart/form-data with `file` field. Returns a local file URL that - can be used when sending messages with attachments. + Returns an + uploadID that can be referenced when sending messages with attachments. Args: - content: Base64-encoded file content (max ~500MB decoded) + file: The file to upload (max 500 MB). - file_name: Original filename. Generated if omitted + file_name: Original filename. Defaults to the uploaded file name if omitted mime_type: MIME type. Auto-detected from magic bytes if omitted @@ -117,17 +117,16 @@ def upload( """ body = deepcopy_minimal( { - "content": content, + "file": file, "file_name": file_name, "mime_type": mime_type, } ) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) - if files: - # It should be noted that the actual Content-Type header that will be - # sent to the server will contain a `boundary` parameter, e.g. - # multipart/form-data; boundary=---abc-- - extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} return self._post( "/v1/assets/upload", body=maybe_transform(body, asset_upload_params.AssetUploadParams), @@ -138,6 +137,56 @@ def upload( cast_to=AssetUploadResponse, ) + def upload_base64( + self, + *, + content: str, + file_name: str | Omit = omit, + mime_type: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AssetUploadBase64Response: + """Upload a file using a JSON body with base64-encoded content. + + Returns an uploadID + that can be referenced when sending messages with attachments. Alternative to + the multipart upload endpoint. + + Args: + content: Base64-encoded file content (max ~500MB decoded) + + file_name: Original filename. Generated if omitted + + mime_type: MIME type. Auto-detected from magic bytes if omitted + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/assets/upload/base64", + body=maybe_transform( + { + "content": content, + "file_name": file_name, + "mime_type": mime_type, + }, + asset_upload_base64_params.AssetUploadBase64Params, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AssetUploadBase64Response, + ) + class AsyncAssetsResource(AsyncAPIResource): """Manage assets in Beeper Desktop, like message attachments""" @@ -199,7 +248,7 @@ async def download( async def upload( self, *, - content: str, + file: FileTypes, file_name: str | Omit = omit, mime_type: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -209,16 +258,15 @@ async def upload( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AssetUploadResponse: - """Upload a file to a temporary location. + """Upload a file to a temporary location using multipart/form-data. - Supports JSON body with base64 `content` - field, or multipart/form-data with `file` field. Returns a local file URL that - can be used when sending messages with attachments. + Returns an + uploadID that can be referenced when sending messages with attachments. Args: - content: Base64-encoded file content (max ~500MB decoded) + file: The file to upload (max 500 MB). - file_name: Original filename. Generated if omitted + file_name: Original filename. Defaults to the uploaded file name if omitted mime_type: MIME type. Auto-detected from magic bytes if omitted @@ -232,17 +280,16 @@ async def upload( """ body = deepcopy_minimal( { - "content": content, + "file": file, "file_name": file_name, "mime_type": mime_type, } ) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) - if files: - # It should be noted that the actual Content-Type header that will be - # sent to the server will contain a `boundary` parameter, e.g. - # multipart/form-data; boundary=---abc-- - extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} return await self._post( "/v1/assets/upload", body=await async_maybe_transform(body, asset_upload_params.AssetUploadParams), @@ -253,6 +300,56 @@ async def upload( cast_to=AssetUploadResponse, ) + async def upload_base64( + self, + *, + content: str, + file_name: str | Omit = omit, + mime_type: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AssetUploadBase64Response: + """Upload a file using a JSON body with base64-encoded content. + + Returns an uploadID + that can be referenced when sending messages with attachments. Alternative to + the multipart upload endpoint. + + Args: + content: Base64-encoded file content (max ~500MB decoded) + + file_name: Original filename. Generated if omitted + + mime_type: MIME type. Auto-detected from magic bytes if omitted + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/assets/upload/base64", + body=await async_maybe_transform( + { + "content": content, + "file_name": file_name, + "mime_type": mime_type, + }, + asset_upload_base64_params.AssetUploadBase64Params, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AssetUploadBase64Response, + ) + class AssetsResourceWithRawResponse: def __init__(self, assets: AssetsResource) -> None: @@ -264,6 +361,9 @@ def __init__(self, assets: AssetsResource) -> None: self.upload = to_raw_response_wrapper( assets.upload, ) + self.upload_base64 = to_raw_response_wrapper( + assets.upload_base64, + ) class AsyncAssetsResourceWithRawResponse: @@ -276,6 +376,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.upload = async_to_raw_response_wrapper( assets.upload, ) + self.upload_base64 = async_to_raw_response_wrapper( + assets.upload_base64, + ) class AssetsResourceWithStreamingResponse: @@ -288,6 +391,9 @@ def __init__(self, assets: AssetsResource) -> None: self.upload = to_streamed_response_wrapper( assets.upload, ) + self.upload_base64 = to_streamed_response_wrapper( + assets.upload_base64, + ) class AsyncAssetsResourceWithStreamingResponse: @@ -300,3 +406,6 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.upload = async_to_streamed_response_wrapper( assets.upload, ) + self.upload_base64 = async_to_streamed_response_wrapper( + assets.upload_base64, + ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index 93aa4b5..9d2773d 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -27,3 +27,5 @@ from .message_update_params import MessageUpdateParams as MessageUpdateParams from .asset_download_response import AssetDownloadResponse as AssetDownloadResponse from .message_update_response import MessageUpdateResponse as MessageUpdateResponse +from .asset_upload_base64_params import AssetUploadBase64Params as AssetUploadBase64Params +from .asset_upload_base64_response import AssetUploadBase64Response as AssetUploadBase64Response diff --git a/src/beeper_desktop_api/types/asset_upload_base64_params.py b/src/beeper_desktop_api/types/asset_upload_base64_params.py new file mode 100644 index 0000000..9600201 --- /dev/null +++ b/src/beeper_desktop_api/types/asset_upload_base64_params.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["AssetUploadBase64Params"] + + +class AssetUploadBase64Params(TypedDict, total=False): + content: Required[str] + """Base64-encoded file content (max ~500MB decoded)""" + + file_name: Annotated[str, PropertyInfo(alias="fileName")] + """Original filename. Generated if omitted""" + + mime_type: Annotated[str, PropertyInfo(alias="mimeType")] + """MIME type. Auto-detected from magic bytes if omitted""" diff --git a/src/beeper_desktop_api/types/asset_upload_base64_response.py b/src/beeper_desktop_api/types/asset_upload_base64_response.py new file mode 100644 index 0000000..cfa8351 --- /dev/null +++ b/src/beeper_desktop_api/types/asset_upload_base64_response.py @@ -0,0 +1,38 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["AssetUploadBase64Response"] + + +class AssetUploadBase64Response(BaseModel): + duration: Optional[float] = None + """Duration in seconds (audio/videos)""" + + error: Optional[str] = None + """Error message if upload failed""" + + file_name: Optional[str] = FieldInfo(alias="fileName", default=None) + """Resolved filename""" + + file_size: Optional[float] = FieldInfo(alias="fileSize", default=None) + """File size in bytes""" + + height: Optional[float] = None + """Height in pixels (images/videos)""" + + mime_type: Optional[str] = FieldInfo(alias="mimeType", default=None) + """Detected or provided MIME type""" + + src_url: Optional[str] = FieldInfo(alias="srcURL", default=None) + """Local file URL (file://) for the uploaded asset""" + + upload_id: Optional[str] = FieldInfo(alias="uploadID", default=None) + """Unique upload ID for this asset""" + + width: Optional[float] = None + """Width in pixels (images/videos)""" diff --git a/src/beeper_desktop_api/types/asset_upload_params.py b/src/beeper_desktop_api/types/asset_upload_params.py index d495ad3..3249b44 100644 --- a/src/beeper_desktop_api/types/asset_upload_params.py +++ b/src/beeper_desktop_api/types/asset_upload_params.py @@ -4,17 +4,18 @@ from typing_extensions import Required, Annotated, TypedDict +from .._types import FileTypes from .._utils import PropertyInfo __all__ = ["AssetUploadParams"] class AssetUploadParams(TypedDict, total=False): - content: Required[str] - """Base64-encoded file content (max ~500MB decoded)""" + file: Required[FileTypes] + """The file to upload (max 500 MB).""" file_name: Annotated[str, PropertyInfo(alias="fileName")] - """Original filename. Generated if omitted""" + """Original filename. Defaults to the uploaded file name if omitted""" mime_type: Annotated[str, PropertyInfo(alias="mimeType")] """MIME type. Auto-detected from magic bytes if omitted""" diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py index 8560041..1aa8c0d 100644 --- a/tests/api_resources/test_assets.py +++ b/tests/api_resources/test_assets.py @@ -12,6 +12,7 @@ from beeper_desktop_api.types import ( AssetUploadResponse, AssetDownloadResponse, + AssetUploadBase64Response, ) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -54,14 +55,14 @@ def test_streaming_response_download(self, client: BeeperDesktop) -> None: @parametrize def test_method_upload(self, client: BeeperDesktop) -> None: asset = client.assets.upload( - content="x", + file=b"raw file contents", ) assert_matches_type(AssetUploadResponse, asset, path=["response"]) @parametrize def test_method_upload_with_all_params(self, client: BeeperDesktop) -> None: asset = client.assets.upload( - content="x", + file=b"raw file contents", file_name="fileName", mime_type="mimeType", ) @@ -70,7 +71,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( - content="x", + file=b"raw file contents", ) assert response.is_closed is True @@ -81,7 +82,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( - content="x", + file=b"raw file contents", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -91,6 +92,46 @@ def test_streaming_response_upload(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True + @parametrize + def test_method_upload_base64(self, client: BeeperDesktop) -> None: + asset = client.assets.upload_base64( + content="x", + ) + assert_matches_type(AssetUploadBase64Response, asset, path=["response"]) + + @parametrize + def test_method_upload_base64_with_all_params(self, client: BeeperDesktop) -> None: + asset = client.assets.upload_base64( + content="x", + file_name="fileName", + mime_type="mimeType", + ) + assert_matches_type(AssetUploadBase64Response, asset, path=["response"]) + + @parametrize + def test_raw_response_upload_base64(self, client: BeeperDesktop) -> None: + response = client.assets.with_raw_response.upload_base64( + content="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + asset = response.parse() + assert_matches_type(AssetUploadBase64Response, asset, path=["response"]) + + @parametrize + def test_streaming_response_upload_base64(self, client: BeeperDesktop) -> None: + with client.assets.with_streaming_response.upload_base64( + content="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + asset = response.parse() + assert_matches_type(AssetUploadBase64Response, asset, path=["response"]) + + assert cast(Any, response.is_closed) is True + class TestAsyncAssets: parametrize = pytest.mark.parametrize( @@ -131,14 +172,14 @@ async def test_streaming_response_download(self, async_client: AsyncBeeperDeskto @parametrize async def test_method_upload(self, async_client: AsyncBeeperDesktop) -> None: asset = await async_client.assets.upload( - content="x", + file=b"raw file contents", ) 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( - content="x", + file=b"raw file contents", file_name="fileName", mime_type="mimeType", ) @@ -147,7 +188,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( - content="x", + file=b"raw file contents", ) assert response.is_closed is True @@ -158,7 +199,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( - content="x", + file=b"raw file contents", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -167,3 +208,43 @@ async def test_streaming_response_upload(self, async_client: AsyncBeeperDesktop) assert_matches_type(AssetUploadResponse, asset, path=["response"]) assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_upload_base64(self, async_client: AsyncBeeperDesktop) -> None: + asset = await async_client.assets.upload_base64( + content="x", + ) + assert_matches_type(AssetUploadBase64Response, asset, path=["response"]) + + @parametrize + async def test_method_upload_base64_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + asset = await async_client.assets.upload_base64( + content="x", + file_name="fileName", + mime_type="mimeType", + ) + assert_matches_type(AssetUploadBase64Response, asset, path=["response"]) + + @parametrize + async def test_raw_response_upload_base64(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.assets.with_raw_response.upload_base64( + content="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + asset = await response.parse() + assert_matches_type(AssetUploadBase64Response, asset, path=["response"]) + + @parametrize + async def test_streaming_response_upload_base64(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.assets.with_streaming_response.upload_base64( + content="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + asset = await response.parse() + assert_matches_type(AssetUploadBase64Response, asset, path=["response"]) + + assert cast(Any, response.is_closed) is True From 4f1afe1797128c73451229c777b3114959b5b830 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 08:10:47 +0000 Subject: [PATCH 54/98] feat(api): remove mcp for now --- .stats.yml | 2 +- README.md | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/.stats.yml b/.stats.yml index cf2ebf1..cf52119 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 18 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5fb80d7f97f2428d1826b9c381476f0d46117fc694140175dbc15920b1884f1f.yml openapi_spec_hash: 06f8538bc0a27163d33a80c00fb16e86 -config_hash: d0b0effc89b6d81f98400536657b6876 +config_hash: 85c42610c7ef58aa5e2a51a068ebb831 diff --git a/README.md b/README.md index 4221bd7..857bb9b 100644 --- a/README.md +++ b/README.md @@ -7,15 +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). -## 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-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1tY3AiXX0) -[![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%7D) - -> Note: You may need to set environment variables in your MCP client. - ## Documentation The REST API documentation can be found on [developers.beeper.com](https://developers.beeper.com/desktop-api/). The full API of this library can be found in [api.md](api.md). From ecf619d6b382a94aeea4389258975421c57611ed Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 00:26:56 +0000 Subject: [PATCH 55/98] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index cf52119..02a0f28 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 18 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5fb80d7f97f2428d1826b9c381476f0d46117fc694140175dbc15920b1884f1f.yml openapi_spec_hash: 06f8538bc0a27163d33a80c00fb16e86 -config_hash: 85c42610c7ef58aa5e2a51a068ebb831 +config_hash: f10bf15270915c249c8c38316ffa83a7 From 17ddb8fadc31ce0668e2cda8a9a40d71f6b4c931 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 00:28:43 +0000 Subject: [PATCH 56/98] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 02a0f28..d0840cd 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 18 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5fb80d7f97f2428d1826b9c381476f0d46117fc694140175dbc15920b1884f1f.yml openapi_spec_hash: 06f8538bc0a27163d33a80c00fb16e86 -config_hash: f10bf15270915c249c8c38316ffa83a7 +config_hash: 196c1c81b169ede101a71d1cf2796d99 From e355dae4639735ca0124e6022c3e45a2d57c9f94 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 04:04:58 +0000 Subject: [PATCH 57/98] chore(ci): upgrade `actions/github-script` --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 825e98a..3e7de04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: - name: Get GitHub OIDC Token if: github.repository == 'stainless-sdks/beeper-desktop-api-python' id: github-oidc - uses: actions/github-script@v6 + uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); From 311300557b56288d2f18140475841f207b3363ab Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 04:00:09 +0000 Subject: [PATCH 58/98] feat(client): add custom JSON encoder for extended type support --- src/beeper_desktop_api/_base_client.py | 7 +- src/beeper_desktop_api/_compat.py | 6 +- src/beeper_desktop_api/_utils/_json.py | 35 +++++++ tests/test_utils/test_json.py | 126 +++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 src/beeper_desktop_api/_utils/_json.py create mode 100644 tests/test_utils/test_json.py diff --git a/src/beeper_desktop_api/_base_client.py b/src/beeper_desktop_api/_base_client.py index 19b20e8..25424b1 100644 --- a/src/beeper_desktop_api/_base_client.py +++ b/src/beeper_desktop_api/_base_client.py @@ -86,6 +86,7 @@ APIConnectionError, APIResponseValidationError, ) +from ._utils._json import openapi_dumps log: logging.Logger = logging.getLogger(__name__) @@ -554,8 +555,10 @@ def _build_request( kwargs["content"] = options.content elif isinstance(json_data, bytes): kwargs["content"] = json_data - else: - kwargs["json"] = json_data if is_given(json_data) else None + elif not files: + # Don't set content when JSON is sent as multipart/form-data, + # since httpx's content param overrides other body arguments + kwargs["content"] = openapi_dumps(json_data) if is_given(json_data) and json_data is not None else None kwargs["files"] = files else: headers.pop("Content-Type", None) diff --git a/src/beeper_desktop_api/_compat.py b/src/beeper_desktop_api/_compat.py index bdef67f..786ff42 100644 --- a/src/beeper_desktop_api/_compat.py +++ b/src/beeper_desktop_api/_compat.py @@ -139,6 +139,7 @@ def model_dump( exclude_defaults: bool = False, warnings: bool = True, mode: Literal["json", "python"] = "python", + by_alias: bool | None = None, ) -> dict[str, Any]: if (not PYDANTIC_V1) or hasattr(model, "model_dump"): return model.model_dump( @@ -148,13 +149,12 @@ 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, ) return cast( "dict[str, Any]", model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - exclude=exclude, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, + exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, by_alias=bool(by_alias) ), ) diff --git a/src/beeper_desktop_api/_utils/_json.py b/src/beeper_desktop_api/_utils/_json.py new file mode 100644 index 0000000..6058421 --- /dev/null +++ b/src/beeper_desktop_api/_utils/_json.py @@ -0,0 +1,35 @@ +import json +from typing import Any +from datetime import datetime +from typing_extensions import override + +import pydantic + +from .._compat import model_dump + + +def openapi_dumps(obj: Any) -> bytes: + """ + Serialize an object to UTF-8 encoded JSON bytes. + + Extends the standard json.dumps with support for additional types + commonly used in the SDK, such as `datetime`, `pydantic.BaseModel`, etc. + """ + return json.dumps( + obj, + cls=_CustomEncoder, + # Uses the same defaults as httpx's JSON serialization + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +class _CustomEncoder(json.JSONEncoder): + @override + def default(self, o: Any) -> Any: + if isinstance(o, datetime): + return o.isoformat() + if isinstance(o, pydantic.BaseModel): + return model_dump(o, exclude_unset=True, mode="json", by_alias=True) + return super().default(o) diff --git a/tests/test_utils/test_json.py b/tests/test_utils/test_json.py new file mode 100644 index 0000000..ef5fab1 --- /dev/null +++ b/tests/test_utils/test_json.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import datetime +from typing import Union + +import pydantic + +from beeper_desktop_api import _compat +from beeper_desktop_api._utils._json import openapi_dumps + + +class TestOpenapiDumps: + def test_basic(self) -> None: + data = {"key": "value", "number": 42} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"key":"value","number":42}' + + def test_datetime_serialization(self) -> None: + dt = datetime.datetime(2023, 1, 1, 12, 0, 0) + data = {"datetime": dt} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"datetime":"2023-01-01T12:00:00"}' + + def test_pydantic_model_serialization(self) -> None: + class User(pydantic.BaseModel): + first_name: str + last_name: str + age: int + + model_instance = User(first_name="John", last_name="Kramer", age=83) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"first_name":"John","last_name":"Kramer","age":83}}' + + def test_pydantic_model_with_default_values(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + score: int = 0 + + model_instance = User(name="Alice") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Alice"}}' + + def test_pydantic_model_with_default_values_overridden(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + + model_instance = User(name="Bob", role="admin", active=False) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Bob","role":"admin","active":false}}' + + def test_pydantic_model_with_alias(self) -> None: + class User(pydantic.BaseModel): + first_name: str = pydantic.Field(alias="firstName") + last_name: str = pydantic.Field(alias="lastName") + + model_instance = User(firstName="John", lastName="Doe") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"firstName":"John","lastName":"Doe"}}' + + def test_pydantic_model_with_alias_and_default(self) -> None: + class User(pydantic.BaseModel): + user_name: str = pydantic.Field(alias="userName") + user_role: str = pydantic.Field(default="member", alias="userRole") + is_active: bool = pydantic.Field(default=True, alias="isActive") + + model_instance = User(userName="charlie") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"charlie"}}' + + model_with_overrides = User(userName="diana", userRole="admin", isActive=False) + data = {"model": model_with_overrides} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"diana","userRole":"admin","isActive":false}}' + + def test_pydantic_model_with_nested_models_and_defaults(self) -> None: + class Address(pydantic.BaseModel): + street: str + city: str = "Unknown" + + class User(pydantic.BaseModel): + name: str + address: Address + verified: bool = False + + if _compat.PYDANTIC_V1: + # to handle forward references in Pydantic v1 + User.update_forward_refs(**locals()) # type: ignore[reportDeprecated] + + address = Address(street="123 Main St") + user = User(name="Diana", address=address) + data = {"user": user} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"user":{"name":"Diana","address":{"street":"123 Main St"}}}' + + address_with_city = Address(street="456 Oak Ave", city="Boston") + user_verified = User(name="Eve", address=address_with_city, verified=True) + data = {"user": user_verified} + json_bytes = openapi_dumps(data) + assert ( + json_bytes == b'{"user":{"name":"Eve","address":{"street":"456 Oak Ave","city":"Boston"},"verified":true}}' + ) + + def test_pydantic_model_with_optional_fields(self) -> None: + class User(pydantic.BaseModel): + name: str + email: Union[str, None] + phone: Union[str, None] + + model_with_none = User(name="Eve", email=None, phone=None) + data = {"model": model_with_none} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Eve","email":null,"phone":null}}' + + model_with_values = User(name="Frank", email="frank@example.com", phone=None) + data = {"model": model_with_values} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Frank","email":"frank@example.com","phone":null}}' From c2cf42d66550190b25ef09700c95754d57dd994b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 03:42:33 +0000 Subject: [PATCH 59/98] feat(api): manual updates --- .stats.yml | 8 +- README.md | 3 +- api.md | 15 ++- src/beeper_desktop_api/resources/assets.py | 96 ++++++++++++++++++- .../resources/chats/chats.py | 13 ++- .../resources/chats/reminders.py | 24 +++-- src/beeper_desktop_api/types/__init__.py | 2 + .../types/asset_serve_params.py | 12 +++ src/beeper_desktop_api/types/chat.py | 3 - .../types/chat_archive_response.py | 12 +++ .../types/chats/__init__.py | 2 + .../types/chats/reminder_create_response.py | 12 +++ .../types/chats/reminder_delete_response.py | 12 +++ tests/api_resources/chats/test_reminders.py | 30 +++--- tests/api_resources/test_assets.py | 62 ++++++++++++ tests/api_resources/test_chats.py | 17 ++-- 16 files changed, 267 insertions(+), 56 deletions(-) create mode 100644 src/beeper_desktop_api/types/asset_serve_params.py create mode 100644 src/beeper_desktop_api/types/chat_archive_response.py create mode 100644 src/beeper_desktop_api/types/chats/reminder_create_response.py create mode 100644 src/beeper_desktop_api/types/chats/reminder_delete_response.py diff --git a/.stats.yml b/.stats.yml index d0840cd..5cfb00e 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 18 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5fb80d7f97f2428d1826b9c381476f0d46117fc694140175dbc15920b1884f1f.yml -openapi_spec_hash: 06f8538bc0a27163d33a80c00fb16e86 -config_hash: 196c1c81b169ede101a71d1cf2796d99 +configured_endpoints: 19 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-099d55ac0e749a64dacc1706d7d8276d1acbe52103f0419393c39e8911966cfe.yml +openapi_spec_hash: 70a1b1d513b62c6d6caabbbf360220b4 +config_hash: 48ff2d23c2ebc82bd3c15787f0041684 diff --git a/README.md b/README.md index 857bb9b..29c41db 100644 --- a/README.md +++ b/README.md @@ -209,10 +209,11 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() -client.chats.reminders.create( +reminder = client.chats.reminders.create( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reminder={"remind_at_ms": 0}, ) +print(reminder.reminder) ``` ## File uploads diff --git a/api.md b/api.md index c309128..957d1d2 100644 --- a/api.md +++ b/api.md @@ -46,7 +46,7 @@ Methods: Types: ```python -from beeper_desktop_api.types import Chat, ChatCreateResponse, ChatListResponse +from beeper_desktop_api.types import Chat, ChatCreateResponse, ChatListResponse, ChatArchiveResponse ``` Methods: @@ -54,15 +54,21 @@ Methods: - client.chats.create(\*\*params) -> ChatCreateResponse - client.chats.retrieve(chat_id, \*\*params) -> Chat - client.chats.list(\*\*params) -> SyncCursorNoLimit[ChatListResponse] -- client.chats.archive(chat_id, \*\*params) -> None +- client.chats.archive(chat_id, \*\*params) -> ChatArchiveResponse - client.chats.search(\*\*params) -> SyncCursorSearch[Chat] ## Reminders +Types: + +```python +from beeper_desktop_api.types.chats import ReminderCreateResponse, ReminderDeleteResponse +``` + Methods: -- client.chats.reminders.create(chat_id, \*\*params) -> None -- client.chats.reminders.delete(chat_id) -> None +- client.chats.reminders.create(chat_id, \*\*params) -> ReminderCreateResponse +- client.chats.reminders.delete(chat_id) -> ReminderDeleteResponse # Messages @@ -94,5 +100,6 @@ from beeper_desktop_api.types import ( Methods: - client.assets.download(\*\*params) -> AssetDownloadResponse +- 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 a4d8ce7..db5dce4 100644 --- a/src/beeper_desktop_api/resources/assets.py +++ b/src/beeper_desktop_api/resources/assets.py @@ -6,8 +6,8 @@ import httpx -from ..types import asset_upload_params, asset_download_params, asset_upload_base64_params -from .._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given +from ..types import asset_serve_params, asset_upload_params, asset_download_params, asset_upload_base64_params +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 .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -82,6 +82,46 @@ def download( cast_to=AssetDownloadResponse, ) + def serve( + self, + *, + url: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """Stream a file given an mxc://, localmxc://, or file:// URL. + + Downloads first if + not cached. Supports Range requests for seeking in large files. + + Args: + url: Asset URL to serve. Accepts mxc://, localmxc://, or file:// URLs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._get( + "/v1/assets/serve", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"url": url}, asset_serve_params.AssetServeParams), + ), + cast_to=NoneType, + ) + def upload( self, *, @@ -245,6 +285,46 @@ async def download( cast_to=AssetDownloadResponse, ) + async def serve( + self, + *, + url: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """Stream a file given an mxc://, localmxc://, or file:// URL. + + Downloads first if + not cached. Supports Range requests for seeking in large files. + + Args: + url: Asset URL to serve. Accepts mxc://, localmxc://, or file:// URLs. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._get( + "/v1/assets/serve", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"url": url}, asset_serve_params.AssetServeParams), + ), + cast_to=NoneType, + ) + async def upload( self, *, @@ -358,6 +438,9 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_raw_response_wrapper( assets.download, ) + self.serve = to_raw_response_wrapper( + assets.serve, + ) self.upload = to_raw_response_wrapper( assets.upload, ) @@ -373,6 +456,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_raw_response_wrapper( assets.download, ) + self.serve = async_to_raw_response_wrapper( + assets.serve, + ) self.upload = async_to_raw_response_wrapper( assets.upload, ) @@ -388,6 +474,9 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_streamed_response_wrapper( assets.download, ) + self.serve = to_streamed_response_wrapper( + assets.serve, + ) self.upload = to_streamed_response_wrapper( assets.upload, ) @@ -403,6 +492,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_streamed_response_wrapper( assets.download, ) + self.serve = async_to_streamed_response_wrapper( + assets.serve, + ) 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 751cd72..0ef629c 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -9,7 +9,7 @@ import httpx 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 ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from .reminders import ( @@ -32,6 +32,7 @@ from ..._base_client import AsyncPaginator, make_request_options from ...types.chat_list_response import ChatListResponse from ...types.chat_create_response import ChatCreateResponse +from ...types.chat_archive_response import ChatArchiveResponse __all__ = ["ChatsResource", "AsyncChatsResource"] @@ -230,7 +231,7 @@ def archive( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> ChatArchiveResponse: """Archive or unarchive a chat. Set archived=true to move to archive, @@ -251,14 +252,13 @@ def archive( """ if not chat_id: 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", 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 ), - cast_to=NoneType, + cast_to=ChatArchiveResponse, ) def search( @@ -553,7 +553,7 @@ async def archive( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> ChatArchiveResponse: """Archive or unarchive a chat. Set archived=true to move to archive, @@ -574,14 +574,13 @@ async def archive( """ if not chat_id: 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", 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 ), - cast_to=NoneType, + cast_to=ChatArchiveResponse, ) def search( diff --git a/src/beeper_desktop_api/resources/chats/reminders.py b/src/beeper_desktop_api/resources/chats/reminders.py index 2096903..99e807c 100644 --- a/src/beeper_desktop_api/resources/chats/reminders.py +++ b/src/beeper_desktop_api/resources/chats/reminders.py @@ -4,7 +4,7 @@ import httpx -from ..._types import Body, Query, Headers, NoneType, NotGiven, not_given +from ..._types import Body, Query, Headers, NotGiven, not_given from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource @@ -16,6 +16,8 @@ ) from ...types.chats import reminder_create_params from ..._base_client import make_request_options +from ...types.chats.reminder_create_response import ReminderCreateResponse +from ...types.chats.reminder_delete_response import ReminderDeleteResponse __all__ = ["RemindersResource", "AsyncRemindersResource"] @@ -53,7 +55,7 @@ def create( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> ReminderCreateResponse: """ Set a reminder for a chat at a specific time @@ -72,14 +74,13 @@ def create( """ if not chat_id: 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", 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 ), - cast_to=NoneType, + cast_to=ReminderCreateResponse, ) def delete( @@ -92,7 +93,7 @@ def delete( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> ReminderDeleteResponse: """ Clear an existing reminder from a chat @@ -109,13 +110,12 @@ def delete( """ if not chat_id: 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", options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=NoneType, + cast_to=ReminderDeleteResponse, ) @@ -152,7 +152,7 @@ async def create( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> ReminderCreateResponse: """ Set a reminder for a chat at a specific time @@ -171,14 +171,13 @@ async def create( """ if not chat_id: 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", 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 ), - cast_to=NoneType, + cast_to=ReminderCreateResponse, ) async def delete( @@ -191,7 +190,7 @@ async def delete( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> ReminderDeleteResponse: """ Clear an existing reminder from a chat @@ -208,13 +207,12 @@ async def delete( """ if not chat_id: 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", options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=NoneType, + cast_to=ReminderDeleteResponse, ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index 9d2773d..14cf548 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -8,6 +8,7 @@ from .focus_response import FocusResponse as FocusResponse from .search_response import SearchResponse as SearchResponse from .chat_list_params import ChatListParams as ChatListParams +from .asset_serve_params import AssetServeParams as AssetServeParams from .chat_create_params import ChatCreateParams as ChatCreateParams from .chat_list_response import ChatListResponse as ChatListResponse from .chat_search_params import ChatSearchParams as ChatSearchParams @@ -22,6 +23,7 @@ from .account_list_response import AccountListResponse as AccountListResponse from .asset_download_params import AssetDownloadParams as AssetDownloadParams from .asset_upload_response import AssetUploadResponse as AssetUploadResponse +from .chat_archive_response import ChatArchiveResponse as ChatArchiveResponse from .message_search_params import MessageSearchParams as MessageSearchParams from .message_send_response import MessageSendResponse as MessageSendResponse from .message_update_params import MessageUpdateParams as MessageUpdateParams diff --git a/src/beeper_desktop_api/types/asset_serve_params.py b/src/beeper_desktop_api/types/asset_serve_params.py new file mode 100644 index 0000000..395e8b1 --- /dev/null +++ b/src/beeper_desktop_api/types/asset_serve_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["AssetServeParams"] + + +class AssetServeParams(TypedDict, total=False): + url: Required[str] + """Asset URL to serve. Accepts mxc://, localmxc://, or file:// URLs.""" diff --git a/src/beeper_desktop_api/types/chat.py b/src/beeper_desktop_api/types/chat.py index 837da86..fc67be8 100644 --- a/src/beeper_desktop_api/types/chat.py +++ b/src/beeper_desktop_api/types/chat.py @@ -32,9 +32,6 @@ class Chat(BaseModel): account_id: str = FieldInfo(alias="accountID") """Account ID this chat belongs to.""" - network: str - """Display-only human-readable network name (e.g., 'WhatsApp', 'Messenger').""" - participants: Participants """Chat participants information.""" diff --git a/src/beeper_desktop_api/types/chat_archive_response.py b/src/beeper_desktop_api/types/chat_archive_response.py new file mode 100644 index 0000000..49dac78 --- /dev/null +++ b/src/beeper_desktop_api/types/chat_archive_response.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["ChatArchiveResponse"] + + +class ChatArchiveResponse(BaseModel): + success: Literal[True] + """Indicates the operation completed successfully""" diff --git a/src/beeper_desktop_api/types/chats/__init__.py b/src/beeper_desktop_api/types/chats/__init__.py index 848b361..650c90f 100644 --- a/src/beeper_desktop_api/types/chats/__init__.py +++ b/src/beeper_desktop_api/types/chats/__init__.py @@ -3,3 +3,5 @@ from __future__ import annotations from .reminder_create_params import ReminderCreateParams as ReminderCreateParams +from .reminder_create_response import ReminderCreateResponse as ReminderCreateResponse +from .reminder_delete_response import ReminderDeleteResponse as ReminderDeleteResponse diff --git a/src/beeper_desktop_api/types/chats/reminder_create_response.py b/src/beeper_desktop_api/types/chats/reminder_create_response.py new file mode 100644 index 0000000..22e798e --- /dev/null +++ b/src/beeper_desktop_api/types/chats/reminder_create_response.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["ReminderCreateResponse"] + + +class ReminderCreateResponse(BaseModel): + success: Literal[True] + """Indicates the operation completed successfully""" diff --git a/src/beeper_desktop_api/types/chats/reminder_delete_response.py b/src/beeper_desktop_api/types/chats/reminder_delete_response.py new file mode 100644 index 0000000..06bdc4a --- /dev/null +++ b/src/beeper_desktop_api/types/chats/reminder_delete_response.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["ReminderDeleteResponse"] + + +class ReminderDeleteResponse(BaseModel): + success: Literal[True] + """Indicates the operation completed successfully""" diff --git a/tests/api_resources/chats/test_reminders.py b/tests/api_resources/chats/test_reminders.py index ea4febb..17388c8 100644 --- a/tests/api_resources/chats/test_reminders.py +++ b/tests/api_resources/chats/test_reminders.py @@ -7,7 +7,9 @@ import pytest +from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types.chats import ReminderCreateResponse, ReminderDeleteResponse base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -21,7 +23,7 @@ def test_method_create(self, client: BeeperDesktop) -> None: chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reminder={"remind_at_ms": 0}, ) - assert reminder is None + assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) @parametrize def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: @@ -32,7 +34,7 @@ def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: "dismiss_on_incoming_message": True, }, ) - assert reminder is None + assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) @parametrize def test_raw_response_create(self, client: BeeperDesktop) -> None: @@ -44,7 +46,7 @@ def test_raw_response_create(self, client: BeeperDesktop) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = response.parse() - assert reminder is None + assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) @parametrize def test_streaming_response_create(self, client: BeeperDesktop) -> None: @@ -56,7 +58,7 @@ def test_streaming_response_create(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = response.parse() - assert reminder is None + assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) assert cast(Any, response.is_closed) is True @@ -73,7 +75,7 @@ def test_method_delete(self, client: BeeperDesktop) -> None: reminder = client.chats.reminders.delete( "!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert reminder is None + assert_matches_type(ReminderDeleteResponse, reminder, path=["response"]) @parametrize def test_raw_response_delete(self, client: BeeperDesktop) -> None: @@ -84,7 +86,7 @@ def test_raw_response_delete(self, client: BeeperDesktop) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = response.parse() - assert reminder is None + assert_matches_type(ReminderDeleteResponse, reminder, path=["response"]) @parametrize def test_streaming_response_delete(self, client: BeeperDesktop) -> None: @@ -95,7 +97,7 @@ def test_streaming_response_delete(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = response.parse() - assert reminder is None + assert_matches_type(ReminderDeleteResponse, reminder, path=["response"]) assert cast(Any, response.is_closed) is True @@ -118,7 +120,7 @@ async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reminder={"remind_at_ms": 0}, ) - assert reminder is None + assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) @parametrize async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -129,7 +131,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesk "dismiss_on_incoming_message": True, }, ) - assert reminder is None + assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) @parametrize async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: @@ -141,7 +143,7 @@ async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = await response.parse() - assert reminder is None + assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) @parametrize async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: @@ -153,7 +155,7 @@ async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = await response.parse() - assert reminder is None + assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) assert cast(Any, response.is_closed) is True @@ -170,7 +172,7 @@ async def test_method_delete(self, async_client: AsyncBeeperDesktop) -> None: reminder = await async_client.chats.reminders.delete( "!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert reminder is None + assert_matches_type(ReminderDeleteResponse, reminder, path=["response"]) @parametrize async def test_raw_response_delete(self, async_client: AsyncBeeperDesktop) -> None: @@ -181,7 +183,7 @@ async def test_raw_response_delete(self, async_client: AsyncBeeperDesktop) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = await response.parse() - assert reminder is None + assert_matches_type(ReminderDeleteResponse, reminder, path=["response"]) @parametrize async def test_streaming_response_delete(self, async_client: AsyncBeeperDesktop) -> None: @@ -192,7 +194,7 @@ async def test_streaming_response_delete(self, async_client: AsyncBeeperDesktop) assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = await response.parse() - assert reminder is None + assert_matches_type(ReminderDeleteResponse, reminder, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py index 1aa8c0d..64927ac 100644 --- a/tests/api_resources/test_assets.py +++ b/tests/api_resources/test_assets.py @@ -52,6 +52,37 @@ 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: + asset = client.assets.serve( + url="x", + ) + assert asset is None + + @parametrize + def test_raw_response_serve(self, client: BeeperDesktop) -> None: + response = 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 + + @parametrize + def test_streaming_response_serve(self, client: BeeperDesktop) -> None: + 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" + + asset = response.parse() + assert asset is None + + assert cast(Any, response.is_closed) is True + @parametrize def test_method_upload(self, client: BeeperDesktop) -> None: asset = client.assets.upload( @@ -169,6 +200,37 @@ 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: + asset = await async_client.assets.serve( + url="x", + ) + assert asset is None + + @parametrize + async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop) -> None: + response = 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 + + @parametrize + async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop) -> None: + 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" + + asset = await response.parse() + assert asset is None + + assert cast(Any, response.is_closed) is True + @parametrize async def test_method_upload(self, async_client: AsyncBeeperDesktop) -> None: asset = await async_client.assets.upload( diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index 786ae9b..f18dd47 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -13,6 +13,7 @@ Chat, ChatListResponse, ChatCreateResponse, + ChatArchiveResponse, ) from beeper_desktop_api._utils import parse_datetime from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorNoLimit, AsyncCursorNoLimit @@ -159,7 +160,7 @@ def test_method_archive(self, client: BeeperDesktop) -> None: chat = client.chats.archive( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert chat is None + assert_matches_type(ChatArchiveResponse, chat, path=["response"]) @parametrize def test_method_archive_with_all_params(self, client: BeeperDesktop) -> None: @@ -167,7 +168,7 @@ def test_method_archive_with_all_params(self, client: BeeperDesktop) -> None: chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", archived=True, ) - assert chat is None + assert_matches_type(ChatArchiveResponse, chat, path=["response"]) @parametrize def test_raw_response_archive(self, client: BeeperDesktop) -> None: @@ -178,7 +179,7 @@ def test_raw_response_archive(self, client: BeeperDesktop) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = response.parse() - assert chat is None + assert_matches_type(ChatArchiveResponse, chat, path=["response"]) @parametrize def test_streaming_response_archive(self, client: BeeperDesktop) -> None: @@ -189,7 +190,7 @@ def test_streaming_response_archive(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = response.parse() - assert chat is None + assert_matches_type(ChatArchiveResponse, chat, path=["response"]) assert cast(Any, response.is_closed) is True @@ -388,7 +389,7 @@ async def test_method_archive(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.archive( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert chat is None + assert_matches_type(ChatArchiveResponse, chat, path=["response"]) @parametrize async def test_method_archive_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -396,7 +397,7 @@ async def test_method_archive_with_all_params(self, async_client: AsyncBeeperDes chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", archived=True, ) - assert chat is None + assert_matches_type(ChatArchiveResponse, chat, path=["response"]) @parametrize async def test_raw_response_archive(self, async_client: AsyncBeeperDesktop) -> None: @@ -407,7 +408,7 @@ async def test_raw_response_archive(self, async_client: AsyncBeeperDesktop) -> N assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = await response.parse() - assert chat is None + assert_matches_type(ChatArchiveResponse, chat, path=["response"]) @parametrize async def test_streaming_response_archive(self, async_client: AsyncBeeperDesktop) -> None: @@ -418,7 +419,7 @@ async def test_streaming_response_archive(self, async_client: AsyncBeeperDesktop assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = await response.parse() - assert chat is None + assert_matches_type(ChatArchiveResponse, chat, path=["response"]) assert cast(Any, response.is_closed) is True From 07942c5f479ef2c3159cbecf968a13ebeab05ff3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 03:47:34 +0000 Subject: [PATCH 60/98] chore(internal): bump dependencies --- requirements-dev.lock | 20 ++++++++++---------- requirements.lock | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/requirements-dev.lock b/requirements-dev.lock index 2e7f1c5..2fdb945 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -12,14 +12,14 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.13.2 +aiohttp==3.13.3 # via beeper-desktop-api # via httpx-aiohttp aiosignal==1.4.0 # via aiohttp annotated-types==0.7.0 # via pydantic -anyio==4.12.0 +anyio==4.12.1 # via beeper-desktop-api # via httpx argcomplete==3.6.3 @@ -31,7 +31,7 @@ attrs==25.4.0 # via nox backports-asyncio-runner==1.2.0 # via pytest-asyncio -certifi==2025.11.12 +certifi==2026.1.4 # via httpcore # via httpx colorlog==6.10.1 @@ -61,7 +61,7 @@ httpx==0.28.1 # via beeper-desktop-api # via httpx-aiohttp # via respx -httpx-aiohttp==0.1.9 +httpx-aiohttp==0.1.12 # via beeper-desktop-api humanize==4.13.0 # via nox @@ -69,7 +69,7 @@ idna==3.11 # via anyio # via httpx # via yarl -importlib-metadata==8.7.0 +importlib-metadata==8.7.1 iniconfig==2.1.0 # via pytest markdown-it-py==3.0.0 @@ -82,14 +82,14 @@ multidict==6.7.0 mypy==1.17.0 mypy-extensions==1.1.0 # via mypy -nodeenv==1.9.1 +nodeenv==1.10.0 # via pyright nox==2025.11.12 packaging==25.0 # via dependency-groups # via nox # via pytest -pathspec==0.12.1 +pathspec==1.0.3 # via mypy platformdirs==4.4.0 # via virtualenv @@ -115,13 +115,13 @@ python-dateutil==2.9.0.post0 # via time-machine respx==0.22.0 rich==14.2.0 -ruff==0.14.7 +ruff==0.14.13 six==1.17.0 # via python-dateutil sniffio==1.3.1 # via beeper-desktop-api time-machine==2.19.0 -tomli==2.3.0 +tomli==2.4.0 # via dependency-groups # via mypy # via nox @@ -141,7 +141,7 @@ typing-extensions==4.15.0 # via virtualenv typing-inspection==0.4.2 # via pydantic -virtualenv==20.35.4 +virtualenv==20.36.1 # via nox yarl==1.22.0 # via aiohttp diff --git a/requirements.lock b/requirements.lock index 9b061dd..eda3b77 100644 --- a/requirements.lock +++ b/requirements.lock @@ -12,21 +12,21 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.13.2 +aiohttp==3.13.3 # via beeper-desktop-api # via httpx-aiohttp aiosignal==1.4.0 # via aiohttp annotated-types==0.7.0 # via pydantic -anyio==4.12.0 +anyio==4.12.1 # via beeper-desktop-api # via httpx async-timeout==5.0.1 # via aiohttp attrs==25.4.0 # via aiohttp -certifi==2025.11.12 +certifi==2026.1.4 # via httpcore # via httpx distro==1.9.0 @@ -43,7 +43,7 @@ httpcore==1.0.9 httpx==0.28.1 # via beeper-desktop-api # via httpx-aiohttp -httpx-aiohttp==0.1.9 +httpx-aiohttp==0.1.12 # via beeper-desktop-api idna==3.11 # via anyio From 1520350dc787b604eab093cfb03113a1a1bb7efd Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 04:33:54 +0000 Subject: [PATCH 61/98] chore(internal): fix lint error on Python 3.14 --- src/beeper_desktop_api/_utils/_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/beeper_desktop_api/_utils/_compat.py b/src/beeper_desktop_api/_utils/_compat.py index dd70323..2c70b29 100644 --- a/src/beeper_desktop_api/_utils/_compat.py +++ b/src/beeper_desktop_api/_utils/_compat.py @@ -26,7 +26,7 @@ def is_union(tp: Optional[Type[Any]]) -> bool: else: import types - return tp is Union or tp is types.UnionType + return tp is Union or tp is types.UnionType # type: ignore[comparison-overlap] def is_typeddict(tp: Type[Any]) -> bool: From fdeee4dceaacc9a4c323e3508a0c659f591edd0c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:19:12 +0000 Subject: [PATCH 62/98] feat(api): add reactions --- .stats.yml | 4 +- README.md | 9 +- api.md | 14 +- src/beeper_desktop_api/_client.py | 4 +- .../resources/accounts/contacts.py | 14 +- .../resources/chats/chats.py | 163 ++++++++++++++++-- .../resources/chats/reminders.py | 24 +-- src/beeper_desktop_api/types/__init__.py | 1 - src/beeper_desktop_api/types/chat.py | 3 + .../types/chat_archive_response.py | 12 -- .../types/chat_create_params.py | 49 +++++- .../types/chat_create_response.py | 10 ++ .../types/chats/__init__.py | 2 - .../types/chats/reminder_create_response.py | 12 -- .../types/chats/reminder_delete_response.py | 12 -- tests/api_resources/chats/test_reminders.py | 30 ++-- tests/api_resources/test_chats.py | 143 +++++++++++++-- 17 files changed, 382 insertions(+), 124 deletions(-) delete mode 100644 src/beeper_desktop_api/types/chat_archive_response.py delete mode 100644 src/beeper_desktop_api/types/chats/reminder_create_response.py delete mode 100644 src/beeper_desktop_api/types/chats/reminder_delete_response.py diff --git a/.stats.yml b/.stats.yml index 5cfb00e..26acc07 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 19 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-099d55ac0e749a64dacc1706d7d8276d1acbe52103f0419393c39e8911966cfe.yml -openapi_spec_hash: 70a1b1d513b62c6d6caabbbf360220b4 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-3f6555bfea11258c6e8882455360ae08202067a270313716ee15571b83ada577.yml +openapi_spec_hash: 020324a708981384284f8fad8ac8c66c config_hash: 48ff2d23c2ebc82bd3c15787f0041684 diff --git a/README.md b/README.md index 29c41db..ffd504e 100644 --- a/README.md +++ b/README.md @@ -209,11 +209,12 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() -reminder = client.chats.reminders.create( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, +chat = client.chats.create( + account_id="accountID", + mode="start", + user={}, ) -print(reminder.reminder) +print(chat.user) ``` ## File uploads diff --git a/api.md b/api.md index 957d1d2..59886e9 100644 --- a/api.md +++ b/api.md @@ -46,7 +46,7 @@ Methods: Types: ```python -from beeper_desktop_api.types import Chat, ChatCreateResponse, ChatListResponse, ChatArchiveResponse +from beeper_desktop_api.types import Chat, ChatCreateResponse, ChatListResponse ``` Methods: @@ -54,21 +54,15 @@ Methods: - client.chats.create(\*\*params) -> ChatCreateResponse - client.chats.retrieve(chat_id, \*\*params) -> Chat - client.chats.list(\*\*params) -> SyncCursorNoLimit[ChatListResponse] -- client.chats.archive(chat_id, \*\*params) -> ChatArchiveResponse +- client.chats.archive(chat_id, \*\*params) -> None - client.chats.search(\*\*params) -> SyncCursorSearch[Chat] ## Reminders -Types: - -```python -from beeper_desktop_api.types.chats import ReminderCreateResponse, ReminderDeleteResponse -``` - Methods: -- client.chats.reminders.create(chat_id, \*\*params) -> ReminderCreateResponse -- client.chats.reminders.delete(chat_id) -> ReminderDeleteResponse +- client.chats.reminders.create(chat_id, \*\*params) -> None +- client.chats.reminders.delete(chat_id) -> None # Messages diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index 7d0cb94..5bfa249 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -297,7 +297,7 @@ def search( """ Returns matching chats, participant name matches in groups, and the first page of messages in one call. Paginate messages via search-messages. Paginate chats - via search-chats. Uses the same sorting as the chat search in the app. + via search-chats. Args: query: User-typed search text. Literal word matching (non-semantic). @@ -585,7 +585,7 @@ async def search( """ Returns matching chats, participant name matches in groups, and the first page of messages in one call. Paginate messages via search-messages. Paginate chats - via search-chats. Uses the same sorting as the chat search in the app. + via search-chats. Args: query: User-typed search text. Literal word matching (non-semantic). diff --git a/src/beeper_desktop_api/resources/accounts/contacts.py b/src/beeper_desktop_api/resources/accounts/contacts.py index 5719d18..f0363f3 100644 --- a/src/beeper_desktop_api/resources/accounts/contacts.py +++ b/src/beeper_desktop_api/resources/accounts/contacts.py @@ -55,10 +55,9 @@ def search( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ContactSearchResponse: - """Search contacts on a specific account using the network's search API. - - Only use - for creating new chats. + """ + Search contacts on a specific account using merged account contacts, network + search, and exact identifier lookup. Args: account_id: Account ID this resource belongs to. @@ -122,10 +121,9 @@ async def search( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ContactSearchResponse: - """Search contacts on a specific account using the network's search API. - - Only use - for creating new chats. + """ + Search contacts on a specific account using merged account contacts, network + search, and exact identifier lookup. Args: account_id: Account ID this resource belongs to. diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index 0ef629c..55f8f91 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -4,13 +4,13 @@ from typing import Union, Optional from datetime import datetime -from typing_extensions import Literal +from typing_extensions import Literal, overload import httpx 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, NotGiven, SequenceNotStr, omit, not_given -from ..._utils import maybe_transform, async_maybe_transform +from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given +from ..._utils import required_args, maybe_transform, async_maybe_transform from ..._compat import cached_property from .reminders import ( RemindersResource, @@ -32,7 +32,6 @@ from ..._base_client import AsyncPaginator, make_request_options from ...types.chat_list_response import ChatListResponse from ...types.chat_create_response import ChatCreateResponse -from ...types.chat_archive_response import ChatArchiveResponse __all__ = ["ChatsResource", "AsyncChatsResource"] @@ -64,6 +63,7 @@ def with_streaming_response(self) -> ChatsResourceWithStreamingResponse: """ return ChatsResourceWithStreamingResponse(self) + @overload def create( self, *, @@ -71,6 +71,7 @@ def create( participant_ids: SequenceNotStr[str], type: Literal["single", "group"], message_text: str | Omit = omit, + mode: Literal["create"] | Omit = omit, title: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -80,8 +81,8 @@ def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ChatCreateResponse: """ - Create a single or group chat on a specific account using participant IDs and - optional title. + Create a single/group chat (mode='create') or start a direct chat from merged + user data (mode='start'). Args: account_id: Account to create the chat on. @@ -93,6 +94,8 @@ def create( message_text: Optional first message content if the platform requires it to create the chat. + mode: Create mode. Defaults to 'create' when omitted. + title: Optional title for group chats; ignored for single chats on most platforms. extra_headers: Send extra headers @@ -103,6 +106,68 @@ def create( timeout: Override the client-level default timeout for this request, in seconds """ + ... + + @overload + def create( + self, + *, + account_id: str, + mode: Literal["start"], + user: chat_create_params.Variant1User, + allow_invite: bool | Omit = omit, + message_text: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + 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'). + + Args: + account_id: Account to start the chat on. + + mode: Start mode for resolving/creating a direct chat from merged contact data. + + user: Merged user-like contact payload used to resolve the best identifier. + + allow_invite: 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. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @required_args(["account_id", "participant_ids", "type"], ["account_id", "mode", "user"]) + def create( + self, + *, + account_id: str, + participant_ids: SequenceNotStr[str] | Omit = omit, + type: Literal["single", "group"] | Omit = omit, + message_text: str | Omit = omit, + mode: Literal["create"] | Literal["start"] | Omit = omit, + title: str | Omit = omit, + user: chat_create_params.Variant1User | Omit = omit, + allow_invite: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ChatCreateResponse: return self._post( "/v1/chats", body=maybe_transform( @@ -111,7 +176,10 @@ def create( "participant_ids": participant_ids, "type": type, "message_text": message_text, + "mode": mode, "title": title, + "user": user, + "allow_invite": allow_invite, }, chat_create_params.ChatCreateParams, ), @@ -231,7 +299,7 @@ def archive( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ChatArchiveResponse: + ) -> None: """Archive or unarchive a chat. Set archived=true to move to archive, @@ -252,13 +320,14 @@ def archive( """ if not chat_id: 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", 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 ), - cast_to=ChatArchiveResponse, + cast_to=NoneType, ) def search( @@ -386,6 +455,7 @@ def with_streaming_response(self) -> AsyncChatsResourceWithStreamingResponse: """ return AsyncChatsResourceWithStreamingResponse(self) + @overload async def create( self, *, @@ -393,6 +463,7 @@ async def create( participant_ids: SequenceNotStr[str], type: Literal["single", "group"], message_text: str | Omit = omit, + mode: Literal["create"] | Omit = omit, title: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -402,8 +473,8 @@ async def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ChatCreateResponse: """ - Create a single or group chat on a specific account using participant IDs and - optional title. + Create a single/group chat (mode='create') or start a direct chat from merged + user data (mode='start'). Args: account_id: Account to create the chat on. @@ -415,6 +486,8 @@ async def create( message_text: Optional first message content if the platform requires it to create the chat. + mode: Create mode. Defaults to 'create' when omitted. + title: Optional title for group chats; ignored for single chats on most platforms. extra_headers: Send extra headers @@ -425,6 +498,68 @@ async def create( timeout: Override the client-level default timeout for this request, in seconds """ + ... + + @overload + async def create( + self, + *, + account_id: str, + mode: Literal["start"], + user: chat_create_params.Variant1User, + allow_invite: bool | Omit = omit, + message_text: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + 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'). + + Args: + account_id: Account to start the chat on. + + mode: Start mode for resolving/creating a direct chat from merged contact data. + + user: Merged user-like contact payload used to resolve the best identifier. + + allow_invite: 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. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + ... + + @required_args(["account_id", "participant_ids", "type"], ["account_id", "mode", "user"]) + async def create( + self, + *, + account_id: str, + participant_ids: SequenceNotStr[str] | Omit = omit, + type: Literal["single", "group"] | Omit = omit, + message_text: str | Omit = omit, + mode: Literal["create"] | Literal["start"] | Omit = omit, + title: str | Omit = omit, + user: chat_create_params.Variant1User | Omit = omit, + allow_invite: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ChatCreateResponse: return await self._post( "/v1/chats", body=await async_maybe_transform( @@ -433,7 +568,10 @@ async def create( "participant_ids": participant_ids, "type": type, "message_text": message_text, + "mode": mode, "title": title, + "user": user, + "allow_invite": allow_invite, }, chat_create_params.ChatCreateParams, ), @@ -553,7 +691,7 @@ async def archive( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ChatArchiveResponse: + ) -> None: """Archive or unarchive a chat. Set archived=true to move to archive, @@ -574,13 +712,14 @@ async def archive( """ if not chat_id: 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", 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 ), - cast_to=ChatArchiveResponse, + cast_to=NoneType, ) def search( diff --git a/src/beeper_desktop_api/resources/chats/reminders.py b/src/beeper_desktop_api/resources/chats/reminders.py index 99e807c..2096903 100644 --- a/src/beeper_desktop_api/resources/chats/reminders.py +++ b/src/beeper_desktop_api/resources/chats/reminders.py @@ -4,7 +4,7 @@ import httpx -from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._types import Body, Query, Headers, NoneType, NotGiven, not_given from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource @@ -16,8 +16,6 @@ ) from ...types.chats import reminder_create_params from ..._base_client import make_request_options -from ...types.chats.reminder_create_response import ReminderCreateResponse -from ...types.chats.reminder_delete_response import ReminderDeleteResponse __all__ = ["RemindersResource", "AsyncRemindersResource"] @@ -55,7 +53,7 @@ def create( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ReminderCreateResponse: + ) -> None: """ Set a reminder for a chat at a specific time @@ -74,13 +72,14 @@ def create( """ if not chat_id: 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", 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 ), - cast_to=ReminderCreateResponse, + cast_to=NoneType, ) def delete( @@ -93,7 +92,7 @@ def delete( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ReminderDeleteResponse: + ) -> None: """ Clear an existing reminder from a chat @@ -110,12 +109,13 @@ def delete( """ if not chat_id: 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", options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ReminderDeleteResponse, + cast_to=NoneType, ) @@ -152,7 +152,7 @@ async def create( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ReminderCreateResponse: + ) -> None: """ Set a reminder for a chat at a specific time @@ -171,13 +171,14 @@ async def create( """ if not chat_id: 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", 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 ), - cast_to=ReminderCreateResponse, + cast_to=NoneType, ) async def delete( @@ -190,7 +191,7 @@ async def delete( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ReminderDeleteResponse: + ) -> None: """ Clear an existing reminder from a chat @@ -207,12 +208,13 @@ async def delete( """ if not chat_id: 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", options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ReminderDeleteResponse, + cast_to=NoneType, ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index 14cf548..77e1f16 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -23,7 +23,6 @@ from .account_list_response import AccountListResponse as AccountListResponse from .asset_download_params import AssetDownloadParams as AssetDownloadParams from .asset_upload_response import AssetUploadResponse as AssetUploadResponse -from .chat_archive_response import ChatArchiveResponse as ChatArchiveResponse from .message_search_params import MessageSearchParams as MessageSearchParams from .message_send_response import MessageSendResponse as MessageSendResponse from .message_update_params import MessageUpdateParams as MessageUpdateParams diff --git a/src/beeper_desktop_api/types/chat.py b/src/beeper_desktop_api/types/chat.py index fc67be8..837da86 100644 --- a/src/beeper_desktop_api/types/chat.py +++ b/src/beeper_desktop_api/types/chat.py @@ -32,6 +32,9 @@ class Chat(BaseModel): account_id: str = FieldInfo(alias="accountID") """Account ID this chat belongs to.""" + network: str + """Display-only human-readable network name (e.g., 'WhatsApp', 'Messenger').""" + participants: Participants """Chat participants information.""" diff --git a/src/beeper_desktop_api/types/chat_archive_response.py b/src/beeper_desktop_api/types/chat_archive_response.py deleted file mode 100644 index 49dac78..0000000 --- a/src/beeper_desktop_api/types/chat_archive_response.py +++ /dev/null @@ -1,12 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from .._models import BaseModel - -__all__ = ["ChatArchiveResponse"] - - -class ChatArchiveResponse(BaseModel): - success: Literal[True] - """Indicates the operation completed successfully""" diff --git a/src/beeper_desktop_api/types/chat_create_params.py b/src/beeper_desktop_api/types/chat_create_params.py index 686bfaa..f86b76d 100644 --- a/src/beeper_desktop_api/types/chat_create_params.py +++ b/src/beeper_desktop_api/types/chat_create_params.py @@ -2,15 +2,16 @@ 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"] +__all__ = ["ChatCreateParams", "Variant0", "Variant1", "Variant1User"] -class ChatCreateParams(TypedDict, total=False): +class Variant0(TypedDict, total=False): account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] """Account to create the chat on.""" @@ -26,5 +27,47 @@ 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"] + """Create mode. Defaults to 'create' when omitted.""" + title: str """Optional title for group chats; ignored for single chats on most platforms.""" + + +class Variant1(TypedDict, total=False): + account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] + """Account to start the chat on.""" + + mode: Required[Literal["start"]] + """Start mode for resolving/creating a direct chat from merged contact data.""" + + user: Required[Variant1User] + """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.""" + + message_text: Annotated[str, PropertyInfo(alias="messageText")] + """Optional first message content if the platform requires it to create the chat.""" + + +class Variant1User(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.""" + + +ChatCreateParams: TypeAlias = Union[Variant0, Variant1] diff --git a/src/beeper_desktop_api/types/chat_create_response.py b/src/beeper_desktop_api/types/chat_create_response.py index b092bdf..3f6b36f 100644 --- a/src/beeper_desktop_api/types/chat_create_response.py +++ b/src/beeper_desktop_api/types/chat_create_response.py @@ -1,5 +1,8 @@ # 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 from .._models import BaseModel @@ -10,3 +13,10 @@ class ChatCreateResponse(BaseModel): chat_id: str = FieldInfo(alias="chatID") """Newly created chat ID.""" + + status: Optional[Literal["existing", "created"]] = None + """Only returned in start mode. + + 'existing' means an existing chat was reused; 'created' means a new chat was + created. + """ diff --git a/src/beeper_desktop_api/types/chats/__init__.py b/src/beeper_desktop_api/types/chats/__init__.py index 650c90f..848b361 100644 --- a/src/beeper_desktop_api/types/chats/__init__.py +++ b/src/beeper_desktop_api/types/chats/__init__.py @@ -3,5 +3,3 @@ from __future__ import annotations from .reminder_create_params import ReminderCreateParams as ReminderCreateParams -from .reminder_create_response import ReminderCreateResponse as ReminderCreateResponse -from .reminder_delete_response import ReminderDeleteResponse as ReminderDeleteResponse diff --git a/src/beeper_desktop_api/types/chats/reminder_create_response.py b/src/beeper_desktop_api/types/chats/reminder_create_response.py deleted file mode 100644 index 22e798e..0000000 --- a/src/beeper_desktop_api/types/chats/reminder_create_response.py +++ /dev/null @@ -1,12 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from ..._models import BaseModel - -__all__ = ["ReminderCreateResponse"] - - -class ReminderCreateResponse(BaseModel): - success: Literal[True] - """Indicates the operation completed successfully""" diff --git a/src/beeper_desktop_api/types/chats/reminder_delete_response.py b/src/beeper_desktop_api/types/chats/reminder_delete_response.py deleted file mode 100644 index 06bdc4a..0000000 --- a/src/beeper_desktop_api/types/chats/reminder_delete_response.py +++ /dev/null @@ -1,12 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing_extensions import Literal - -from ..._models import BaseModel - -__all__ = ["ReminderDeleteResponse"] - - -class ReminderDeleteResponse(BaseModel): - success: Literal[True] - """Indicates the operation completed successfully""" diff --git a/tests/api_resources/chats/test_reminders.py b/tests/api_resources/chats/test_reminders.py index 17388c8..ea4febb 100644 --- a/tests/api_resources/chats/test_reminders.py +++ b/tests/api_resources/chats/test_reminders.py @@ -7,9 +7,7 @@ import pytest -from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types.chats import ReminderCreateResponse, ReminderDeleteResponse base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -23,7 +21,7 @@ def test_method_create(self, client: BeeperDesktop) -> None: chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reminder={"remind_at_ms": 0}, ) - assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) + assert reminder is None @parametrize def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: @@ -34,7 +32,7 @@ def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: "dismiss_on_incoming_message": True, }, ) - assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) + assert reminder is None @parametrize def test_raw_response_create(self, client: BeeperDesktop) -> None: @@ -46,7 +44,7 @@ def test_raw_response_create(self, client: BeeperDesktop) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = response.parse() - assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) + assert reminder is None @parametrize def test_streaming_response_create(self, client: BeeperDesktop) -> None: @@ -58,7 +56,7 @@ def test_streaming_response_create(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = response.parse() - assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) + assert reminder is None assert cast(Any, response.is_closed) is True @@ -75,7 +73,7 @@ def test_method_delete(self, client: BeeperDesktop) -> None: reminder = client.chats.reminders.delete( "!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(ReminderDeleteResponse, reminder, path=["response"]) + assert reminder is None @parametrize def test_raw_response_delete(self, client: BeeperDesktop) -> None: @@ -86,7 +84,7 @@ def test_raw_response_delete(self, client: BeeperDesktop) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = response.parse() - assert_matches_type(ReminderDeleteResponse, reminder, path=["response"]) + assert reminder is None @parametrize def test_streaming_response_delete(self, client: BeeperDesktop) -> None: @@ -97,7 +95,7 @@ def test_streaming_response_delete(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = response.parse() - assert_matches_type(ReminderDeleteResponse, reminder, path=["response"]) + assert reminder is None assert cast(Any, response.is_closed) is True @@ -120,7 +118,7 @@ async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reminder={"remind_at_ms": 0}, ) - assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) + assert reminder is None @parametrize async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -131,7 +129,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesk "dismiss_on_incoming_message": True, }, ) - assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) + assert reminder is None @parametrize async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: @@ -143,7 +141,7 @@ async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = await response.parse() - assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) + assert reminder is None @parametrize async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: @@ -155,7 +153,7 @@ async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = await response.parse() - assert_matches_type(ReminderCreateResponse, reminder, path=["response"]) + assert reminder is None assert cast(Any, response.is_closed) is True @@ -172,7 +170,7 @@ async def test_method_delete(self, async_client: AsyncBeeperDesktop) -> None: reminder = await async_client.chats.reminders.delete( "!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(ReminderDeleteResponse, reminder, path=["response"]) + assert reminder is None @parametrize async def test_raw_response_delete(self, async_client: AsyncBeeperDesktop) -> None: @@ -183,7 +181,7 @@ async def test_raw_response_delete(self, async_client: AsyncBeeperDesktop) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = await response.parse() - assert_matches_type(ReminderDeleteResponse, reminder, path=["response"]) + assert reminder is None @parametrize async def test_streaming_response_delete(self, async_client: AsyncBeeperDesktop) -> None: @@ -194,7 +192,7 @@ async def test_streaming_response_delete(self, async_client: AsyncBeeperDesktop) assert response.http_request.headers.get("X-Stainless-Lang") == "python" reminder = await response.parse() - assert_matches_type(ReminderDeleteResponse, reminder, path=["response"]) + assert reminder is None assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index f18dd47..f6fde7c 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -13,7 +13,6 @@ Chat, ChatListResponse, ChatCreateResponse, - ChatArchiveResponse, ) from beeper_desktop_api._utils import parse_datetime from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorNoLimit, AsyncCursorNoLimit @@ -25,7 +24,7 @@ class TestChats: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) @parametrize - def test_method_create(self, client: BeeperDesktop) -> None: + def test_method_create_overload_1(self, client: BeeperDesktop) -> None: chat = client.chats.create( account_id="accountID", participant_ids=["string"], @@ -34,18 +33,19 @@ def test_method_create(self, client: BeeperDesktop) -> None: assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize - def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: + def test_method_create_with_all_params_overload_1(self, client: BeeperDesktop) -> None: chat = client.chats.create( account_id="accountID", participant_ids=["string"], type="single", message_text="messageText", + mode="create", title="title", ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize - def test_raw_response_create(self, client: BeeperDesktop) -> None: + def test_raw_response_create_overload_1(self, client: BeeperDesktop) -> None: response = client.chats.with_raw_response.create( account_id="accountID", participant_ids=["string"], @@ -58,7 +58,7 @@ def test_raw_response_create(self, client: BeeperDesktop) -> None: assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize - def test_streaming_response_create(self, client: BeeperDesktop) -> None: + def test_streaming_response_create_overload_1(self, client: BeeperDesktop) -> None: with client.chats.with_streaming_response.create( account_id="accountID", participant_ids=["string"], @@ -72,6 +72,60 @@ def test_streaming_response_create(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True + @parametrize + def test_method_create_overload_2(self, client: BeeperDesktop) -> None: + chat = client.chats.create( + account_id="accountID", + mode="start", + user={}, + ) + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + @parametrize + def test_method_create_with_all_params_overload_2(self, client: BeeperDesktop) -> None: + chat = client.chats.create( + 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_overload_2(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.create( + account_id="accountID", + mode="start", + user={}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + @parametrize + def test_streaming_response_create_overload_2(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.create( + account_id="accountID", + mode="start", + user={}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + @parametrize def test_method_retrieve(self, client: BeeperDesktop) -> None: chat = client.chats.retrieve( @@ -160,7 +214,7 @@ def test_method_archive(self, client: BeeperDesktop) -> None: chat = client.chats.archive( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(ChatArchiveResponse, chat, path=["response"]) + assert chat is None @parametrize def test_method_archive_with_all_params(self, client: BeeperDesktop) -> None: @@ -168,7 +222,7 @@ def test_method_archive_with_all_params(self, client: BeeperDesktop) -> None: chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", archived=True, ) - assert_matches_type(ChatArchiveResponse, chat, path=["response"]) + assert chat is None @parametrize def test_raw_response_archive(self, client: BeeperDesktop) -> None: @@ -179,7 +233,7 @@ def test_raw_response_archive(self, client: BeeperDesktop) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = response.parse() - assert_matches_type(ChatArchiveResponse, chat, path=["response"]) + assert chat is None @parametrize def test_streaming_response_archive(self, client: BeeperDesktop) -> None: @@ -190,7 +244,7 @@ def test_streaming_response_archive(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = response.parse() - assert_matches_type(ChatArchiveResponse, chat, path=["response"]) + assert chat is None assert cast(Any, response.is_closed) is True @@ -254,7 +308,7 @@ class TestAsyncChats: ) @parametrize - async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: + async def test_method_create_overload_1(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.create( account_id="accountID", participant_ids=["string"], @@ -263,18 +317,19 @@ async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize - async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + async def test_method_create_with_all_params_overload_1(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.create( account_id="accountID", participant_ids=["string"], type="single", message_text="messageText", + mode="create", title="title", ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize - async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: + async def test_raw_response_create_overload_1(self, async_client: AsyncBeeperDesktop) -> None: response = await async_client.chats.with_raw_response.create( account_id="accountID", participant_ids=["string"], @@ -287,7 +342,7 @@ async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> No assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize - async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: + async def test_streaming_response_create_overload_1(self, async_client: AsyncBeeperDesktop) -> None: async with async_client.chats.with_streaming_response.create( account_id="accountID", participant_ids=["string"], @@ -301,6 +356,60 @@ async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) assert cast(Any, response.is_closed) is True + @parametrize + async def test_method_create_overload_2(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.create( + account_id="accountID", + mode="start", + user={}, + ) + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + @parametrize + async def test_method_create_with_all_params_overload_2(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.create( + 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_overload_2(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.create( + account_id="accountID", + mode="start", + user={}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + @parametrize + async def test_streaming_response_create_overload_2(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.create( + account_id="accountID", + mode="start", + user={}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(ChatCreateResponse, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + @parametrize async def test_method_retrieve(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.retrieve( @@ -389,7 +498,7 @@ async def test_method_archive(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.archive( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(ChatArchiveResponse, chat, path=["response"]) + assert chat is None @parametrize async def test_method_archive_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -397,7 +506,7 @@ async def test_method_archive_with_all_params(self, async_client: AsyncBeeperDes chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", archived=True, ) - assert_matches_type(ChatArchiveResponse, chat, path=["response"]) + assert chat is None @parametrize async def test_raw_response_archive(self, async_client: AsyncBeeperDesktop) -> None: @@ -408,7 +517,7 @@ async def test_raw_response_archive(self, async_client: AsyncBeeperDesktop) -> N assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = await response.parse() - assert_matches_type(ChatArchiveResponse, chat, path=["response"]) + assert chat is None @parametrize async def test_streaming_response_archive(self, async_client: AsyncBeeperDesktop) -> None: @@ -419,7 +528,7 @@ async def test_streaming_response_archive(self, async_client: AsyncBeeperDesktop assert response.http_request.headers.get("X-Stainless-Lang") == "python" chat = await response.parse() - assert_matches_type(ChatArchiveResponse, chat, path=["response"]) + assert chat is None assert cast(Any, response.is_closed) is True From 841e08e9b4ca23f28664ddff2d3cbdb0fe21f0d5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 03:44:09 +0000 Subject: [PATCH 63/98] chore: format all `api.md` files --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e8cf3e3..99df50b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ format = { chain = [ # run formatting again to fix any inconsistencies when imports are stripped "format:ruff", ]} -"format:docs" = "python scripts/utils/ruffen-docs.py README.md api.md" +"format:docs" = "bash -c 'python scripts/utils/ruffen-docs.py README.md $(find . -type f -name api.md)'" "format:ruff" = "ruff format" "lint" = { chain = [ From 9d03ffd2d1fb1100db00e2b3ca25aebf4de98bf8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 01:04:39 +0000 Subject: [PATCH 64/98] feat(api): manual updates --- .stats.yml | 8 +- README.md | 17 +- api.md | 28 ++ src/beeper_desktop_api/_client.py | 39 ++- src/beeper_desktop_api/resources/__init__.py | 14 + .../resources/accounts/contacts.py | 144 +++++++- .../resources/chats/__init__.py | 14 + .../resources/chats/chats.py | 236 +++---------- .../resources/chats/messages/__init__.py | 33 ++ .../resources/chats/messages/messages.py | 112 +++++++ .../resources/chats/messages/reactions.py | 310 ++++++++++++++++++ src/beeper_desktop_api/resources/info.py | 141 ++++++++ src/beeper_desktop_api/types/__init__.py | 1 + src/beeper_desktop_api/types/account.py | 3 - .../types/accounts/__init__.py | 1 + .../types/accounts/contact_list_params.py | 24 ++ src/beeper_desktop_api/types/chat.py | 3 - .../types/chat_create_params.py | 46 +-- .../types/chats/messages/__init__.py | 8 + .../chats/messages/reaction_add_params.py | 20 ++ .../chats/messages/reaction_add_response.py | 26 ++ .../chats/messages/reaction_delete_params.py | 17 + .../messages/reaction_delete_response.py | 23 ++ .../types/info_retrieve_response.py | 92 ++++++ tests/api_resources/accounts/test_contacts.py | 100 ++++++ .../api_resources/chats/messages/__init__.py | 1 + .../chats/messages/test_reactions.py | 259 +++++++++++++++ tests/api_resources/test_chats.py | 184 ++--------- tests/api_resources/test_info.py | 74 +++++ 29 files changed, 1590 insertions(+), 388 deletions(-) create mode 100644 src/beeper_desktop_api/resources/chats/messages/__init__.py create mode 100644 src/beeper_desktop_api/resources/chats/messages/messages.py create mode 100644 src/beeper_desktop_api/resources/chats/messages/reactions.py create mode 100644 src/beeper_desktop_api/resources/info.py create mode 100644 src/beeper_desktop_api/types/accounts/contact_list_params.py create mode 100644 src/beeper_desktop_api/types/chats/messages/__init__.py create mode 100644 src/beeper_desktop_api/types/chats/messages/reaction_add_params.py create mode 100644 src/beeper_desktop_api/types/chats/messages/reaction_add_response.py create mode 100644 src/beeper_desktop_api/types/chats/messages/reaction_delete_params.py create mode 100644 src/beeper_desktop_api/types/chats/messages/reaction_delete_response.py create mode 100644 src/beeper_desktop_api/types/info_retrieve_response.py create mode 100644 tests/api_resources/chats/messages/__init__.py create mode 100644 tests/api_resources/chats/messages/test_reactions.py create mode 100644 tests/api_resources/test_info.py diff --git a/.stats.yml b/.stats.yml index 26acc07..3e8b0a3 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 19 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-3f6555bfea11258c6e8882455360ae08202067a270313716ee15571b83ada577.yml -openapi_spec_hash: 020324a708981384284f8fad8ac8c66c -config_hash: 48ff2d23c2ebc82bd3c15787f0041684 +configured_endpoints: 23 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-ee25e67fc85ccc86cedb2ca0865385709877582132103e0afa68d7b43551784a.yml +openapi_spec_hash: d41fd99c9a8645a1fd69c519cd25a637 +config_hash: 01fba090f51e67f4dfd3a4fa6c53d665 diff --git a/README.md b/README.md index ffd504e..c0c9be9 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,15 @@ 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). +## 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-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. + ## Documentation The REST API documentation can be found on [developers.beeper.com](https://developers.beeper.com/desktop-api/). The full API of this library can be found in [api.md](api.md). @@ -209,12 +218,10 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() -chat = client.chats.create( - account_id="accountID", - mode="start", - user={}, +client.chats.reminders.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, ) -print(chat.user) ``` ## File uploads diff --git a/api.md b/api.md index 59886e9..5efec0a 100644 --- a/api.md +++ b/api.md @@ -39,6 +39,7 @@ from beeper_desktop_api.types.accounts import ContactSearchResponse Methods: +- client.accounts.contacts.list(account_id, \*\*params) -> SyncCursorSearch[User] - client.accounts.contacts.search(account_id, \*\*params) -> ContactSearchResponse # Chats @@ -64,6 +65,21 @@ Methods: - client.chats.reminders.create(chat_id, \*\*params) -> None - client.chats.reminders.delete(chat_id) -> None +## Messages + +### Reactions + +Types: + +```python +from beeper_desktop_api.types.chats.messages import ReactionDeleteResponse, ReactionAddResponse +``` + +Methods: + +- client.chats.messages.reactions.delete(message_id, \*, chat_id, \*\*params) -> ReactionDeleteResponse +- client.chats.messages.reactions.add(message_id, \*, chat_id, \*\*params) -> ReactionAddResponse + # Messages Types: @@ -97,3 +113,15 @@ Methods: - client.assets.serve(\*\*params) -> None - client.assets.upload(\*\*params) -> AssetUploadResponse - client.assets.upload_base64(\*\*params) -> AssetUploadBase64Response + +# Info + +Types: + +```python +from beeper_desktop_api.types import InfoRetrieveResponse +``` + +Methods: + +- client.info.retrieve() -> InfoRetrieveResponse diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index 5bfa249..2dc0bf9 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -50,7 +50,8 @@ from .types.search_response import SearchResponse if TYPE_CHECKING: - from .resources import chats, assets, accounts, messages + from .resources import info, chats, assets, accounts, messages + from .resources.info import InfoResource, AsyncInfoResource from .resources.assets import AssetsResource, AsyncAssetsResource from .resources.messages import MessagesResource, AsyncMessagesResource from .resources.chats.chats import ChatsResource, AsyncChatsResource @@ -151,6 +152,12 @@ def assets(self) -> AssetsResource: return AssetsResource(self) + @cached_property + def info(self) -> InfoResource: + from .resources.info import InfoResource + + return InfoResource(self) + @cached_property def with_raw_response(self) -> BeeperDesktopWithRawResponse: return BeeperDesktopWithRawResponse(self) @@ -439,6 +446,12 @@ def assets(self) -> AsyncAssetsResource: return AsyncAssetsResource(self) + @cached_property + def info(self) -> AsyncInfoResource: + from .resources.info import AsyncInfoResource + + return AsyncInfoResource(self) + @cached_property def with_raw_response(self) -> AsyncBeeperDesktopWithRawResponse: return AsyncBeeperDesktopWithRawResponse(self) @@ -685,6 +698,12 @@ def assets(self) -> assets.AssetsResourceWithRawResponse: return AssetsResourceWithRawResponse(self._client.assets) + @cached_property + def info(self) -> info.InfoResourceWithRawResponse: + from .resources.info import InfoResourceWithRawResponse + + return InfoResourceWithRawResponse(self._client.info) + class AsyncBeeperDesktopWithRawResponse: _client: AsyncBeeperDesktop @@ -727,6 +746,12 @@ def assets(self) -> assets.AsyncAssetsResourceWithRawResponse: return AsyncAssetsResourceWithRawResponse(self._client.assets) + @cached_property + def info(self) -> info.AsyncInfoResourceWithRawResponse: + from .resources.info import AsyncInfoResourceWithRawResponse + + return AsyncInfoResourceWithRawResponse(self._client.info) + class BeeperDesktopWithStreamedResponse: _client: BeeperDesktop @@ -769,6 +794,12 @@ def assets(self) -> assets.AssetsResourceWithStreamingResponse: return AssetsResourceWithStreamingResponse(self._client.assets) + @cached_property + def info(self) -> info.InfoResourceWithStreamingResponse: + from .resources.info import InfoResourceWithStreamingResponse + + return InfoResourceWithStreamingResponse(self._client.info) + class AsyncBeeperDesktopWithStreamedResponse: _client: AsyncBeeperDesktop @@ -811,6 +842,12 @@ def assets(self) -> assets.AsyncAssetsResourceWithStreamingResponse: return AsyncAssetsResourceWithStreamingResponse(self._client.assets) + @cached_property + def info(self) -> info.AsyncInfoResourceWithStreamingResponse: + from .resources.info import AsyncInfoResourceWithStreamingResponse + + return AsyncInfoResourceWithStreamingResponse(self._client.info) + Client = BeeperDesktop diff --git a/src/beeper_desktop_api/resources/__init__.py b/src/beeper_desktop_api/resources/__init__.py index 391042a..a066e9b 100644 --- a/src/beeper_desktop_api/resources/__init__.py +++ b/src/beeper_desktop_api/resources/__init__.py @@ -1,5 +1,13 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from .info import ( + InfoResource, + AsyncInfoResource, + InfoResourceWithRawResponse, + AsyncInfoResourceWithRawResponse, + InfoResourceWithStreamingResponse, + AsyncInfoResourceWithStreamingResponse, +) from .chats import ( ChatsResource, AsyncChatsResource, @@ -58,4 +66,10 @@ "AsyncAssetsResourceWithRawResponse", "AssetsResourceWithStreamingResponse", "AsyncAssetsResourceWithStreamingResponse", + "InfoResource", + "AsyncInfoResource", + "InfoResourceWithRawResponse", + "AsyncInfoResourceWithRawResponse", + "InfoResourceWithStreamingResponse", + "AsyncInfoResourceWithStreamingResponse", ] diff --git a/src/beeper_desktop_api/resources/accounts/contacts.py b/src/beeper_desktop_api/resources/accounts/contacts.py index f0363f3..02749f1 100644 --- a/src/beeper_desktop_api/resources/accounts/contacts.py +++ b/src/beeper_desktop_api/resources/accounts/contacts.py @@ -2,9 +2,11 @@ from __future__ import annotations +from typing_extensions import Literal + import httpx -from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource @@ -14,8 +16,10 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from ..._base_client import make_request_options -from ...types.accounts import contact_search_params +from ...pagination import SyncCursorSearch, AsyncCursorSearch +from ..._base_client import AsyncPaginator, make_request_options +from ...types.accounts import contact_list_params, contact_search_params +from ...types.shared.user import User from ...types.accounts.contact_search_response import ContactSearchResponse __all__ = ["ContactsResource", "AsyncContactsResource"] @@ -43,6 +47,67 @@ def with_streaming_response(self) -> ContactsResourceWithStreamingResponse: """ return ContactsResourceWithStreamingResponse(self) + def list( + self, + account_id: str, + *, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + limit: int | Omit = omit, + query: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SyncCursorSearch[User]: + """ + List merged contacts for a specific account with cursor-based pagination. + + Args: + account_id: Account ID this resource belongs to. + + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. + + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + + limit: Maximum contacts to return per page. + + query: Optional search query for blended contact lookup. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not 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", + page=SyncCursorSearch[User], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "cursor": cursor, + "direction": direction, + "limit": limit, + "query": query, + }, + contact_list_params.ContactListParams, + ), + ), + model=User, + ) + def search( self, account_id: str, @@ -109,6 +174,67 @@ def with_streaming_response(self) -> AsyncContactsResourceWithStreamingResponse: """ return AsyncContactsResourceWithStreamingResponse(self) + def list( + self, + account_id: str, + *, + cursor: str | Omit = omit, + direction: Literal["after", "before"] | Omit = omit, + limit: int | Omit = omit, + query: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncPaginator[User, AsyncCursorSearch[User]]: + """ + List merged contacts for a specific account with cursor-based pagination. + + Args: + account_id: Account ID this resource belongs to. + + cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. + + direction: Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + + limit: Maximum contacts to return per page. + + query: Optional search query for blended contact lookup. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not 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", + page=AsyncCursorSearch[User], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "cursor": cursor, + "direction": direction, + "limit": limit, + "query": query, + }, + contact_list_params.ContactListParams, + ), + ), + model=User, + ) + async def search( self, account_id: str, @@ -157,6 +283,9 @@ class ContactsResourceWithRawResponse: def __init__(self, contacts: ContactsResource) -> None: self._contacts = contacts + self.list = to_raw_response_wrapper( + contacts.list, + ) self.search = to_raw_response_wrapper( contacts.search, ) @@ -166,6 +295,9 @@ class AsyncContactsResourceWithRawResponse: def __init__(self, contacts: AsyncContactsResource) -> None: self._contacts = contacts + self.list = async_to_raw_response_wrapper( + contacts.list, + ) self.search = async_to_raw_response_wrapper( contacts.search, ) @@ -175,6 +307,9 @@ class ContactsResourceWithStreamingResponse: def __init__(self, contacts: ContactsResource) -> None: self._contacts = contacts + self.list = to_streamed_response_wrapper( + contacts.list, + ) self.search = to_streamed_response_wrapper( contacts.search, ) @@ -184,6 +319,9 @@ class AsyncContactsResourceWithStreamingResponse: def __init__(self, contacts: AsyncContactsResource) -> None: self._contacts = contacts + self.list = async_to_streamed_response_wrapper( + contacts.list, + ) self.search = async_to_streamed_response_wrapper( contacts.search, ) diff --git a/src/beeper_desktop_api/resources/chats/__init__.py b/src/beeper_desktop_api/resources/chats/__init__.py index e26ae7f..83986f5 100644 --- a/src/beeper_desktop_api/resources/chats/__init__.py +++ b/src/beeper_desktop_api/resources/chats/__init__.py @@ -8,6 +8,14 @@ ChatsResourceWithStreamingResponse, AsyncChatsResourceWithStreamingResponse, ) +from .messages import ( + MessagesResource, + AsyncMessagesResource, + MessagesResourceWithRawResponse, + AsyncMessagesResourceWithRawResponse, + MessagesResourceWithStreamingResponse, + AsyncMessagesResourceWithStreamingResponse, +) from .reminders import ( RemindersResource, AsyncRemindersResource, @@ -24,6 +32,12 @@ "AsyncRemindersResourceWithRawResponse", "RemindersResourceWithStreamingResponse", "AsyncRemindersResourceWithStreamingResponse", + "MessagesResource", + "AsyncMessagesResource", + "MessagesResourceWithRawResponse", + "AsyncMessagesResourceWithRawResponse", + "MessagesResourceWithStreamingResponse", + "AsyncMessagesResourceWithStreamingResponse", "ChatsResource", "AsyncChatsResource", "ChatsResourceWithRawResponse", diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index 55f8f91..4682744 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -4,13 +4,13 @@ from typing import Union, Optional from datetime import datetime -from typing_extensions import Literal, overload +from typing_extensions import Literal import httpx 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 required_args, maybe_transform, async_maybe_transform +from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from .reminders import ( RemindersResource, @@ -30,6 +30,14 @@ from ...pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorNoLimit, AsyncCursorNoLimit from ...types.chat import Chat from ..._base_client import AsyncPaginator, make_request_options +from .messages.messages import ( + MessagesResource, + AsyncMessagesResource, + MessagesResourceWithRawResponse, + AsyncMessagesResourceWithRawResponse, + MessagesResourceWithStreamingResponse, + AsyncMessagesResourceWithStreamingResponse, +) from ...types.chat_list_response import ChatListResponse from ...types.chat_create_response import ChatCreateResponse @@ -44,6 +52,11 @@ def reminders(self) -> RemindersResource: """Manage reminders for chats""" return RemindersResource(self._client) + @cached_property + def messages(self) -> MessagesResource: + """Manage chat messages""" + return MessagesResource(self._client) + @cached_property def with_raw_response(self) -> ChatsResourceWithRawResponse: """ @@ -63,60 +76,10 @@ def with_streaming_response(self) -> ChatsResourceWithStreamingResponse: """ return ChatsResourceWithStreamingResponse(self) - @overload - def create( - self, - *, - account_id: str, - participant_ids: SequenceNotStr[str], - type: Literal["single", "group"], - message_text: str | Omit = omit, - mode: Literal["create"] | Omit = omit, - title: str | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - 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'). - - Args: - account_id: Account to create the chat on. - - participant_ids: User IDs to include in the new chat. - - type: Chat type to create: 'single' requires exactly one participantID; 'group' - supports multiple participants and optional title. - - message_text: Optional first message content if the platform requires it to create the chat. - - mode: Create mode. Defaults to 'create' when omitted. - - title: Optional title for group chats; ignored for single chats on most platforms. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - ... - - @overload def create( self, *, - account_id: str, - mode: Literal["start"], - user: chat_create_params.Variant1User, - allow_invite: bool | Omit = omit, - message_text: str | Omit = omit, + chat: chat_create_params.Chat | 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, @@ -129,16 +92,6 @@ def create( user data (mode='start'). Args: - account_id: Account to start the chat on. - - mode: Start mode for resolving/creating a direct chat from merged contact data. - - user: Merged user-like contact payload used to resolve the best identifier. - - allow_invite: 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. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -147,42 +100,9 @@ def create( timeout: Override the client-level default timeout for this request, in seconds """ - ... - - @required_args(["account_id", "participant_ids", "type"], ["account_id", "mode", "user"]) - def create( - self, - *, - account_id: str, - participant_ids: SequenceNotStr[str] | Omit = omit, - type: Literal["single", "group"] | Omit = omit, - message_text: str | Omit = omit, - mode: Literal["create"] | Literal["start"] | Omit = omit, - title: str | Omit = omit, - user: chat_create_params.Variant1User | Omit = omit, - allow_invite: bool | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ChatCreateResponse: return self._post( "/v1/chats", - body=maybe_transform( - { - "account_id": account_id, - "participant_ids": participant_ids, - "type": type, - "message_text": message_text, - "mode": mode, - "title": title, - "user": user, - "allow_invite": allow_invite, - }, - chat_create_params.ChatCreateParams, - ), + body=maybe_transform(chat, chat_create_params.ChatCreateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -436,6 +356,11 @@ def reminders(self) -> AsyncRemindersResource: """Manage reminders for chats""" return AsyncRemindersResource(self._client) + @cached_property + def messages(self) -> AsyncMessagesResource: + """Manage chat messages""" + return AsyncMessagesResource(self._client) + @cached_property def with_raw_response(self) -> AsyncChatsResourceWithRawResponse: """ @@ -455,16 +380,10 @@ def with_streaming_response(self) -> AsyncChatsResourceWithStreamingResponse: """ return AsyncChatsResourceWithStreamingResponse(self) - @overload async def create( self, *, - account_id: str, - participant_ids: SequenceNotStr[str], - type: Literal["single", "group"], - message_text: str | Omit = omit, - mode: Literal["create"] | Omit = omit, - title: str | Omit = omit, + chat: chat_create_params.Chat | 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, @@ -477,19 +396,6 @@ async def create( user data (mode='start'). Args: - account_id: Account to create the chat on. - - participant_ids: User IDs to include in the new chat. - - type: Chat type to create: 'single' requires exactly one participantID; 'group' - supports multiple participants and optional title. - - message_text: Optional first message content if the platform requires it to create the chat. - - mode: Create mode. Defaults to 'create' when omitted. - - title: Optional title for group chats; ignored for single chats on most platforms. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -498,83 +404,9 @@ async def create( timeout: Override the client-level default timeout for this request, in seconds """ - ... - - @overload - async def create( - self, - *, - account_id: str, - mode: Literal["start"], - user: chat_create_params.Variant1User, - allow_invite: bool | Omit = omit, - message_text: str | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - 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'). - - Args: - account_id: Account to start the chat on. - - mode: Start mode for resolving/creating a direct chat from merged contact data. - - user: Merged user-like contact payload used to resolve the best identifier. - - allow_invite: 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. - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - ... - - @required_args(["account_id", "participant_ids", "type"], ["account_id", "mode", "user"]) - async def create( - self, - *, - account_id: str, - participant_ids: SequenceNotStr[str] | Omit = omit, - type: Literal["single", "group"] | Omit = omit, - message_text: str | Omit = omit, - mode: Literal["create"] | Literal["start"] | Omit = omit, - title: str | Omit = omit, - user: chat_create_params.Variant1User | Omit = omit, - allow_invite: bool | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ChatCreateResponse: return await self._post( "/v1/chats", - body=await async_maybe_transform( - { - "account_id": account_id, - "participant_ids": participant_ids, - "type": type, - "message_text": message_text, - "mode": mode, - "title": title, - "user": user, - "allow_invite": allow_invite, - }, - chat_create_params.ChatCreateParams, - ), + body=await async_maybe_transform(chat, chat_create_params.ChatCreateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -845,6 +677,11 @@ def reminders(self) -> RemindersResourceWithRawResponse: """Manage reminders for chats""" return RemindersResourceWithRawResponse(self._chats.reminders) + @cached_property + def messages(self) -> MessagesResourceWithRawResponse: + """Manage chat messages""" + return MessagesResourceWithRawResponse(self._chats.messages) + class AsyncChatsResourceWithRawResponse: def __init__(self, chats: AsyncChatsResource) -> None: @@ -871,6 +708,11 @@ def reminders(self) -> AsyncRemindersResourceWithRawResponse: """Manage reminders for chats""" return AsyncRemindersResourceWithRawResponse(self._chats.reminders) + @cached_property + def messages(self) -> AsyncMessagesResourceWithRawResponse: + """Manage chat messages""" + return AsyncMessagesResourceWithRawResponse(self._chats.messages) + class ChatsResourceWithStreamingResponse: def __init__(self, chats: ChatsResource) -> None: @@ -897,6 +739,11 @@ def reminders(self) -> RemindersResourceWithStreamingResponse: """Manage reminders for chats""" return RemindersResourceWithStreamingResponse(self._chats.reminders) + @cached_property + def messages(self) -> MessagesResourceWithStreamingResponse: + """Manage chat messages""" + return MessagesResourceWithStreamingResponse(self._chats.messages) + class AsyncChatsResourceWithStreamingResponse: def __init__(self, chats: AsyncChatsResource) -> None: @@ -922,3 +769,8 @@ def __init__(self, chats: AsyncChatsResource) -> None: def reminders(self) -> AsyncRemindersResourceWithStreamingResponse: """Manage reminders for chats""" return AsyncRemindersResourceWithStreamingResponse(self._chats.reminders) + + @cached_property + def messages(self) -> AsyncMessagesResourceWithStreamingResponse: + """Manage chat messages""" + return AsyncMessagesResourceWithStreamingResponse(self._chats.messages) diff --git a/src/beeper_desktop_api/resources/chats/messages/__init__.py b/src/beeper_desktop_api/resources/chats/messages/__init__.py new file mode 100644 index 0000000..8028430 --- /dev/null +++ b/src/beeper_desktop_api/resources/chats/messages/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .messages import ( + MessagesResource, + AsyncMessagesResource, + MessagesResourceWithRawResponse, + AsyncMessagesResourceWithRawResponse, + MessagesResourceWithStreamingResponse, + AsyncMessagesResourceWithStreamingResponse, +) +from .reactions import ( + ReactionsResource, + AsyncReactionsResource, + ReactionsResourceWithRawResponse, + AsyncReactionsResourceWithRawResponse, + ReactionsResourceWithStreamingResponse, + AsyncReactionsResourceWithStreamingResponse, +) + +__all__ = [ + "ReactionsResource", + "AsyncReactionsResource", + "ReactionsResourceWithRawResponse", + "AsyncReactionsResourceWithRawResponse", + "ReactionsResourceWithStreamingResponse", + "AsyncReactionsResourceWithStreamingResponse", + "MessagesResource", + "AsyncMessagesResource", + "MessagesResourceWithRawResponse", + "AsyncMessagesResourceWithRawResponse", + "MessagesResourceWithStreamingResponse", + "AsyncMessagesResourceWithStreamingResponse", +] diff --git a/src/beeper_desktop_api/resources/chats/messages/messages.py b/src/beeper_desktop_api/resources/chats/messages/messages.py new file mode 100644 index 0000000..a6a32d7 --- /dev/null +++ b/src/beeper_desktop_api/resources/chats/messages/messages.py @@ -0,0 +1,112 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .reactions import ( + ReactionsResource, + AsyncReactionsResource, + ReactionsResourceWithRawResponse, + AsyncReactionsResourceWithRawResponse, + ReactionsResourceWithStreamingResponse, + AsyncReactionsResourceWithStreamingResponse, +) +from ...._compat import cached_property +from ...._resource import SyncAPIResource, AsyncAPIResource + +__all__ = ["MessagesResource", "AsyncMessagesResource"] + + +class MessagesResource(SyncAPIResource): + """Manage chat messages""" + + @cached_property + def reactions(self) -> ReactionsResource: + """Manage message reactions""" + return ReactionsResource(self._client) + + @cached_property + def with_raw_response(self) -> MessagesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return MessagesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> MessagesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return MessagesResourceWithStreamingResponse(self) + + +class AsyncMessagesResource(AsyncAPIResource): + """Manage chat messages""" + + @cached_property + def reactions(self) -> AsyncReactionsResource: + """Manage message reactions""" + return AsyncReactionsResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncMessagesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncMessagesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncMessagesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return AsyncMessagesResourceWithStreamingResponse(self) + + +class MessagesResourceWithRawResponse: + def __init__(self, messages: MessagesResource) -> None: + self._messages = messages + + @cached_property + def reactions(self) -> ReactionsResourceWithRawResponse: + """Manage message reactions""" + return ReactionsResourceWithRawResponse(self._messages.reactions) + + +class AsyncMessagesResourceWithRawResponse: + def __init__(self, messages: AsyncMessagesResource) -> None: + self._messages = messages + + @cached_property + def reactions(self) -> AsyncReactionsResourceWithRawResponse: + """Manage message reactions""" + return AsyncReactionsResourceWithRawResponse(self._messages.reactions) + + +class MessagesResourceWithStreamingResponse: + def __init__(self, messages: MessagesResource) -> None: + self._messages = messages + + @cached_property + def reactions(self) -> ReactionsResourceWithStreamingResponse: + """Manage message reactions""" + return ReactionsResourceWithStreamingResponse(self._messages.reactions) + + +class AsyncMessagesResourceWithStreamingResponse: + def __init__(self, messages: AsyncMessagesResource) -> None: + self._messages = messages + + @cached_property + def reactions(self) -> AsyncReactionsResourceWithStreamingResponse: + """Manage message reactions""" + return AsyncReactionsResourceWithStreamingResponse(self._messages.reactions) diff --git a/src/beeper_desktop_api/resources/chats/messages/reactions.py b/src/beeper_desktop_api/resources/chats/messages/reactions.py new file mode 100644 index 0000000..d9e610d --- /dev/null +++ b/src/beeper_desktop_api/resources/chats/messages/reactions.py @@ -0,0 +1,310 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ...._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ...._utils import maybe_transform, async_maybe_transform +from ...._compat import cached_property +from ...._resource import SyncAPIResource, AsyncAPIResource +from ...._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ...._base_client import make_request_options +from ....types.chats.messages import reaction_add_params, reaction_delete_params +from ....types.chats.messages.reaction_add_response import ReactionAddResponse +from ....types.chats.messages.reaction_delete_response import ReactionDeleteResponse + +__all__ = ["ReactionsResource", "AsyncReactionsResource"] + + +class ReactionsResource(SyncAPIResource): + """Manage message reactions""" + + @cached_property + def with_raw_response(self) -> ReactionsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return ReactionsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ReactionsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return ReactionsResourceWithStreamingResponse(self) + + def delete( + self, + message_id: str, + *, + chat_id: str, + reaction_key: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ReactionDeleteResponse: + """ + Remove the authenticated user's reaction from an existing message. + + Args: + chat_id: Unique identifier of the chat. + + reaction_key: Reaction key to remove + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + 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", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"reaction_key": reaction_key}, reaction_delete_params.ReactionDeleteParams), + ), + cast_to=ReactionDeleteResponse, + ) + + def add( + self, + message_id: str, + *, + chat_id: str, + reaction_key: str, + transaction_id: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ReactionAddResponse: + """ + Add a reaction to an existing message. + + Args: + chat_id: Unique identifier of the chat. + + reaction_key: Reaction key to add (emoji, shortcode, or custom emoji key) + + transaction_id: Optional transaction ID for deduplication and local echo tracking + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + 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", + body=maybe_transform( + { + "reaction_key": reaction_key, + "transaction_id": transaction_id, + }, + reaction_add_params.ReactionAddParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ReactionAddResponse, + ) + + +class AsyncReactionsResource(AsyncAPIResource): + """Manage message reactions""" + + @cached_property + def with_raw_response(self) -> AsyncReactionsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncReactionsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncReactionsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return AsyncReactionsResourceWithStreamingResponse(self) + + async def delete( + self, + message_id: str, + *, + chat_id: str, + reaction_key: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ReactionDeleteResponse: + """ + Remove the authenticated user's reaction from an existing message. + + Args: + chat_id: Unique identifier of the chat. + + reaction_key: Reaction key to remove + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + 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", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"reaction_key": reaction_key}, reaction_delete_params.ReactionDeleteParams + ), + ), + cast_to=ReactionDeleteResponse, + ) + + async def add( + self, + message_id: str, + *, + chat_id: str, + reaction_key: str, + transaction_id: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ReactionAddResponse: + """ + Add a reaction to an existing message. + + Args: + chat_id: Unique identifier of the chat. + + reaction_key: Reaction key to add (emoji, shortcode, or custom emoji key) + + transaction_id: Optional transaction ID for deduplication and local echo tracking + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + 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", + body=await async_maybe_transform( + { + "reaction_key": reaction_key, + "transaction_id": transaction_id, + }, + reaction_add_params.ReactionAddParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ReactionAddResponse, + ) + + +class ReactionsResourceWithRawResponse: + def __init__(self, reactions: ReactionsResource) -> None: + self._reactions = reactions + + self.delete = to_raw_response_wrapper( + reactions.delete, + ) + self.add = to_raw_response_wrapper( + reactions.add, + ) + + +class AsyncReactionsResourceWithRawResponse: + def __init__(self, reactions: AsyncReactionsResource) -> None: + self._reactions = reactions + + self.delete = async_to_raw_response_wrapper( + reactions.delete, + ) + self.add = async_to_raw_response_wrapper( + reactions.add, + ) + + +class ReactionsResourceWithStreamingResponse: + def __init__(self, reactions: ReactionsResource) -> None: + self._reactions = reactions + + self.delete = to_streamed_response_wrapper( + reactions.delete, + ) + self.add = to_streamed_response_wrapper( + reactions.add, + ) + + +class AsyncReactionsResourceWithStreamingResponse: + def __init__(self, reactions: AsyncReactionsResource) -> None: + self._reactions = reactions + + self.delete = async_to_streamed_response_wrapper( + reactions.delete, + ) + self.add = async_to_streamed_response_wrapper( + reactions.add, + ) diff --git a/src/beeper_desktop_api/resources/info.py b/src/beeper_desktop_api/resources/info.py new file mode 100644 index 0000000..43a98bf --- /dev/null +++ b/src/beeper_desktop_api/resources/info.py @@ -0,0 +1,141 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from .._types import Body, Query, Headers, NotGiven, not_given +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.info_retrieve_response import InfoRetrieveResponse + +__all__ = ["InfoResource", "AsyncInfoResource"] + + +class InfoResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> InfoResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return InfoResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> InfoResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return InfoResourceWithStreamingResponse(self) + + def retrieve( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> InfoRetrieveResponse: + """ + Returns app, platform, server, and endpoint discovery metadata for this Beeper + Desktop instance. + """ + return self._get( + "/v1/info", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InfoRetrieveResponse, + ) + + +class AsyncInfoResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncInfoResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/beeper/desktop-api-python#accessing-raw-response-data-eg-headers + """ + return AsyncInfoResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncInfoResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/beeper/desktop-api-python#with_streaming_response + """ + return AsyncInfoResourceWithStreamingResponse(self) + + async def retrieve( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> InfoRetrieveResponse: + """ + Returns app, platform, server, and endpoint discovery metadata for this Beeper + Desktop instance. + """ + return await self._get( + "/v1/info", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=InfoRetrieveResponse, + ) + + +class InfoResourceWithRawResponse: + def __init__(self, info: InfoResource) -> None: + self._info = info + + self.retrieve = to_raw_response_wrapper( + info.retrieve, + ) + + +class AsyncInfoResourceWithRawResponse: + def __init__(self, info: AsyncInfoResource) -> None: + self._info = info + + self.retrieve = async_to_raw_response_wrapper( + info.retrieve, + ) + + +class InfoResourceWithStreamingResponse: + def __init__(self, info: InfoResource) -> None: + self._info = info + + self.retrieve = to_streamed_response_wrapper( + info.retrieve, + ) + + +class AsyncInfoResourceWithStreamingResponse: + def __init__(self, info: AsyncInfoResource) -> None: + self._info = info + + self.retrieve = async_to_streamed_response_wrapper( + info.retrieve, + ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index 77e1f16..9e7445a 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -26,6 +26,7 @@ from .message_search_params import MessageSearchParams as MessageSearchParams from .message_send_response import MessageSendResponse as MessageSendResponse from .message_update_params import MessageUpdateParams as MessageUpdateParams +from .info_retrieve_response import InfoRetrieveResponse as InfoRetrieveResponse from .asset_download_response import AssetDownloadResponse as AssetDownloadResponse from .message_update_response import MessageUpdateResponse as MessageUpdateResponse from .asset_upload_base64_params import AssetUploadBase64Params as AssetUploadBase64Params diff --git a/src/beeper_desktop_api/types/account.py b/src/beeper_desktop_api/types/account.py index 011c64e..ff00c78 100644 --- a/src/beeper_desktop_api/types/account.py +++ b/src/beeper_desktop_api/types/account.py @@ -14,8 +14,5 @@ class Account(BaseModel): account_id: str = FieldInfo(alias="accountID") """Chat account added to Beeper. Use this to route account-scoped actions.""" - network: str - """Display-only human-readable network name (e.g., 'WhatsApp', 'Messenger').""" - user: User """User the account belongs to.""" diff --git a/src/beeper_desktop_api/types/accounts/__init__.py b/src/beeper_desktop_api/types/accounts/__init__.py index 90dd1b9..2addd13 100644 --- a/src/beeper_desktop_api/types/accounts/__init__.py +++ b/src/beeper_desktop_api/types/accounts/__init__.py @@ -2,5 +2,6 @@ from __future__ import annotations +from .contact_list_params import ContactListParams as ContactListParams from .contact_search_params import ContactSearchParams as ContactSearchParams from .contact_search_response import ContactSearchResponse as ContactSearchResponse diff --git a/src/beeper_desktop_api/types/accounts/contact_list_params.py b/src/beeper_desktop_api/types/accounts/contact_list_params.py new file mode 100644 index 0000000..e452ddb --- /dev/null +++ b/src/beeper_desktop_api/types/accounts/contact_list_params.py @@ -0,0 +1,24 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, TypedDict + +__all__ = ["ContactListParams"] + + +class ContactListParams(TypedDict, total=False): + cursor: str + """Opaque pagination cursor; do not inspect. Use together with 'direction'.""" + + direction: Literal["after", "before"] + """ + Pagination direction used with 'cursor': 'before' fetches older results, 'after' + fetches newer results. Defaults to 'before' when only 'cursor' is provided. + """ + + limit: int + """Maximum contacts to return per page.""" + + query: str + """Optional search query for blended contact lookup.""" diff --git a/src/beeper_desktop_api/types/chat.py b/src/beeper_desktop_api/types/chat.py index 837da86..fc67be8 100644 --- a/src/beeper_desktop_api/types/chat.py +++ b/src/beeper_desktop_api/types/chat.py @@ -32,9 +32,6 @@ class Chat(BaseModel): account_id: str = FieldInfo(alias="accountID") """Account ID this chat belongs to.""" - network: str - """Display-only human-readable network name (e.g., 'WhatsApp', 'Messenger').""" - participants: Participants """Chat participants information.""" diff --git a/src/beeper_desktop_api/types/chat_create_params.py b/src/beeper_desktop_api/types/chat_create_params.py index f86b76d..b0314d5 100644 --- a/src/beeper_desktop_api/types/chat_create_params.py +++ b/src/beeper_desktop_api/types/chat_create_params.py @@ -8,10 +8,14 @@ from .._types import SequenceNotStr from .._utils import PropertyInfo -__all__ = ["ChatCreateParams", "Variant0", "Variant1", "Variant1User"] +__all__ = ["ChatCreateParams", "Chat", "ChatUnionMember0", "ChatUnionMember1", "ChatUnionMember1User"] -class Variant0(TypedDict, total=False): +class ChatCreateParams(TypedDict, total=False): + chat: Chat + + +class ChatUnionMember0(TypedDict, total=False): account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] """Account to create the chat on.""" @@ -34,24 +38,7 @@ class Variant0(TypedDict, total=False): """Optional title for group chats; ignored for single chats on most platforms.""" -class Variant1(TypedDict, total=False): - account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] - """Account to start the chat on.""" - - mode: Required[Literal["start"]] - """Start mode for resolving/creating a direct chat from merged contact data.""" - - user: Required[Variant1User] - """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.""" - - message_text: Annotated[str, PropertyInfo(alias="messageText")] - """Optional first message content if the platform requires it to create the chat.""" - - -class Variant1User(TypedDict, total=False): +class ChatUnionMember1User(TypedDict, total=False): """Merged user-like contact payload used to resolve the best identifier.""" id: str @@ -70,4 +57,21 @@ class Variant1User(TypedDict, total=False): """Username/handle candidate.""" -ChatCreateParams: TypeAlias = Union[Variant0, Variant1] +class ChatUnionMember1(TypedDict, total=False): + account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] + """Account to start the chat on.""" + + mode: Required[Literal["start"]] + """Start mode for resolving/creating a direct chat from merged contact data.""" + + user: Required[ChatUnionMember1User] + """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.""" + + message_text: Annotated[str, PropertyInfo(alias="messageText")] + """Optional first message content if the platform requires it to create the chat.""" + + +Chat: TypeAlias = Union[ChatUnionMember0, ChatUnionMember1] diff --git a/src/beeper_desktop_api/types/chats/messages/__init__.py b/src/beeper_desktop_api/types/chats/messages/__init__.py new file mode 100644 index 0000000..5731683 --- /dev/null +++ b/src/beeper_desktop_api/types/chats/messages/__init__.py @@ -0,0 +1,8 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .reaction_add_params import ReactionAddParams as ReactionAddParams +from .reaction_add_response import ReactionAddResponse as ReactionAddResponse +from .reaction_delete_params import ReactionDeleteParams as ReactionDeleteParams +from .reaction_delete_response import ReactionDeleteResponse as ReactionDeleteResponse diff --git a/src/beeper_desktop_api/types/chats/messages/reaction_add_params.py b/src/beeper_desktop_api/types/chats/messages/reaction_add_params.py new file mode 100644 index 0000000..da7ae6c --- /dev/null +++ b/src/beeper_desktop_api/types/chats/messages/reaction_add_params.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from ...._utils import PropertyInfo + +__all__ = ["ReactionAddParams"] + + +class ReactionAddParams(TypedDict, total=False): + chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] + """Unique identifier of the chat.""" + + reaction_key: Required[Annotated[str, PropertyInfo(alias="reactionKey")]] + """Reaction key to add (emoji, shortcode, or custom emoji key)""" + + transaction_id: Annotated[str, PropertyInfo(alias="transactionID")] + """Optional transaction ID for deduplication and local echo tracking""" diff --git a/src/beeper_desktop_api/types/chats/messages/reaction_add_response.py b/src/beeper_desktop_api/types/chats/messages/reaction_add_response.py new file mode 100644 index 0000000..d7bb679 --- /dev/null +++ b/src/beeper_desktop_api/types/chats/messages/reaction_add_response.py @@ -0,0 +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 + +__all__ = ["ReactionAddResponse"] + + +class ReactionAddResponse(BaseModel): + chat_id: str = FieldInfo(alias="chatID") + """Unique identifier of the chat.""" + + message_id: str = FieldInfo(alias="messageID") + """Message ID.""" + + reaction_key: str = FieldInfo(alias="reactionKey") + """Reaction key that was added""" + + success: Literal[True] + """Whether the reaction was successfully added""" + + transaction_id: str = FieldInfo(alias="transactionID") + """Transaction ID used for the reaction event""" diff --git a/src/beeper_desktop_api/types/chats/messages/reaction_delete_params.py b/src/beeper_desktop_api/types/chats/messages/reaction_delete_params.py new file mode 100644 index 0000000..c6bdfc3 --- /dev/null +++ b/src/beeper_desktop_api/types/chats/messages/reaction_delete_params.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from ...._utils import PropertyInfo + +__all__ = ["ReactionDeleteParams"] + + +class ReactionDeleteParams(TypedDict, total=False): + chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] + """Unique identifier of the chat.""" + + reaction_key: Required[Annotated[str, PropertyInfo(alias="reactionKey")]] + """Reaction key to remove""" diff --git a/src/beeper_desktop_api/types/chats/messages/reaction_delete_response.py b/src/beeper_desktop_api/types/chats/messages/reaction_delete_response.py new file mode 100644 index 0000000..05ced92 --- /dev/null +++ b/src/beeper_desktop_api/types/chats/messages/reaction_delete_response.py @@ -0,0 +1,23 @@ +# 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 + +__all__ = ["ReactionDeleteResponse"] + + +class ReactionDeleteResponse(BaseModel): + chat_id: str = FieldInfo(alias="chatID") + """Unique identifier of the chat.""" + + message_id: str = FieldInfo(alias="messageID") + """Message ID.""" + + reaction_key: str = FieldInfo(alias="reactionKey") + """Reaction key that was removed""" + + success: Literal[True] + """Whether the reaction was successfully removed""" diff --git a/src/beeper_desktop_api/types/info_retrieve_response.py b/src/beeper_desktop_api/types/info_retrieve_response.py new file mode 100644 index 0000000..b7230a7 --- /dev/null +++ b/src/beeper_desktop_api/types/info_retrieve_response.py @@ -0,0 +1,92 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .._models import BaseModel + +__all__ = ["InfoRetrieveResponse", "App", "Endpoints", "EndpointsOAuth", "Platform", "Server"] + + +class App(BaseModel): + bundle_id: str + """App bundle identifier""" + + name: str + """App name""" + + version: str + """App version""" + + +class EndpointsOAuth(BaseModel): + authorization_endpoint: str + """OAuth authorization endpoint""" + + introspection_endpoint: str + """OAuth introspection endpoint""" + + registration_endpoint: str + """OAuth dynamic client registration endpoint""" + + revocation_endpoint: str + """OAuth token revocation endpoint""" + + token_endpoint: str + """OAuth token endpoint""" + + userinfo_endpoint: str + """OAuth userinfo endpoint""" + + +class Endpoints(BaseModel): + mcp: str + """MCP endpoint""" + + oauth: EndpointsOAuth + + spec: str + """OpenAPI spec endpoint""" + + ws_events: str + """WebSocket events endpoint""" + + +class Platform(BaseModel): + arch: str + """CPU architecture""" + + os: str + """Operating system identifier""" + + release: Optional[str] = None + """Runtime release version""" + + +class Server(BaseModel): + base_url: str + """Base URL of the Connect server""" + + hostname: str + """Listening host""" + + mcp_enabled: bool + """Whether MCP endpoint is enabled""" + + port: int + """Listening port""" + + remote_access: bool + """Whether remote access is enabled""" + + status: str + """Server status""" + + +class InfoRetrieveResponse(BaseModel): + app: App + + endpoints: Endpoints + + platform: Platform + + server: Server diff --git a/tests/api_resources/accounts/test_contacts.py b/tests/api_resources/accounts/test_contacts.py index 4458b5d..1361a11 100644 --- a/tests/api_resources/accounts/test_contacts.py +++ b/tests/api_resources/accounts/test_contacts.py @@ -9,6 +9,8 @@ from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch +from beeper_desktop_api.types.shared import User from beeper_desktop_api.types.accounts import ContactSearchResponse base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -17,6 +19,55 @@ class TestContacts: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + @parametrize + def test_method_list(self, client: BeeperDesktop) -> None: + contact = client.accounts.contacts.list( + account_id="accountID", + ) + assert_matches_type(SyncCursorSearch[User], contact, path=["response"]) + + @parametrize + def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: + contact = client.accounts.contacts.list( + account_id="accountID", + cursor="1725489123456|c29tZUltc2dQYWdl", + direction="before", + limit=1, + query="x", + ) + assert_matches_type(SyncCursorSearch[User], contact, path=["response"]) + + @parametrize + def test_raw_response_list(self, client: BeeperDesktop) -> None: + response = client.accounts.contacts.with_raw_response.list( + account_id="accountID", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + contact = response.parse() + assert_matches_type(SyncCursorSearch[User], contact, path=["response"]) + + @parametrize + def test_streaming_response_list(self, client: BeeperDesktop) -> None: + with client.accounts.contacts.with_streaming_response.list( + account_id="accountID", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + contact = response.parse() + assert_matches_type(SyncCursorSearch[User], contact, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_list(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `account_id` but received ''"): + client.accounts.contacts.with_raw_response.list( + account_id="", + ) + @parametrize def test_method_search(self, client: BeeperDesktop) -> None: contact = client.accounts.contacts.search( @@ -65,6 +116,55 @@ class TestAsyncContacts: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) + @parametrize + async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: + contact = await async_client.accounts.contacts.list( + account_id="accountID", + ) + assert_matches_type(AsyncCursorSearch[User], contact, path=["response"]) + + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + contact = await async_client.accounts.contacts.list( + account_id="accountID", + cursor="1725489123456|c29tZUltc2dQYWdl", + direction="before", + limit=1, + query="x", + ) + assert_matches_type(AsyncCursorSearch[User], contact, path=["response"]) + + @parametrize + async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.accounts.contacts.with_raw_response.list( + account_id="accountID", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + contact = await response.parse() + assert_matches_type(AsyncCursorSearch[User], contact, path=["response"]) + + @parametrize + async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.accounts.contacts.with_streaming_response.list( + account_id="accountID", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + contact = await response.parse() + assert_matches_type(AsyncCursorSearch[User], contact, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_list(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `account_id` but received ''"): + await async_client.accounts.contacts.with_raw_response.list( + account_id="", + ) + @parametrize async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: contact = await async_client.accounts.contacts.search( diff --git a/tests/api_resources/chats/messages/__init__.py b/tests/api_resources/chats/messages/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/api_resources/chats/messages/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/chats/messages/test_reactions.py b/tests/api_resources/chats/messages/test_reactions.py new file mode 100644 index 0000000..c472ebf --- /dev/null +++ b/tests/api_resources/chats/messages/test_reactions.py @@ -0,0 +1,259 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types.chats.messages import ( + ReactionAddResponse, + ReactionDeleteResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestReactions: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_delete(self, client: BeeperDesktop) -> None: + reaction = client.chats.messages.reactions.delete( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) + assert_matches_type(ReactionDeleteResponse, reaction, path=["response"]) + + @parametrize + def test_raw_response_delete(self, client: BeeperDesktop) -> None: + response = client.chats.messages.reactions.with_raw_response.delete( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + reaction = response.parse() + assert_matches_type(ReactionDeleteResponse, reaction, path=["response"]) + + @parametrize + def test_streaming_response_delete(self, client: BeeperDesktop) -> None: + with client.chats.messages.reactions.with_streaming_response.delete( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + reaction = response.parse() + assert_matches_type(ReactionDeleteResponse, reaction, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_delete(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.chats.messages.reactions.with_raw_response.delete( + message_id="messageID", + chat_id="", + reaction_key="x", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + client.chats.messages.reactions.with_raw_response.delete( + message_id="", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) + + @parametrize + def test_method_add(self, client: BeeperDesktop) -> None: + reaction = client.chats.messages.reactions.add( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) + assert_matches_type(ReactionAddResponse, reaction, path=["response"]) + + @parametrize + def test_method_add_with_all_params(self, client: BeeperDesktop) -> None: + reaction = client.chats.messages.reactions.add( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + transaction_id="transactionID", + ) + assert_matches_type(ReactionAddResponse, reaction, path=["response"]) + + @parametrize + def test_raw_response_add(self, client: BeeperDesktop) -> None: + response = client.chats.messages.reactions.with_raw_response.add( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + reaction = response.parse() + assert_matches_type(ReactionAddResponse, reaction, path=["response"]) + + @parametrize + def test_streaming_response_add(self, client: BeeperDesktop) -> None: + with client.chats.messages.reactions.with_streaming_response.add( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + reaction = response.parse() + assert_matches_type(ReactionAddResponse, reaction, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_add(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.chats.messages.reactions.with_raw_response.add( + message_id="messageID", + chat_id="", + reaction_key="x", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + client.chats.messages.reactions.with_raw_response.add( + message_id="", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) + + +class TestAsyncReactions: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_delete(self, async_client: AsyncBeeperDesktop) -> None: + reaction = await async_client.chats.messages.reactions.delete( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) + assert_matches_type(ReactionDeleteResponse, reaction, path=["response"]) + + @parametrize + async def test_raw_response_delete(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.messages.reactions.with_raw_response.delete( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + reaction = await response.parse() + assert_matches_type(ReactionDeleteResponse, reaction, path=["response"]) + + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.messages.reactions.with_streaming_response.delete( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + reaction = await response.parse() + assert_matches_type(ReactionDeleteResponse, reaction, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_delete(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.chats.messages.reactions.with_raw_response.delete( + message_id="messageID", + chat_id="", + reaction_key="x", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + await async_client.chats.messages.reactions.with_raw_response.delete( + message_id="", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) + + @parametrize + async def test_method_add(self, async_client: AsyncBeeperDesktop) -> None: + reaction = await async_client.chats.messages.reactions.add( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) + assert_matches_type(ReactionAddResponse, reaction, path=["response"]) + + @parametrize + async def test_method_add_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + reaction = await async_client.chats.messages.reactions.add( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + transaction_id="transactionID", + ) + assert_matches_type(ReactionAddResponse, reaction, path=["response"]) + + @parametrize + async def test_raw_response_add(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.messages.reactions.with_raw_response.add( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + reaction = await response.parse() + assert_matches_type(ReactionAddResponse, reaction, path=["response"]) + + @parametrize + async def test_streaming_response_add(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.messages.reactions.with_streaming_response.add( + message_id="messageID", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + reaction = await response.parse() + assert_matches_type(ReactionAddResponse, reaction, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_add(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.chats.messages.reactions.with_raw_response.add( + message_id="messageID", + chat_id="", + reaction_key="x", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + await async_client.chats.messages.reactions.with_raw_response.add( + message_id="", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reaction_key="x", + ) diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index f6fde7c..ba28f71 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -24,87 +24,27 @@ class TestChats: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) @parametrize - def test_method_create_overload_1(self, client: BeeperDesktop) -> None: - chat = client.chats.create( - account_id="accountID", - participant_ids=["string"], - type="single", - ) - assert_matches_type(ChatCreateResponse, chat, path=["response"]) - - @parametrize - def test_method_create_with_all_params_overload_1(self, client: BeeperDesktop) -> None: - chat = client.chats.create( - account_id="accountID", - participant_ids=["string"], - type="single", - message_text="messageText", - mode="create", - title="title", - ) - assert_matches_type(ChatCreateResponse, chat, path=["response"]) - - @parametrize - def test_raw_response_create_overload_1(self, client: BeeperDesktop) -> None: - response = client.chats.with_raw_response.create( - account_id="accountID", - participant_ids=["string"], - type="single", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - chat = response.parse() + def test_method_create(self, client: BeeperDesktop) -> None: + chat = client.chats.create() assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize - def test_streaming_response_create_overload_1(self, client: BeeperDesktop) -> None: - with client.chats.with_streaming_response.create( - account_id="accountID", - participant_ids=["string"], - type="single", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - chat = response.parse() - assert_matches_type(ChatCreateResponse, chat, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - def test_method_create_overload_2(self, client: BeeperDesktop) -> None: + def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: chat = client.chats.create( - account_id="accountID", - mode="start", - user={}, - ) - assert_matches_type(ChatCreateResponse, chat, path=["response"]) - - @parametrize - def test_method_create_with_all_params_overload_2(self, client: BeeperDesktop) -> None: - chat = client.chats.create( - account_id="accountID", - mode="start", - user={ - "id": "id", - "email": "email", - "full_name": "fullName", - "phone_number": "phoneNumber", - "username": "username", + chat={ + "account_id": "accountID", + "participant_ids": ["string"], + "type": "single", + "message_text": "messageText", + "mode": "create", + "title": "title", }, - allow_invite=True, - message_text="messageText", ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize - def test_raw_response_create_overload_2(self, client: BeeperDesktop) -> None: - response = client.chats.with_raw_response.create( - account_id="accountID", - mode="start", - user={}, - ) + def test_raw_response_create(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.create() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -112,12 +52,8 @@ def test_raw_response_create_overload_2(self, client: BeeperDesktop) -> None: assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize - def test_streaming_response_create_overload_2(self, client: BeeperDesktop) -> None: - with client.chats.with_streaming_response.create( - account_id="accountID", - mode="start", - user={}, - ) as response: + def test_streaming_response_create(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.create() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -308,87 +244,27 @@ class TestAsyncChats: ) @parametrize - async def test_method_create_overload_1(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.create( - account_id="accountID", - participant_ids=["string"], - type="single", - ) - assert_matches_type(ChatCreateResponse, chat, path=["response"]) - - @parametrize - async def test_method_create_with_all_params_overload_1(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.create( - account_id="accountID", - participant_ids=["string"], - type="single", - message_text="messageText", - mode="create", - title="title", - ) - assert_matches_type(ChatCreateResponse, chat, path=["response"]) - - @parametrize - async def test_raw_response_create_overload_1(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.chats.with_raw_response.create( - account_id="accountID", - participant_ids=["string"], - type="single", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - chat = await response.parse() + async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.create() assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize - async def test_streaming_response_create_overload_1(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.chats.with_streaming_response.create( - account_id="accountID", - participant_ids=["string"], - type="single", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - chat = await response.parse() - assert_matches_type(ChatCreateResponse, chat, path=["response"]) - - assert cast(Any, response.is_closed) is True - - @parametrize - async def test_method_create_overload_2(self, async_client: AsyncBeeperDesktop) -> None: + async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.create( - account_id="accountID", - mode="start", - user={}, - ) - assert_matches_type(ChatCreateResponse, chat, path=["response"]) - - @parametrize - async def test_method_create_with_all_params_overload_2(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.create( - account_id="accountID", - mode="start", - user={ - "id": "id", - "email": "email", - "full_name": "fullName", - "phone_number": "phoneNumber", - "username": "username", + chat={ + "account_id": "accountID", + "participant_ids": ["string"], + "type": "single", + "message_text": "messageText", + "mode": "create", + "title": "title", }, - allow_invite=True, - message_text="messageText", ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize - async def test_raw_response_create_overload_2(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.chats.with_raw_response.create( - account_id="accountID", - mode="start", - user={}, - ) + async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: + 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" @@ -396,12 +272,8 @@ async def test_raw_response_create_overload_2(self, async_client: AsyncBeeperDes assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize - async def test_streaming_response_create_overload_2(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.chats.with_streaming_response.create( - account_id="accountID", - mode="start", - user={}, - ) as response: + async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: + 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" diff --git a/tests/api_resources/test_info.py b/tests/api_resources/test_info.py new file mode 100644 index 0000000..66d98ed --- /dev/null +++ b/tests/api_resources/test_info.py @@ -0,0 +1,74 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api.types import InfoRetrieveResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestInfo: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_retrieve(self, client: BeeperDesktop) -> None: + info = client.info.retrieve() + assert_matches_type(InfoRetrieveResponse, info, path=["response"]) + + @parametrize + def test_raw_response_retrieve(self, client: BeeperDesktop) -> None: + response = client.info.with_raw_response.retrieve() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + info = response.parse() + assert_matches_type(InfoRetrieveResponse, info, path=["response"]) + + @parametrize + def test_streaming_response_retrieve(self, client: BeeperDesktop) -> None: + with client.info.with_streaming_response.retrieve() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + info = response.parse() + assert_matches_type(InfoRetrieveResponse, info, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncInfo: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + info = await async_client.info.retrieve() + assert_matches_type(InfoRetrieveResponse, info, path=["response"]) + + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.info.with_raw_response.retrieve() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + info = await response.parse() + assert_matches_type(InfoRetrieveResponse, info, path=["response"]) + + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.info.with_streaming_response.retrieve() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + info = await response.parse() + assert_matches_type(InfoRetrieveResponse, info, path=["response"]) + + assert cast(Any, response.is_closed) is True From 211e996108db521062017e26577ba2dc5774ce10 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 01:10:15 +0000 Subject: [PATCH 65/98] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 3e8b0a3..50bd7a1 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-ee25e67fc85ccc86cedb2ca0865385709877582132103e0afa68d7b43551784a.yml openapi_spec_hash: d41fd99c9a8645a1fd69c519cd25a637 -config_hash: 01fba090f51e67f4dfd3a4fa6c53d665 +config_hash: abdcaeff62a619bdf25d727cdeacf3b0 From ad978dfa57b1f5139fc00cdbbb19511b4d1618dc Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 01:10:53 +0000 Subject: [PATCH 66/98] 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 50bd7a1..9b53ab5 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-ee25e67fc85ccc86cedb2ca0865385709877582132103e0afa68d7b43551784a.yml openapi_spec_hash: d41fd99c9a8645a1fd69c519cd25a637 -config_hash: abdcaeff62a619bdf25d727cdeacf3b0 +config_hash: bd091e75baa300de3a05731fbd7f479e diff --git a/README.md b/README.md index c0c9be9..a48acff 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). @@ -90,8 +87,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 2569913665637b37cc5f58d7a8b899124893c43e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 01:11:11 +0000 Subject: [PATCH 67/98] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 9b53ab5..776042b 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-ee25e67fc85ccc86cedb2ca0865385709877582132103e0afa68d7b43551784a.yml openapi_spec_hash: d41fd99c9a8645a1fd69c519cd25a637 -config_hash: bd091e75baa300de3a05731fbd7f479e +config_hash: 07a9227b2e53d5bf022c964ac30d72fa From 8e5fce9ffb4197e9b89e8cc2d8cc55987336eacb Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 01:11:36 +0000 Subject: [PATCH 68/98] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/beeper_desktop_api/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d6a8b5d..bd7f384 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "4.1.296" + ".": "4.2.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 99df50b..96e553a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "beeper_desktop_api" -version = "4.1.296" +version = "4.2.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 f18d0c4..9e1f20a 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.1.296" # x-release-please-version +__version__ = "4.2.0" # x-release-please-version From b062700ae07cf3a08c23599d55960d0533ed27ab Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 01:21:27 +0000 Subject: [PATCH 69/98] feat(api): manual updates --- .stats.yml | 6 +- README.md | 17 +++-- .../resources/chats/chats.py | 4 +- .../types/chat_create_params.py | 72 +++++++++---------- tests/api_resources/test_chats.py | 48 ++++++++++--- 5 files changed, 88 insertions(+), 59 deletions(-) diff --git a/.stats.yml b/.stats.yml index 776042b..768e7e7 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-ee25e67fc85ccc86cedb2ca0865385709877582132103e0afa68d7b43551784a.yml -openapi_spec_hash: d41fd99c9a8645a1fd69c519cd25a637 -config_hash: 07a9227b2e53d5bf022c964ac30d72fa +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-1b8324f05cd39e88cfc36b9b86a868b6f7e0c9e0827bb30d70a6d875c151ae52.yml +openapi_spec_hash: 41410e315f6a3d0be787ece9e4fcb96a +config_hash: abdcaeff62a619bdf25d727cdeacf3b0 diff --git a/README.md b/README.md index a48acff..17e8229 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,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). @@ -87,8 +90,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()`: @@ -215,10 +218,10 @@ 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( + chat={"account_id": "accountID"}, ) +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 4682744..75b6c33 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -79,7 +79,7 @@ def with_streaming_response(self) -> ChatsResourceWithStreamingResponse: def create( self, *, - chat: chat_create_params.Chat | Omit = omit, + chat: chat_create_params.Chat, # 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, @@ -383,7 +383,7 @@ def with_streaming_response(self) -> AsyncChatsResourceWithStreamingResponse: async def create( self, *, - chat: chat_create_params.Chat | Omit = omit, + chat: chat_create_params.Chat, # 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, diff --git a/src/beeper_desktop_api/types/chat_create_params.py b/src/beeper_desktop_api/types/chat_create_params.py index b0314d5..0ba872c 100644 --- a/src/beeper_desktop_api/types/chat_create_params.py +++ b/src/beeper_desktop_api/types/chat_create_params.py @@ -2,44 +2,23 @@ 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", "Chat", "ChatUnionMember0", "ChatUnionMember1", "ChatUnionMember1User"] +__all__ = ["ChatCreateParams", "Chat", "ChatUser"] class ChatCreateParams(TypedDict, total=False): - chat: Chat + chat: Required[Chat] -class ChatUnionMember0(TypedDict, total=False): - account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] - """Account to create the chat on.""" - - participant_ids: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")]] - """User IDs to include in the new chat.""" +class ChatUser(TypedDict, total=False): + """Required when mode='start'. - type: Required[Literal["single", "group"]] + Merged user-like contact payload used to resolve the best identifier. """ - Chat type to create: 'single' requires exactly one participantID; 'group' - supports multiple participants and optional title. - """ - - message_text: Annotated[str, PropertyInfo(alias="messageText")] - """Optional first message content if the platform requires it to create the chat.""" - - mode: Literal["create"] - """Create mode. Defaults to 'create' when omitted.""" - - title: str - """Optional title for group chats; ignored for single chats on most platforms.""" - - -class ChatUnionMember1User(TypedDict, total=False): - """Merged user-like contact payload used to resolve the best identifier.""" id: str """Known user ID when available.""" @@ -57,21 +36,40 @@ class ChatUnionMember1User(TypedDict, total=False): """Username/handle candidate.""" -class ChatUnionMember1(TypedDict, total=False): +class Chat(TypedDict, total=False): account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] - """Account to start the chat on.""" - - mode: Required[Literal["start"]] - """Start mode for resolving/creating a direct chat from merged contact data.""" - - user: Required[ChatUnionMember1User] - """Merged user-like contact payload used to resolve the best identifier.""" + """Account to create or start the chat on.""" allow_invite: Annotated[bool, PropertyInfo(alias="allowInvite")] - """Whether invite-based DM creation is allowed when required by the platform.""" + """Whether invite-based DM creation is allowed when required by the platform. + + Used for mode='start'. + """ 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. + """ + + type: Literal["single", "group"] + """Required when mode='create'. + + 'single' requires exactly one participantID; 'group' supports multiple + participants and optional title. + """ + + user: ChatUser + """Required when mode='start'. -Chat: TypeAlias = Union[ChatUnionMember0, ChatUnionMember1] + Merged user-like contact payload used to resolve the best identifier. + """ diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index ba28f71..74b7ab4 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -25,7 +25,9 @@ class TestChats: @parametrize def test_method_create(self, client: BeeperDesktop) -> None: - chat = client.chats.create() + chat = client.chats.create( + chat={"account_id": "accountID"}, + ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize @@ -33,18 +35,28 @@ def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: chat = client.chats.create( chat={ "account_id": "accountID", - "participant_ids": ["string"], - "type": "single", + "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", + }, }, ) 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( + chat={"account_id": "accountID"}, + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -53,7 +65,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( + chat={"account_id": "accountID"}, + ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -245,7 +259,9 @@ 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( + chat={"account_id": "accountID"}, + ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize @@ -253,18 +269,28 @@ async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesk chat = await async_client.chats.create( chat={ "account_id": "accountID", - "participant_ids": ["string"], - "type": "single", + "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", + }, }, ) 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( + chat={"account_id": "accountID"}, + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -273,7 +299,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( + chat={"account_id": "accountID"}, + ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" From d810e2594aa2c1aaaf4802aa4a2afcf792812e23 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 04:19:59 +0000 Subject: [PATCH 70/98] chore: update mock server docs --- CONTRIBUTING.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 81bc94a..08c3ec2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,8 +88,7 @@ $ pip install ./path-to-wheel-file.whl Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. ```sh -# you will need npm installed -$ npx prism mock path/to/your/openapi.yml +$ ./scripts/mock ``` ```sh From fcbe7967d2bb454e923b8bb73b5586f9d7c03cb4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:18:58 +0000 Subject: [PATCH 71/98] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 768e7e7..3a78cf5 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-1b8324f05cd39e88cfc36b9b86a868b6f7e0c9e0827bb30d70a6d875c151ae52.yml -openapi_spec_hash: 41410e315f6a3d0be787ece9e4fcb96a +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-774bb08472b6bb14c280fe5b767925675516b5c8ccc0b89b5abd7ac7bc30fe5a.yml +openapi_spec_hash: ddd1ce1f334b45206ac008b0f5296842 config_hash: abdcaeff62a619bdf25d727cdeacf3b0 From ec0df7150e5a7fb61ecd12b0f9c81431d00c3d2d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:19:50 +0000 Subject: [PATCH 72/98] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 3a78cf5..b40afd8 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-774bb08472b6bb14c280fe5b767925675516b5c8ccc0b89b5abd7ac7bc30fe5a.yml openapi_spec_hash: ddd1ce1f334b45206ac008b0f5296842 -config_hash: abdcaeff62a619bdf25d727cdeacf3b0 +config_hash: cd9eef64c1202fa937a22172b0218447 From 31dfd01838b1bfc2143a391c7fc7eebe3b77e567 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:42:20 +0000 Subject: [PATCH 73/98] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index b40afd8..8ec4701 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-774bb08472b6bb14c280fe5b767925675516b5c8ccc0b89b5abd7ac7bc30fe5a.yml openapi_spec_hash: ddd1ce1f334b45206ac008b0f5296842 -config_hash: cd9eef64c1202fa937a22172b0218447 +config_hash: b5ac0c1579dfe6257bcdb84cfd1002fc From 6e43a798ad442d794bab80e9236652c964f32a07 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:52:14 +0000 Subject: [PATCH 74/98] feat(api): api update --- .stats.yml | 4 ++-- src/beeper_desktop_api/resources/messages.py | 4 ++-- src/beeper_desktop_api/types/message_search_params.py | 2 +- src/beeper_desktop_api/types/shared/error.py | 10 +++++----- tests/api_resources/test_messages.py | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.stats.yml b/.stats.yml index 8ec4701..5a6113f 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-774bb08472b6bb14c280fe5b767925675516b5c8ccc0b89b5abd7ac7bc30fe5a.yml -openapi_spec_hash: ddd1ce1f334b45206ac008b0f5296842 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-4acef56b00be513f305543096fdd407e6947f0a5ad268ab2e627ff30b37a75db.yml +openapi_spec_hash: e876d796b6c25f18577f6be3944bf7d9 config_hash: b5ac0c1579dfe6257bcdb84cfd1002fc diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index 63ad8e1..b97c7a0 100644 --- a/src/beeper_desktop_api/resources/messages.py +++ b/src/beeper_desktop_api/resources/messages.py @@ -163,7 +163,7 @@ def search( limit: int | Omit = omit, media_types: List[Literal["any", "video", "image", "link", "file"]] | Omit = omit, query: str | Omit = omit, - sender: Union[Literal["me", "others"], str] | Omit = omit, + sender: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -439,7 +439,7 @@ def search( limit: int | Omit = omit, media_types: List[Literal["any", "video", "image", "link", "file"]] | Omit = omit, query: str | Omit = omit, - sender: Union[Literal["me", "others"], str] | Omit = omit, + sender: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, diff --git a/src/beeper_desktop_api/types/message_search_params.py b/src/beeper_desktop_api/types/message_search_params.py index 3ba609d..e9bab35 100644 --- a/src/beeper_desktop_api/types/message_search_params.py +++ b/src/beeper_desktop_api/types/message_search_params.py @@ -74,7 +74,7 @@ class MessageSearchParams(TypedDict, total=False): only by other parameters. """ - sender: Union[Literal["me", "others"], str] + sender: str """ Filter by sender: 'me' (messages sent by the authenticated user), 'others' (messages sent by others), or a specific user ID string (user.id). diff --git a/src/beeper_desktop_api/types/shared/error.py b/src/beeper_desktop_api/types/shared/error.py index df9fdbb..17c2409 100644 --- a/src/beeper_desktop_api/types/shared/error.py +++ b/src/beeper_desktop_api/types/shared/error.py @@ -5,10 +5,10 @@ from ..._models import BaseModel -__all__ = ["Error", "Details", "DetailsIssues", "DetailsIssuesIssue"] +__all__ = ["Error", "Details", "DetailsValidationDetails", "DetailsValidationDetailsIssue"] -class DetailsIssuesIssue(BaseModel): +class DetailsValidationDetailsIssue(BaseModel): code: str """Validation issue code""" @@ -19,14 +19,14 @@ class DetailsIssuesIssue(BaseModel): """Path pointing to the invalid field within the payload""" -class DetailsIssues(BaseModel): +class DetailsValidationDetails(BaseModel): """Validation error details""" - issues: List[DetailsIssuesIssue] + issues: List[DetailsValidationDetailsIssue] """List of validation issues""" -Details: TypeAlias = Union[DetailsIssues, Dict[str, Optional[object]], Optional[object]] +Details: TypeAlias = Union[DetailsValidationDetails, Dict[str, Optional[object]], Optional[object]] class Error(BaseModel): diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index b6c3900..a167221 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -146,7 +146,7 @@ def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: limit=20, media_types=["any"], query="dinner", - sender="me", + sender="sender", ) assert_matches_type(SyncCursorSearch[Message], message, path=["response"]) @@ -357,7 +357,7 @@ async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesk limit=20, media_types=["any"], query="dinner", - sender="me", + sender="sender", ) assert_matches_type(AsyncCursorSearch[Message], message, path=["response"]) From 7196401d8019b9e291f568243785a369977d5a55 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:58:08 +0000 Subject: [PATCH 75/98] feat(api): api update --- .stats.yml | 2 +- README.md | 3 +- .../resources/chats/chats.py | 86 ++++++++++++++++++- .../types/chat_create_params.py | 52 ++++++----- tests/api_resources/test_chats.py | 68 +++++++-------- 5 files changed, 141 insertions(+), 70 deletions(-) diff --git a/.stats.yml b/.stats.yml index 5a6113f..56c368e 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: b5ac0c1579dfe6257bcdb84cfd1002fc +config_hash: 659111d4e28efa599b5f800619ed79c2 diff --git a/README.md b/README.md index 17e8229..b42ad75 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,8 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() chat = client.chats.create( - chat={"account_id": "accountID"}, + account_id="accountID", + user={}, ) print(chat.user) ``` diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index 75b6c33..6a3cdb0 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, *, - chat: chat_create_params.Chat, + 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, # 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, @@ -92,6 +99,26 @@ 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 @@ -102,7 +129,19 @@ def create( """ return self._post( "/v1/chats", - body=maybe_transform(chat, 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 ), @@ -383,7 +422,14 @@ def with_streaming_response(self) -> AsyncChatsResourceWithStreamingResponse: async def create( self, *, - chat: chat_create_params.Chat, + 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, # 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, @@ -396,6 +442,26 @@ 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 @@ -406,7 +472,19 @@ async def create( """ return await self._post( "/v1/chats", - body=await async_maybe_transform(chat, 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 ), diff --git a/src/beeper_desktop_api/types/chat_create_params.py b/src/beeper_desktop_api/types/chat_create_params.py index 0ba872c..93229c1 100644 --- a/src/beeper_desktop_api/types/chat_create_params.py +++ b/src/beeper_desktop_api/types/chat_create_params.py @@ -7,36 +7,10 @@ from .._types import SequenceNotStr from .._utils import PropertyInfo -__all__ = ["ChatCreateParams", "Chat", "ChatUser"] +__all__ = ["ChatCreateParams", "User"] class ChatCreateParams(TypedDict, total=False): - chat: Required[Chat] - - -class ChatUser(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.""" - - 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 Chat(TypedDict, total=False): account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] """Account to create or start the chat on.""" @@ -68,8 +42,30 @@ class Chat(TypedDict, total=False): participants and optional title. """ - user: ChatUser + 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.""" + + 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.""" diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index 74b7ab4..b899add 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -26,28 +26,26 @@ class TestChats: @parametrize def test_method_create(self, client: BeeperDesktop) -> None: chat = client.chats.create( - chat={"account_id": "accountID"}, + 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( - chat={ - "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", - }, + 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", }, ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @@ -55,7 +53,7 @@ def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: @parametrize def test_raw_response_create(self, client: BeeperDesktop) -> None: response = client.chats.with_raw_response.create( - chat={"account_id": "accountID"}, + account_id="accountID", ) assert response.is_closed is True @@ -66,7 +64,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( - chat={"account_id": "accountID"}, + account_id="accountID", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -260,28 +258,26 @@ class TestAsyncChats: @parametrize async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.create( - chat={"account_id": "accountID"}, + 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( - chat={ - "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", - }, + 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", }, ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @@ -289,7 +285,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesk @parametrize async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: response = await async_client.chats.with_raw_response.create( - chat={"account_id": "accountID"}, + account_id="accountID", ) assert response.is_closed is True @@ -300,7 +296,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( - chat={"account_id": "accountID"}, + account_id="accountID", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" From 2b438945c4499c3a1bcd0d99c00c7dbf61e5680b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:01:30 +0000 Subject: [PATCH 76/98] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/beeper_desktop_api/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index bd7f384..29102ae 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "4.2.0" + ".": "4.3.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 96e553a..089b317 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "beeper_desktop_api" -version = "4.2.0" +version = "4.3.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 9e1f20a..1bc95e4 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.2.0" # x-release-please-version +__version__ = "4.3.0" # x-release-please-version From 326796e6301205c056d36318da0180d74eb78a54 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 77/98] 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 e4f41c23bf7971682aa846053f85a7e62e2b0db0 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 78/98] 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 bcb056250c5f87e006597ffa4a28b689c5767362 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 79/98] 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 6545fd652b5bb9f0e16919eee90353a5f6f8bb1e 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 80/98] 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 87f7a9fa0a792477be2e847a1bc958c4258a1186 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 81/98] 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 6ede61cc1a8405b749c63483d2528260c3959843 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 82/98] 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 13bd5c67df66750bfe7bd28b856dc381303866be 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 83/98] 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 b256089d26763d94352c728431c1f5313aa495a7 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 84/98] 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 0ba74c35e758619c80f3e2ddf5018f0b24c618fb 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 85/98] 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 65afedde5d8f41c5459ac9686f30486b09ffa6df 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 86/98] 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 7b9ba4712b00963a9d0b241b8c5e23a2818cb9f2 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 87/98] 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 d037bcb2ce267edc758c23be7322f32f0d653780 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 88/98] 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 0c240f20c52c9cc3622e9a946b91b60373f57494 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 89/98] 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 056ae05a834ffcccf1949541bb63749a4c39ff9e 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 90/98] 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 b30e1a29dfa2b323d2a5fb9edbf41780f14fa8e6 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 91/98] 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 285d86084b7bd4b561b338390d22c6564eedb9cc 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 92/98] 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 91d598e8c122dbbace9cad45814f39ab3152c2eb 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 93/98] 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 a253661be4ea6b2c4a73ec5c7e62e7b1b852568f 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 94/98] 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 ce56048747ba38b201a097e087c3e9288b64b0d0 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 95/98] 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 ccd05a07f195057f1f565b53a70559c80351cab1 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 96/98] 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 efbc96e705c68f11011fd6e4f335430212298678 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 97/98] 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 aebebb59145c4f7906c7f51c0ca5164b1294e484 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 98/98] 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