diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3e7de04..1ca0ca2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,12 +1,14 @@
name: CI
on:
push:
- branches-ignore:
- - 'generated'
- - 'codegen/**'
- - 'integrated/**'
- - 'stl-preview-head/**'
- - 'stl-preview-base/**'
+ branches:
+ - '**'
+ - '!integrated/**'
+ - '!stl-preview-head/**'
+ - '!stl-preview-base/**'
+ - '!generated'
+ - '!codegen/**'
+ - 'codegen/stl/**'
pull_request:
branches-ignore:
- 'stl-preview-head/**'
@@ -17,7 +19,7 @@ jobs:
timeout-minutes: 10
name: lint
runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
- if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
+ if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
steps:
- uses: actions/checkout@v6
@@ -36,7 +38,7 @@ jobs:
run: ./scripts/lint
build:
- if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
+ if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
timeout-minutes: 10
name: build
permissions:
@@ -61,14 +63,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 }}
diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml
new file mode 100644
index 0000000..8514409
--- /dev/null
+++ b/.github/workflows/detect-breaking-changes.yml
@@ -0,0 +1,42 @@
+name: CI
+on:
+ pull_request:
+ branches:
+ - main
+ - next
+
+jobs:
+ detect_breaking_changes:
+ runs-on: 'ubuntu-latest'
+ name: detect-breaking-changes
+ if: github.repository == 'beeper/desktop-api-python'
+ steps:
+ - name: Calculate fetch-depth
+ run: |
+ echo "FETCH_DEPTH=$(expr ${{ github.event.pull_request.commits }} + 1)" >> $GITHUB_ENV
+
+ - uses: actions/checkout@v6
+ with:
+ # Ensure we can check out the pull request base in the script below.
+ fetch-depth: ${{ env.FETCH_DEPTH }}
+
+ - name: Install Rye
+ run: |
+ curl -sSf https://rye.astral.sh/get | bash
+ echo "$HOME/.rye/shims" >> $GITHUB_PATH
+ env:
+ RYE_VERSION: '0.44.0'
+ RYE_INSTALL_OPTION: '--yes'
+ - name: Install dependencies
+ run: |
+ rye sync --all-features
+ - name: Detect removed symbols
+ run: |
+ rye run python scripts/detect-breaking-changes.py "${{ github.event.pull_request.base.sha }}"
+
+ - name: Detect breaking changes
+ run: |
+ # Try to check out previous versions of the breaking change detection script. This ensures that
+ # we still detect breaking changes when entire files and their tests are removed.
+ git checkout "${{ github.event.pull_request.base.sha }}" -- ./scripts/detect-breaking-changes 2>/dev/null || true
+ ./scripts/detect-breaking-changes ${{ github.event.pull_request.base.sha }}
\ No newline at end of file
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
index 08d08f6..54361b5 100644
--- a/.github/workflows/publish-pypi.yml
+++ b/.github/workflows/publish-pypi.yml
@@ -28,4 +28,4 @@ jobs:
run: |
bash ./bin/publish-pypi
env:
- PYPI_TOKEN: ${{ secrets.BEEPER_DESKTOP_PYPI_TOKEN || secrets.PYPI_TOKEN }}
+ PYPI_TOKEN: ${{ secrets.BEEPER_PYPI_TOKEN || secrets.PYPI_TOKEN }}
diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml
index 4bccf2f..2d24407 100644
--- a/.github/workflows/release-doctor.yml
+++ b/.github/workflows/release-doctor.yml
@@ -18,4 +18,4 @@ jobs:
run: |
bash ./bin/check-release-environment
env:
- PYPI_TOKEN: ${{ secrets.BEEPER_DESKTOP_PYPI_TOKEN || secrets.PYPI_TOKEN }}
+ PYPI_TOKEN: ${{ secrets.BEEPER_PYPI_TOKEN || secrets.PYPI_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 95ceb18..3824f4c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.prism.log
+.stdy.log
_dev
__pycache__
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 29102ae..934f2cc 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "4.3.0"
+ ".": "4.4.0"
}
\ No newline at end of file
diff --git a/.stats.yml b/.stats.yml
index 56c368e..ec75571 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 23
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-4acef56b00be513f305543096fdd407e6947f0a5ad268ab2e627ff30b37a75db.yml
-openapi_spec_hash: e876d796b6c25f18577f6be3944bf7d9
-config_hash: 659111d4e28efa599b5f800619ed79c2
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-356444646dafe352d3ef7c2e01aedf030197a5519b41cf2c3fd8be2571456b43.yml
+openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef
+config_hash: 05ebdec072113f63395372504da98192
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5ee4c9..36a4dd6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,64 @@
# Changelog
+## 4.4.0 (2026-05-01)
+
+Full Changelog: [v4.3.0...v4.4.0](https://github.com/beeper/desktop-api-python/compare/v4.3.0...v4.4.0)
+
+### Features
+
+* **api:** add network, bridge fields to accounts ([af70fc9](https://github.com/beeper/desktop-api-python/commit/af70fc9fab45036721b4be634bb4444964c70d1e))
+* **api:** api update ([770a8e2](https://github.com/beeper/desktop-api-python/commit/770a8e2a6fc4d96dae58b3b787d55072faf63e34))
+* **api:** manual updates ([c84dca5](https://github.com/beeper/desktop-api-python/commit/c84dca576d56b83e314ab798749607e70aea7223))
+* **internal:** implement indices array format for query and form serialization ([de85c3a](https://github.com/beeper/desktop-api-python/commit/de85c3aef481f44350bab667ed81e155573ace81))
+* support setting headers via env ([6841539](https://github.com/beeper/desktop-api-python/commit/6841539c8619a507ec5e717d08a81837e83d76c2))
+
+
+### Bug Fixes
+
+* **client:** preserve hardcoded query params when merging with user params ([9e86464](https://github.com/beeper/desktop-api-python/commit/9e86464960e28472bc3a4f137c5d2c025f2acc16))
+* **deps:** bump minimum typing-extensions version ([922d90a](https://github.com/beeper/desktop-api-python/commit/922d90aeb6d75306490a359d26ebbf71a6e340b8))
+* ensure file data are only sent as 1 parameter ([69f6d11](https://github.com/beeper/desktop-api-python/commit/69f6d11ecb0959d1a5eb90c41c76542a1ea5826f))
+* **pydantic:** do not pass `by_alias` unless set ([8b9fe85](https://github.com/beeper/desktop-api-python/commit/8b9fe85df1911bc10a65b5c965e5465c4041e065))
+* sanitize endpoint path params ([900c955](https://github.com/beeper/desktop-api-python/commit/900c955edf1d5f8cf7aa9c7d8a7859e5b61ae379))
+* use correct field name format for multipart file arrays ([d086e7f](https://github.com/beeper/desktop-api-python/commit/d086e7f0ff86653ace4d1f21c5daf1d6605b3369))
+
+
+### Performance Improvements
+
+* **client:** optimize file structure copying in multipart requests ([7addb88](https://github.com/beeper/desktop-api-python/commit/7addb88adf0574857ce646e8a9f15e8eb035a48a))
+
+
+### Chores
+
+* **ci:** skip lint on metadata-only changes ([1fe013e](https://github.com/beeper/desktop-api-python/commit/1fe013eacfb814508b88eefa3b2ee3bf51618edc))
+* **ci:** skip uploading artifacts on stainless-internal branches ([3f5692e](https://github.com/beeper/desktop-api-python/commit/3f5692eb199bd02db1359e8131c8774eee7fabcf))
+* configure new SDK language ([8b9d76c](https://github.com/beeper/desktop-api-python/commit/8b9d76c76fe4e3ae99d85429f20d9b782bea2520))
+* configure new SDK language ([a54d51a](https://github.com/beeper/desktop-api-python/commit/a54d51a23c31d38e124dc263f748f6ada2f2409c))
+* **internal:** add request options to SSE classes ([fcf96d3](https://github.com/beeper/desktop-api-python/commit/fcf96d3c4f3bdbec2cd1b88745cdbbc48e864be2))
+* **internal:** codegen related update ([a6b8aac](https://github.com/beeper/desktop-api-python/commit/a6b8aac8430c698cd1a73bab2cd257c9cf553df6))
+* **internal:** make `test_proxy_environment_variables` more resilient ([2420dd3](https://github.com/beeper/desktop-api-python/commit/2420dd3d3de95350f142acaf7fb923fd292af59e))
+* **internal:** make `test_proxy_environment_variables` more resilient to env ([1ad2ddf](https://github.com/beeper/desktop-api-python/commit/1ad2ddfe678d2a495d69e45d7a1a8f0856af4211))
+* **internal:** more robust bootstrap script ([ed8c2c4](https://github.com/beeper/desktop-api-python/commit/ed8c2c499c99ac2f1a4e83054ae0000cb9e12c47))
+* **internal:** reformat pyproject.toml ([f11e2a6](https://github.com/beeper/desktop-api-python/commit/f11e2a6c2e25d78bd95e0a81ce52e814dc2a9e79))
+* **internal:** tweak CI branches ([311a998](https://github.com/beeper/desktop-api-python/commit/311a998617de99c1defaa4c54f1b8a308d1bfaf3))
+* **internal:** update gitignore ([3dfb379](https://github.com/beeper/desktop-api-python/commit/3dfb3799f6ebbde6400db092864361e3b6a6e07f))
+* **test:** do not count install time for mock server timeout ([352dc26](https://github.com/beeper/desktop-api-python/commit/352dc26df496dac34b88c8d410a3d5761fad7cde))
+* **tests:** bump steady to v0.19.4 ([68f14af](https://github.com/beeper/desktop-api-python/commit/68f14afb85f168eb61a91398165b1d8222fbeea4))
+* **tests:** bump steady to v0.19.5 ([9229d32](https://github.com/beeper/desktop-api-python/commit/9229d32b59f8494f49677dbf508d2fedd21cd8b4))
+* **tests:** bump steady to v0.19.6 ([166b069](https://github.com/beeper/desktop-api-python/commit/166b069fbd3034b27d16baf50ef96c61a46996d9))
+* **tests:** bump steady to v0.19.7 ([31a8e58](https://github.com/beeper/desktop-api-python/commit/31a8e58a09bd798b9930c195da2be239d50a2e77))
+* **tests:** bump steady to v0.20.1 ([d2cf119](https://github.com/beeper/desktop-api-python/commit/d2cf119042313a4b82f4a395a43c175874ceca1e))
+* **tests:** bump steady to v0.20.2 ([0def55c](https://github.com/beeper/desktop-api-python/commit/0def55c17787849b27d1e8c21cb6cd129069e220))
+* **tests:** bump steady to v0.22.1 ([29127f7](https://github.com/beeper/desktop-api-python/commit/29127f7697a10191b913f86e8664321963b55003))
+* update placeholder string ([f9883db](https://github.com/beeper/desktop-api-python/commit/f9883db325ccb453c60affa4218f2b257c4d41d8))
+* update SDK settings ([b954521](https://github.com/beeper/desktop-api-python/commit/b954521c09743ea77dc1fe3f49e62cadb9cb6b1f))
+* update SDK settings ([5df69bf](https://github.com/beeper/desktop-api-python/commit/5df69bf22554340ee0fd0c694fb755c80907ee22))
+
+
+### Refactors
+
+* **tests:** switch from prism to steady ([ef99778](https://github.com/beeper/desktop-api-python/commit/ef99778f642f49a26aad1d65c59df9f9cfa766e9))
+
## 4.3.0 (2026-02-20)
Full Changelog: [v4.2.0...v4.3.0](https://github.com/beeper/desktop-api-python/compare/v4.2.0...v4.3.0)
diff --git a/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/README.md b/README.md
index b42ad75..bdee98a 100644
--- a/README.md
+++ b/README.md
@@ -337,10 +337,10 @@ Note that requests that time out are [retried twice by default](#retries).
We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module.
-You can enable logging by setting the environment variable `BEEPER_DESKTOP_LOG` to `info`.
+You can enable logging by setting the environment variable `BEEPER_LOG` to `info`.
```shell
-$ export BEEPER_DESKTOP_LOG=info
+$ export BEEPER_LOG=info
```
Or to `debug` for more verbose logging.
@@ -439,7 +439,7 @@ import httpx
from beeper_desktop_api import BeeperDesktop, DefaultHttpxClient
client = BeeperDesktop(
- # Or use the `BEEPER_DESKTOP_BASE_URL` env var
+ # Or use the `BEEPER_BASE_URL` env var
base_url="http://my.test.server.example.com:8083",
http_client=DefaultHttpxClient(
proxy="http://my.test.proxy.example.com",
diff --git a/api.md b/api.md
index 5efec0a..068f976 100644
--- a/api.md
+++ b/api.md
@@ -91,7 +91,7 @@ from beeper_desktop_api.types import MessageUpdateResponse, MessageSendResponse
Methods:
- client.messages.update(message_id, \*, chat_id, \*\*params) -> MessageUpdateResponse
-- client.messages.list(chat_id, \*\*params) -> SyncCursorSortKey[Message]
+- client.messages.list(chat_id, \*\*params) -> SyncCursorNoLimit[Message]
- client.messages.search(\*\*params) -> SyncCursorSearch[Message]
- client.messages.send(chat_id, \*\*params) -> MessageSendResponse
@@ -110,7 +110,7 @@ from beeper_desktop_api.types import (
Methods:
- client.assets.download(\*\*params) -> AssetDownloadResponse
-- client.assets.serve(\*\*params) -> None
+- client.assets.serve(\*\*params) -> BinaryAPIResponse
- client.assets.upload(\*\*params) -> AssetUploadResponse
- client.assets.upload_base64(\*\*params) -> AssetUploadBase64Response
diff --git a/pyproject.toml b/pyproject.toml
index 089b317..f04ae30 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "beeper_desktop_api"
-version = "4.3.0"
+version = "4.4.0"
description = "The official Python library for the beeperdesktop API"
dynamic = ["readme"]
license = "MIT"
@@ -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",
@@ -59,6 +59,7 @@ dev-dependencies = [
"importlib-metadata>=6.7.0",
"rich>=13.7.1",
"pytest-xdist>=3.6.1",
+ "griffe>=1",
]
[tool.rye.scripts]
@@ -168,7 +169,7 @@ show_error_codes = true
#
# We also exclude our `tests` as mypy doesn't always infer
# types correctly and Pyright will still catch any type errors.
-exclude = ['src/beeper_desktop_api/_files.py', '_dev/.*.py', 'tests/.*']
+exclude = ["src/beeper_desktop_api/_files.py", "_dev/.*.py", "tests/.*"]
strict_equality = true
implicit_reexport = true
diff --git a/requirements-dev.lock b/requirements-dev.lock
index 2fdb945..c74b1ba 100644
--- a/requirements-dev.lock
+++ b/requirements-dev.lock
@@ -34,6 +34,8 @@ backports-asyncio-runner==1.2.0
certifi==2026.1.4
# via httpcore
# via httpx
+colorama==0.4.6
+ # via griffe
colorlog==6.10.1
# via nox
dependency-groups==1.3.1
@@ -53,6 +55,7 @@ filelock==3.19.1
frozenlist==1.8.0
# via aiohttp
# via aiosignal
+griffe==1.14.0
h11==0.16.0
# via httpcore
httpcore==1.0.9
diff --git a/scripts/bootstrap b/scripts/bootstrap
index b430fee..fe8451e 100755
--- a/scripts/bootstrap
+++ b/scripts/bootstrap
@@ -4,7 +4,7 @@ set -e
cd "$(dirname "$0")/.."
-if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then
+if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then
brew bundle check >/dev/null 2>&1 || {
echo -n "==> Install Homebrew dependencies? (y/N): "
read -r response
diff --git a/scripts/detect-breaking-changes b/scripts/detect-breaking-changes
new file mode 100755
index 0000000..fb28f3a
--- /dev/null
+++ b/scripts/detect-breaking-changes
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+
+set -e
+
+cd "$(dirname "$0")/.."
+
+echo "==> Detecting breaking changes"
+
+TEST_PATHS=( tests/api_resources tests/test_client.py tests/test_response.py )
+
+for PATHSPEC in "${TEST_PATHS[@]}"; do
+ # Try to check out previous versions of the test files
+ # with the current SDK.
+ git checkout "$1" -- "${PATHSPEC}" 2>/dev/null || true
+done
+
+# Instead of running the tests, use the linter to check if an
+# older test is no longer compatible with the latest SDK.
+./scripts/lint
diff --git a/scripts/detect-breaking-changes.py b/scripts/detect-breaking-changes.py
new file mode 100644
index 0000000..c61e8ba
--- /dev/null
+++ b/scripts/detect-breaking-changes.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import sys
+from typing import Iterator
+from pathlib import Path
+
+import rich
+import griffe
+from rich.text import Text
+from rich.style import Style
+
+
+def public_members(obj: griffe.Object | griffe.Alias) -> dict[str, griffe.Object | griffe.Alias]:
+ if isinstance(obj, griffe.Alias):
+ # ignore imports for now, they're technically part of the public API
+ # but we don't have good preventative measures in place to prevent
+ # changing them
+ return {}
+
+ return {name: value for name, value in obj.all_members.items() if not name.startswith("_")}
+
+
+def find_breaking_changes(
+ new_obj: griffe.Object | griffe.Alias,
+ old_obj: griffe.Object | griffe.Alias,
+ *,
+ path: list[str],
+) -> Iterator[Text | str]:
+ new_members = public_members(new_obj)
+ old_members = public_members(old_obj)
+
+ for name, old_member in old_members.items():
+ if isinstance(old_member, griffe.Alias) and len(path) > 2:
+ # ignore imports in `/types/` for now, they're technically part of the public API
+ # but we don't have good preventative measures in place to prevent changing them
+ continue
+
+ new_member = new_members.get(name)
+ if new_member is None:
+ cls_name = old_member.__class__.__name__
+ yield Text(f"({cls_name})", style=Style(color="rgb(119, 119, 119)"))
+ yield from [" " for _ in range(10 - len(cls_name))]
+ yield f" {'.'.join(path)}.{name}"
+ yield "\n"
+ continue
+
+ yield from find_breaking_changes(new_member, old_member, path=[*path, name])
+
+
+def main() -> None:
+ try:
+ against_ref = sys.argv[1]
+ except IndexError as err:
+ raise RuntimeError("You must specify a base ref to run breaking change detection against") from err
+
+ package = griffe.load(
+ "beeper_desktop_api",
+ search_paths=[Path(__file__).parent.parent.joinpath("src")],
+ )
+ old_package = griffe.load_git(
+ "beeper_desktop_api",
+ ref=against_ref,
+ search_paths=["src"],
+ )
+ assert isinstance(package, griffe.Module)
+ assert isinstance(old_package, griffe.Module)
+
+ output = list(find_breaking_changes(package, old_package, path=["beeper_desktop_api"]))
+ if output:
+ rich.print(Text("Breaking changes detected!", style=Style(color="rgb(165, 79, 87)")))
+ rich.print()
+
+ for text in output:
+ rich.print(text, end="")
+
+ sys.exit(1)
+
+
+main()
diff --git a/scripts/mock b/scripts/mock
index 0b28f6e..9c7c439 100755
--- a/scripts/mock
+++ b/scripts/mock
@@ -19,23 +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
- npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log &
+ # Pre-install the package so the download doesn't eat into the startup timeout
+ npm exec --package=@stdy/cli@0.22.1 -- steady --version
- # Wait for server to come online
+ npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log &
+
+ # Wait for server to come online via health endpoint (max 30s)
echo -n "Waiting for server"
- while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do
+ attempts=0
+ 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 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.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL"
fi
diff --git a/scripts/test b/scripts/test
index dbeda2d..0159035 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.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}"
echo
exit 1
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
diff --git a/src/beeper_desktop_api/_base_client.py b/src/beeper_desktop_api/_base_client.py
index 25424b1..5bce507 100644
--- a/src/beeper_desktop_api/_base_client.py
+++ b/src/beeper_desktop_api/_base_client.py
@@ -63,7 +63,7 @@
)
from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping
from ._compat import PYDANTIC_V1, model_copy, model_dump
-from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type
+from ._models import GenericModel, SecurityOptions, FinalRequestOptions, validate_type, construct_type
from ._response import (
APIResponse,
BaseAPIResponse,
@@ -432,9 +432,27 @@ def _make_status_error(
) -> _exceptions.APIStatusError:
raise NotImplementedError()
+ def _auth_headers(
+ self,
+ security: SecurityOptions, # noqa: ARG002
+ ) -> dict[str, str]:
+ return {}
+
+ def _auth_query(
+ self,
+ security: SecurityOptions, # noqa: ARG002
+ ) -> dict[str, str]:
+ return {}
+
+ def _custom_auth(
+ self,
+ security: SecurityOptions, # noqa: ARG002
+ ) -> httpx.Auth | None:
+ return None
+
def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0) -> httpx.Headers:
custom_headers = options.headers or {}
- headers_dict = _merge_mappings(self.default_headers, custom_headers)
+ headers_dict = _merge_mappings({**self._auth_headers(options.security), **self.default_headers}, custom_headers)
self._validate_headers(headers_dict, custom_headers)
# headers are case-insensitive while dictionaries are not.
@@ -506,7 +524,7 @@ def _build_request(
raise RuntimeError(f"Unexpected JSON data type, {type(json_data)}, cannot merge with `extra_body`")
headers = self._build_headers(options, retries_taken=retries_taken)
- params = _merge_mappings(self.default_query, options.params)
+ params = _merge_mappings({**self._auth_query(options.security), **self.default_query}, options.params)
content_type = headers.get("Content-Type")
files = options.files
@@ -540,6 +558,10 @@ def _build_request(
files = cast(HttpxRequestFiles, ForceMultipartDict())
prepared_url = self._prepare_url(options.url)
+ # preserve hard-coded query params from the url
+ if params and prepared_url.query:
+ params = {**dict(prepared_url.params.items()), **params}
+ prepared_url = prepared_url.copy_with(raw_path=prepared_url.raw_path.split(b"?", 1)[0])
if "_" in prepared_url.host:
# work around https://github.com/encode/httpx/discussions/2880
kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")}
@@ -671,7 +693,6 @@ def default_headers(self) -> dict[str, str | Omit]:
"Content-Type": "application/json",
"User-Agent": self.user_agent,
**self.platform_headers(),
- **self.auth_headers,
**self._custom_headers,
}
@@ -990,8 +1011,9 @@ def request(
self._prepare_request(request)
kwargs: HttpxSendArgs = {}
- if self.custom_auth is not None:
- kwargs["auth"] = self.custom_auth
+ custom_auth = self._custom_auth(options.security)
+ if custom_auth is not None:
+ kwargs["auth"] = custom_auth
if options.follow_redirects is not None:
kwargs["follow_redirects"] = options.follow_redirects
@@ -1952,6 +1974,7 @@ def make_request_options(
idempotency_key: str | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
post_parser: PostParser | NotGiven = not_given,
+ security: SecurityOptions | None = None,
) -> RequestOptions:
"""Create a dict of type RequestOptions without keys of NotGiven values."""
options: RequestOptions = {}
@@ -1977,6 +2000,9 @@ def make_request_options(
# internal
options["post_parser"] = post_parser # type: ignore
+ if security is not None:
+ options["security"] = security
+
return options
diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py
index 2dc0bf9..bf45e4d 100644
--- a/src/beeper_desktop_api/_client.py
+++ b/src/beeper_desktop_api/_client.py
@@ -26,11 +26,13 @@
)
from ._utils import (
is_given,
+ is_mapping_t,
maybe_transform,
get_async_library,
async_maybe_transform,
)
from ._compat import cached_property
+from ._models import SecurityOptions
from ._version import __version__
from ._response import (
to_raw_response_wrapper,
@@ -109,10 +111,19 @@ def __init__(
self.access_token = access_token
if base_url is None:
- base_url = os.environ.get("BEEPER_DESKTOP_BASE_URL")
+ base_url = os.environ.get("BEEPER_BASE_URL")
if base_url is None:
base_url = f"http://localhost:23373"
+ custom_headers_env = os.environ.get("BEEPER_CUSTOM_HEADERS")
+ if custom_headers_env is not None:
+ parsed: dict[str, str] = {}
+ for line in custom_headers_env.split("\n"):
+ colon = line.find(":")
+ if colon >= 0:
+ parsed[line[:colon].strip()] = line[colon + 1 :].strip()
+ default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})}
+
super().__init__(
version=__version__,
base_url=base_url,
@@ -154,6 +165,7 @@ def assets(self) -> AssetsResource:
@cached_property
def info(self) -> InfoResource:
+ """Control the Beeper Desktop application"""
from .resources.info import InfoResource
return InfoResource(self)
@@ -171,9 +183,14 @@ def with_streaming_response(self) -> BeeperDesktopWithStreamedResponse:
def qs(self) -> Querystring:
return Querystring(array_format="repeat")
- @property
@override
- def auth_headers(self) -> dict[str, str]:
+ def _auth_headers(self, security: SecurityOptions) -> dict[str, str]:
+ return {
+ **(self._bearer_auth if security.get("bearer_auth", False) else {}),
+ }
+
+ @property
+ def _bearer_auth(self) -> dict[str, str]:
access_token = self.access_token
return {"Authorization": f"Bearer {access_token}"}
@@ -403,10 +420,19 @@ def __init__(
self.access_token = access_token
if base_url is None:
- base_url = os.environ.get("BEEPER_DESKTOP_BASE_URL")
+ base_url = os.environ.get("BEEPER_BASE_URL")
if base_url is None:
base_url = f"http://localhost:23373"
+ custom_headers_env = os.environ.get("BEEPER_CUSTOM_HEADERS")
+ if custom_headers_env is not None:
+ parsed: dict[str, str] = {}
+ for line in custom_headers_env.split("\n"):
+ colon = line.find(":")
+ if colon >= 0:
+ parsed[line[:colon].strip()] = line[colon + 1 :].strip()
+ default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})}
+
super().__init__(
version=__version__,
base_url=base_url,
@@ -448,6 +474,7 @@ def assets(self) -> AsyncAssetsResource:
@cached_property
def info(self) -> AsyncInfoResource:
+ """Control the Beeper Desktop application"""
from .resources.info import AsyncInfoResource
return AsyncInfoResource(self)
@@ -465,9 +492,14 @@ def with_streaming_response(self) -> AsyncBeeperDesktopWithStreamedResponse:
def qs(self) -> Querystring:
return Querystring(array_format="repeat")
- @property
@override
- def auth_headers(self) -> dict[str, str]:
+ def _auth_headers(self, security: SecurityOptions) -> dict[str, str]:
+ return {
+ **(self._bearer_auth if security.get("bearer_auth", False) else {}),
+ }
+
+ @property
+ def _bearer_auth(self) -> dict[str, str]:
access_token = self.access_token
return {"Authorization": f"Bearer {access_token}"}
@@ -700,6 +732,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 +781,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 +830,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 +879,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/_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]",
diff --git a/src/beeper_desktop_api/_files.py b/src/beeper_desktop_api/_files.py
index e0ef7aa..8a371d3 100644
--- a/src/beeper_desktop_api/_files.py
+++ b/src/beeper_desktop_api/_files.py
@@ -3,8 +3,8 @@
import io
import os
import pathlib
-from typing import overload
-from typing_extensions import TypeGuard
+from typing import Sequence, cast, overload
+from typing_extensions import TypeVar, TypeGuard
import anyio
@@ -17,7 +17,9 @@
HttpxFileContent,
HttpxRequestFiles,
)
-from ._utils import is_tuple_t, is_mapping_t, is_sequence_t
+from ._utils import is_list, is_mapping, is_tuple_t, is_mapping_t, is_sequence_t
+
+_T = TypeVar("_T")
def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]:
@@ -121,3 +123,51 @@ async def async_read_file_content(file: FileContent) -> HttpxFileContent:
return await anyio.Path(file).read_bytes()
return file
+
+
+def deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]]) -> _T:
+ """Copy only the containers along the given paths.
+
+ Used to guard against mutation by extract_files without copying the entire structure.
+ Only dicts and lists that lie on a path are copied; everything else
+ is returned by reference.
+
+ For example, given paths=[["foo", "files", "file"]] and the structure:
+ {
+ "foo": {
+ "bar": {"baz": {}},
+ "files": {"file": }
+ }
+ }
+ The root dict, "foo", and "files" are copied (they lie on the path).
+ "bar" and "baz" are returned by reference (off the path).
+ """
+ return _deepcopy_with_paths(item, paths, 0)
+
+
+def _deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]], index: int) -> _T:
+ if not paths:
+ return item
+ if is_mapping(item):
+ key_to_paths: dict[str, list[Sequence[str]]] = {}
+ for path in paths:
+ if index < len(path):
+ key_to_paths.setdefault(path[index], []).append(path)
+
+ # if no path continues through this mapping, it won't be mutated and copying it is redundant
+ if not key_to_paths:
+ return item
+
+ result = dict(item)
+ for key, subpaths in key_to_paths.items():
+ if key in result:
+ result[key] = _deepcopy_with_paths(result[key], subpaths, index + 1)
+ return cast(_T, result)
+ if is_list(item):
+ array_paths = [path for path in paths if index < len(path) and path[index] == ""]
+
+ # if no path expects a list here, nothing will be mutated inside it - return by reference
+ if not array_paths:
+ return cast(_T, item)
+ return cast(_T, [_deepcopy_with_paths(entry, array_paths, index + 1) for entry in item])
+ return item
diff --git a/src/beeper_desktop_api/_models.py b/src/beeper_desktop_api/_models.py
index 29070e0..e22dd2a 100644
--- a/src/beeper_desktop_api/_models.py
+++ b/src/beeper_desktop_api/_models.py
@@ -791,6 +791,10 @@ def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]:
return RootModel[type_] # type: ignore
+class SecurityOptions(TypedDict, total=False):
+ bearer_auth: bool
+
+
class FinalRequestOptionsInput(TypedDict, total=False):
method: Required[str]
url: Required[str]
@@ -804,6 +808,7 @@ class FinalRequestOptionsInput(TypedDict, total=False):
json_data: Body
extra_json: AnyMapping
follow_redirects: bool
+ security: SecurityOptions
@final
@@ -818,6 +823,7 @@ class FinalRequestOptions(pydantic.BaseModel):
idempotency_key: Union[str, None] = None
post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven()
follow_redirects: Union[bool, None] = None
+ security: SecurityOptions = {"bearer_auth": True}
content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None
# It should be noted that we cannot use `json` here as that would override
diff --git a/src/beeper_desktop_api/_qs.py b/src/beeper_desktop_api/_qs.py
index ada6fd3..4127c19 100644
--- a/src/beeper_desktop_api/_qs.py
+++ b/src/beeper_desktop_api/_qs.py
@@ -2,17 +2,13 @@
from typing import Any, List, Tuple, Union, Mapping, TypeVar
from urllib.parse import parse_qs, urlencode
-from typing_extensions import Literal, get_args
+from typing_extensions import get_args
-from ._types import NotGiven, not_given
+from ._types import NotGiven, ArrayFormat, NestedFormat, not_given
from ._utils import flatten
_T = TypeVar("_T")
-
-ArrayFormat = Literal["comma", "repeat", "indices", "brackets"]
-NestedFormat = Literal["dots", "brackets"]
-
PrimitiveData = Union[str, int, float, bool, None]
# this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"]
# https://github.com/microsoft/pyright/issues/3555
@@ -101,7 +97,10 @@ def _stringify_item(
items.extend(self._stringify_item(key, item, opts))
return items
elif array_format == "indices":
- raise NotImplementedError("The array indices format is not supported yet")
+ items = []
+ for i, item in enumerate(value):
+ items.extend(self._stringify_item(f"{key}[{i}]", item, opts))
+ return items
elif array_format == "brackets":
items = []
key = key + "[]"
diff --git a/src/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__()
diff --git a/src/beeper_desktop_api/_types.py b/src/beeper_desktop_api/_types.py
index 2880d78..a131d99 100644
--- a/src/beeper_desktop_api/_types.py
+++ b/src/beeper_desktop_api/_types.py
@@ -36,7 +36,7 @@
from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport
if TYPE_CHECKING:
- from ._models import BaseModel
+ from ._models import BaseModel, SecurityOptions
from ._response import APIResponse, AsyncAPIResponse
Transport = BaseTransport
@@ -47,6 +47,9 @@
ModelT = TypeVar("ModelT", bound=pydantic.BaseModel)
_T = TypeVar("_T")
+ArrayFormat = Literal["comma", "repeat", "indices", "brackets"]
+NestedFormat = Literal["dots", "brackets"]
+
# Approximates httpx internal ProxiesTypes and RequestFiles types
# while adding support for `PathLike` instances
@@ -121,6 +124,7 @@ class RequestOptions(TypedDict, total=False):
extra_json: AnyMapping
idempotency_key: str
follow_redirects: bool
+ security: SecurityOptions
# Sentinel class used until PEP 0661 is accepted
diff --git a/src/beeper_desktop_api/_utils/__init__.py b/src/beeper_desktop_api/_utils/__init__.py
index dc64e29..1c090e5 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 (
@@ -23,7 +24,6 @@
coerce_integer as coerce_integer,
file_from_path as file_from_path,
strip_not_given as strip_not_given,
- deepcopy_minimal as deepcopy_minimal,
get_async_library as get_async_library,
maybe_coerce_float as maybe_coerce_float,
get_required_header as get_required_header,
diff --git a/src/beeper_desktop_api/_utils/_logs.py b/src/beeper_desktop_api/_utils/_logs.py
index da351d5..96d73d5 100644
--- a/src/beeper_desktop_api/_utils/_logs.py
+++ b/src/beeper_desktop_api/_utils/_logs.py
@@ -14,7 +14,7 @@ def _basic_config() -> None:
def setup_logging() -> None:
- env = os.environ.get("BEEPER_DESKTOP_LOG")
+ env = os.environ.get("BEEPER_LOG")
if env == "debug":
_basic_config()
logger.setLevel(logging.DEBUG)
diff --git a/src/beeper_desktop_api/_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/_utils/_utils.py b/src/beeper_desktop_api/_utils/_utils.py
index eec7f4a..199cd23 100644
--- a/src/beeper_desktop_api/_utils/_utils.py
+++ b/src/beeper_desktop_api/_utils/_utils.py
@@ -17,11 +17,11 @@
)
from pathlib import Path
from datetime import date, datetime
-from typing_extensions import TypeGuard
+from typing_extensions import TypeGuard, get_args
import sniffio
-from .._types import Omit, NotGiven, FileTypes, HeadersLike
+from .._types import Omit, NotGiven, FileTypes, ArrayFormat, HeadersLike
_T = TypeVar("_T")
_TupleT = TypeVar("_TupleT", bound=Tuple[object, ...])
@@ -40,25 +40,45 @@ def extract_files(
query: Mapping[str, object],
*,
paths: Sequence[Sequence[str]],
+ array_format: ArrayFormat = "brackets",
) -> list[tuple[str, FileTypes]]:
"""Recursively extract files from the given dictionary based on specified paths.
A path may look like this ['foo', 'files', '', 'data'].
+ ``array_format`` controls how ```` segments contribute to the emitted
+ field name. Supported values: ``"brackets"`` (``foo[]``), ``"repeat"`` and
+ ``"comma"`` (``foo``), ``"indices"`` (``foo[0]``, ``foo[1]``).
+
Note: this mutates the given dictionary.
"""
files: list[tuple[str, FileTypes]] = []
for path in paths:
- files.extend(_extract_items(query, path, index=0, flattened_key=None))
+ files.extend(_extract_items(query, path, index=0, flattened_key=None, array_format=array_format))
return files
+def _array_suffix(array_format: ArrayFormat, array_index: int) -> str:
+ if array_format == "brackets":
+ return "[]"
+ if array_format == "indices":
+ return f"[{array_index}]"
+ if array_format == "repeat" or array_format == "comma":
+ # Both repeat the bare field name for each file part; there is no
+ # meaningful way to comma-join binary parts.
+ return ""
+ raise NotImplementedError(
+ f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}"
+ )
+
+
def _extract_items(
obj: object,
path: Sequence[str],
*,
index: int,
flattened_key: str | None,
+ array_format: ArrayFormat,
) -> list[tuple[str, FileTypes]]:
try:
key = path[index]
@@ -75,9 +95,11 @@ def _extract_items(
if is_list(obj):
files: list[tuple[str, FileTypes]] = []
- for entry in obj:
- assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "")
- files.append((flattened_key + "[]", cast(FileTypes, entry)))
+ for array_index, entry in enumerate(obj):
+ suffix = _array_suffix(array_format, array_index)
+ emitted_key = (flattened_key + suffix) if flattened_key else suffix
+ assert_is_file_content(entry, key=emitted_key)
+ files.append((emitted_key, cast(FileTypes, entry)))
return files
assert_is_file_content(obj, key=flattened_key)
@@ -86,8 +108,9 @@ def _extract_items(
index += 1
if is_dict(obj):
try:
- # We are at the last entry in the path so we must remove the field
- if (len(path)) == index:
+ # Remove the field if there are no more dict keys in the path,
+ # only "" traversal markers or end.
+ if all(p == "" for p in path[index:]):
item = obj.pop(key)
else:
item = obj[key]
@@ -105,6 +128,7 @@ def _extract_items(
path,
index=index,
flattened_key=flattened_key,
+ array_format=array_format,
)
elif is_list(obj):
if key != "":
@@ -116,9 +140,12 @@ def _extract_items(
item,
path,
index=index,
- flattened_key=flattened_key + "[]" if flattened_key is not None else "[]",
+ flattened_key=(
+ (flattened_key if flattened_key is not None else "") + _array_suffix(array_format, array_index)
+ ),
+ array_format=array_format,
)
- for item in obj
+ for array_index, item in enumerate(obj)
]
)
@@ -176,21 +203,6 @@ def is_iterable(obj: object) -> TypeGuard[Iterable[object]]:
return isinstance(obj, Iterable)
-def deepcopy_minimal(item: _T) -> _T:
- """Minimal reimplementation of copy.deepcopy() that will only copy certain object types:
-
- - mappings, e.g. `dict`
- - list
-
- This is done for performance reasons.
- """
- if is_mapping(item):
- return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()})
- if is_list(item):
- return cast(_T, [deepcopy_minimal(entry) for entry in item])
- return item
-
-
# copied from https://github.com/Rapptz/RoboDanny
def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str:
size = len(seq)
diff --git a/src/beeper_desktop_api/_version.py b/src/beeper_desktop_api/_version.py
index 1bc95e4..d6b05f3 100644
--- a/src/beeper_desktop_api/_version.py
+++ b/src/beeper_desktop_api/_version.py
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
__title__ = "beeper_desktop_api"
-__version__ = "4.3.0" # x-release-please-version
+__version__ = "4.4.0" # x-release-please-version
diff --git a/src/beeper_desktop_api/pagination.py b/src/beeper_desktop_api/pagination.py
index 03ecb2a..b3dc44c 100644
--- a/src/beeper_desktop_api/pagination.py
+++ b/src/beeper_desktop_api/pagination.py
@@ -1,29 +1,17 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-from typing import Any, List, Generic, TypeVar, Optional, cast
-from typing_extensions import Protocol, override, runtime_checkable
+from typing import List, Generic, TypeVar, Optional
+from typing_extensions import override
from pydantic import Field as FieldInfo
from ._base_client import BasePage, PageInfo, BaseSyncPage, BaseAsyncPage
-__all__ = [
- "SyncCursorSearch",
- "AsyncCursorSearch",
- "SyncCursorNoLimit",
- "AsyncCursorNoLimit",
- "SyncCursorSortKey",
- "AsyncCursorSortKey",
-]
+__all__ = ["SyncCursorSearch", "AsyncCursorSearch", "SyncCursorNoLimit", "AsyncCursorNoLimit"]
_T = TypeVar("_T")
-@runtime_checkable
-class CursorSortKeyItem(Protocol):
- sort_key: Optional[str]
-
-
class SyncCursorSearch(BaseSyncPage[_T], BasePage[_T], Generic[_T]):
items: List[_T]
has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None)
@@ -142,69 +130,3 @@ def next_page_info(self) -> Optional[PageInfo]:
return None
return PageInfo(params={"cursor": oldest_cursor})
-
-
-class SyncCursorSortKey(BaseSyncPage[_T], BasePage[_T], Generic[_T]):
- items: List[_T]
- has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None)
-
- @override
- def _get_page_items(self) -> List[_T]:
- items = self.items
- if not items:
- return []
- return items
-
- @override
- def has_next_page(self) -> bool:
- has_more = self.has_more
- if has_more is not None and has_more is False:
- return False
-
- return super().has_next_page()
-
- @override
- def next_page_info(self) -> Optional[PageInfo]:
- items = self.items
- if not items:
- return None
-
- item = cast(Any, items[-1])
- if not isinstance(item, CursorSortKeyItem) or item.sort_key is None:
- # TODO emit warning log
- return None
-
- return PageInfo(params={"cursor": item.sort_key})
-
-
-class AsyncCursorSortKey(BaseAsyncPage[_T], BasePage[_T], Generic[_T]):
- items: List[_T]
- has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None)
-
- @override
- def _get_page_items(self) -> List[_T]:
- items = self.items
- if not items:
- return []
- return items
-
- @override
- def has_next_page(self) -> bool:
- has_more = self.has_more
- if has_more is not None and has_more is False:
- return False
-
- return super().has_next_page()
-
- @override
- def next_page_info(self) -> Optional[PageInfo]:
- items = self.items
- if not items:
- return None
-
- item = cast(Any, items[-1])
- if not isinstance(item, CursorSortKeyItem) or item.sort_key is None:
- # TODO emit warning log
- return None
-
- return PageInfo(params={"cursor": item.sort_key})
diff --git a/src/beeper_desktop_api/resources/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/assets.py b/src/beeper_desktop_api/resources/assets.py
index db5dce4..dc85070 100644
--- a/src/beeper_desktop_api/resources/assets.py
+++ b/src/beeper_desktop_api/resources/assets.py
@@ -7,15 +7,24 @@
import httpx
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 .._files import deepcopy_with_paths
+from .._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given
+from .._utils import extract_files, maybe_transform, async_maybe_transform
from .._compat import cached_property
from .._resource import SyncAPIResource, AsyncAPIResource
from .._response import (
+ BinaryAPIResponse,
+ AsyncBinaryAPIResponse,
+ StreamedBinaryAPIResponse,
+ AsyncStreamedBinaryAPIResponse,
to_raw_response_wrapper,
to_streamed_response_wrapper,
async_to_raw_response_wrapper,
+ to_custom_raw_response_wrapper,
async_to_streamed_response_wrapper,
+ to_custom_streamed_response_wrapper,
+ async_to_custom_raw_response_wrapper,
+ async_to_custom_streamed_response_wrapper,
)
from .._base_client import make_request_options
from ..types.asset_upload_response import AssetUploadResponse
@@ -92,7 +101,7 @@ def serve(
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
- ) -> None:
+ ) -> BinaryAPIResponse:
"""Stream a file given an mxc://, localmxc://, or file:// URL.
Downloads first if
@@ -109,7 +118,7 @@ def serve(
timeout: Override the client-level default timeout for this request, in seconds
"""
- extra_headers = {"Accept": "*/*", **(extra_headers or {})}
+ extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})}
return self._get(
"/v1/assets/serve",
options=make_request_options(
@@ -119,7 +128,7 @@ def serve(
timeout=timeout,
query=maybe_transform({"url": url}, asset_serve_params.AssetServeParams),
),
- cast_to=NoneType,
+ cast_to=BinaryAPIResponse,
)
def upload(
@@ -155,12 +164,13 @@ def upload(
timeout: Override the client-level default timeout for this request, in seconds
"""
- body = deepcopy_minimal(
+ body = deepcopy_with_paths(
{
"file": file,
"file_name": file_name,
"mime_type": mime_type,
- }
+ },
+ [["file"]],
)
files = extract_files(cast(Mapping[str, object], body), paths=[["file"]])
# It should be noted that the actual Content-Type header that will be
@@ -295,7 +305,7 @@ async def serve(
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
- ) -> None:
+ ) -> AsyncBinaryAPIResponse:
"""Stream a file given an mxc://, localmxc://, or file:// URL.
Downloads first if
@@ -312,7 +322,7 @@ async def serve(
timeout: Override the client-level default timeout for this request, in seconds
"""
- extra_headers = {"Accept": "*/*", **(extra_headers or {})}
+ extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})}
return await self._get(
"/v1/assets/serve",
options=make_request_options(
@@ -322,7 +332,7 @@ async def serve(
timeout=timeout,
query=await async_maybe_transform({"url": url}, asset_serve_params.AssetServeParams),
),
- cast_to=NoneType,
+ cast_to=AsyncBinaryAPIResponse,
)
async def upload(
@@ -358,12 +368,13 @@ async def upload(
timeout: Override the client-level default timeout for this request, in seconds
"""
- body = deepcopy_minimal(
+ body = deepcopy_with_paths(
{
"file": file,
"file_name": file_name,
"mime_type": mime_type,
- }
+ },
+ [["file"]],
)
files = extract_files(cast(Mapping[str, object], body), paths=[["file"]])
# It should be noted that the actual Content-Type header that will be
@@ -438,8 +449,9 @@ def __init__(self, assets: AssetsResource) -> None:
self.download = to_raw_response_wrapper(
assets.download,
)
- self.serve = to_raw_response_wrapper(
+ self.serve = to_custom_raw_response_wrapper(
assets.serve,
+ BinaryAPIResponse,
)
self.upload = to_raw_response_wrapper(
assets.upload,
@@ -456,8 +468,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None:
self.download = async_to_raw_response_wrapper(
assets.download,
)
- self.serve = async_to_raw_response_wrapper(
+ self.serve = async_to_custom_raw_response_wrapper(
assets.serve,
+ AsyncBinaryAPIResponse,
)
self.upload = async_to_raw_response_wrapper(
assets.upload,
@@ -474,8 +487,9 @@ def __init__(self, assets: AssetsResource) -> None:
self.download = to_streamed_response_wrapper(
assets.download,
)
- self.serve = to_streamed_response_wrapper(
+ self.serve = to_custom_streamed_response_wrapper(
assets.serve,
+ StreamedBinaryAPIResponse,
)
self.upload = to_streamed_response_wrapper(
assets.upload,
@@ -492,8 +506,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None:
self.download = async_to_streamed_response_wrapper(
assets.download,
)
- self.serve = async_to_streamed_response_wrapper(
+ self.serve = async_to_custom_streamed_response_wrapper(
assets.serve,
+ AsyncStreamedBinaryAPIResponse,
)
self.upload = async_to_streamed_response_wrapper(
assets.upload,
diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py
index 6a3cdb0..318ebf3 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,
@@ -82,7 +82,7 @@ def create(
account_id: str,
allow_invite: bool | Omit = omit,
message_text: str | Omit = omit,
- mode: Literal["create", "start"] | Omit = omit,
+ mode: Literal["start", "create"] | Omit = omit,
participant_ids: SequenceNotStr[str] | Omit = omit,
title: str | Omit = omit,
type: Literal["single", "group"] | Omit = omit,
@@ -95,28 +95,29 @@ def create(
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> ChatCreateResponse:
"""
- Create a single/group chat (mode='create') or start a direct chat from merged
- user data (mode='start').
+ Create a direct or group chat with mode="create", or use mode="start" to resolve
+ a contact and open a direct chat.
Args:
account_id: Account to create or start the chat on.
- allow_invite: Whether invite-based DM creation is allowed when required by the platform. Used
- for mode='start'.
+ allow_invite: Only used for mode='start'. Whether invite-based DM creation is allowed when
+ required by the platform.
message_text: Optional first message content if the platform requires it to create the chat.
- mode: Operation mode. Defaults to 'create' when omitted.
+ mode: Operation mode. Use 'start' to resolve a user/contact and start a direct chat.
+ Omit or set 'create' to create a chat directly.
- participant_ids: Required when mode='create'. User IDs to include in the new chat.
+ participant_ids: Required for create mode. Provide exactly one user ID for 'single' chats and one
+ or more for 'group' chats.
- title: Optional title for group chats when mode='create'; ignored for single chats on
- most platforms.
+ title: Optional title for group chats; ignored for single chats on most networks.
- type: Required when mode='create'. 'single' requires exactly one participantID;
- 'group' supports multiple participants and optional title.
+ type: Required for create mode. 'single' creates a direct message chat; 'group'
+ creates a group chat.
- user: Required when mode='start'. Merged user-like contact payload used to resolve the
+ user: Required for mode='start'. Merged user-like contact payload used to resolve the
best identifier.
extra_headers: Send extra headers
@@ -180,7 +181,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 +282,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
@@ -312,8 +313,7 @@ def search(
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> SyncCursorSearch[Chat]:
"""
- Search chats by title/network or participants using Beeper Desktop's renderer
- algorithm.
+ Search chats by title, network, or participant names.
Args:
account_ids: Provide an array of account IDs to filter chats from specific messaging accounts
@@ -425,7 +425,7 @@ async def create(
account_id: str,
allow_invite: bool | Omit = omit,
message_text: str | Omit = omit,
- mode: Literal["create", "start"] | Omit = omit,
+ mode: Literal["start", "create"] | Omit = omit,
participant_ids: SequenceNotStr[str] | Omit = omit,
title: str | Omit = omit,
type: Literal["single", "group"] | Omit = omit,
@@ -438,28 +438,29 @@ async def create(
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> ChatCreateResponse:
"""
- Create a single/group chat (mode='create') or start a direct chat from merged
- user data (mode='start').
+ Create a direct or group chat with mode="create", or use mode="start" to resolve
+ a contact and open a direct chat.
Args:
account_id: Account to create or start the chat on.
- allow_invite: Whether invite-based DM creation is allowed when required by the platform. Used
- for mode='start'.
+ allow_invite: Only used for mode='start'. Whether invite-based DM creation is allowed when
+ required by the platform.
message_text: Optional first message content if the platform requires it to create the chat.
- mode: Operation mode. Defaults to 'create' when omitted.
+ mode: Operation mode. Use 'start' to resolve a user/contact and start a direct chat.
+ Omit or set 'create' to create a chat directly.
- participant_ids: Required when mode='create'. User IDs to include in the new chat.
+ participant_ids: Required for create mode. Provide exactly one user ID for 'single' chats and one
+ or more for 'group' chats.
- title: Optional title for group chats when mode='create'; ignored for single chats on
- most platforms.
+ title: Optional title for group chats; ignored for single chats on most networks.
- type: Required when mode='create'. 'single' requires exactly one participantID;
- 'group' supports multiple participants and optional title.
+ type: Required for create mode. 'single' creates a direct message chat; 'group'
+ creates a group chat.
- user: Required when mode='start'. Merged user-like contact payload used to resolve the
+ user: Required for mode='start'. Merged user-like contact payload used to resolve the
best identifier.
extra_headers: Send extra headers
@@ -523,7 +524,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 +625,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
@@ -655,8 +656,7 @@ def search(
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> AsyncPaginator[Chat, AsyncCursorSearch[Chat]]:
"""
- Search chats by title/network or participants using Beeper Desktop's renderer
- algorithm.
+ Search chats by title, network, or participant names.
Args:
account_ids: Provide an array of account IDs to filter chats from specific messaging accounts
diff --git a/src/beeper_desktop_api/resources/chats/messages/reactions.py b/src/beeper_desktop_api/resources/chats/messages/reactions.py
index d9e610d..bdf93a5 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 (
@@ -58,7 +58,7 @@ def delete(
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> ReactionDeleteResponse:
"""
- Remove the authenticated user's reaction from an existing message.
+ Remove the reaction added by the authenticated user from an existing message.
Args:
chat_id: Unique identifier of the chat.
@@ -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,
@@ -177,7 +181,7 @@ async def delete(
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> ReactionDeleteResponse:
"""
- Remove the authenticated user's reaction from an existing message.
+ Remove the reaction added by the authenticated user from an existing message.
Args:
chat_id: Unique identifier of the chat.
@@ -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/info.py b/src/beeper_desktop_api/resources/info.py
index 43a98bf..2b33a3d 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:
"""
@@ -56,13 +58,19 @@ def retrieve(
return self._get(
"/v1/info",
options=make_request_options(
- extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ security={},
),
cast_to=InfoRetrieveResponse,
)
class AsyncInfoResource(AsyncAPIResource):
+ """Control the Beeper Desktop application"""
+
@cached_property
def with_raw_response(self) -> AsyncInfoResourceWithRawResponse:
"""
@@ -99,7 +107,11 @@ async def retrieve(
return await self._get(
"/v1/info",
options=make_request_options(
- extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ security={},
),
cast_to=InfoRetrieveResponse,
)
diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py
index b97c7a0..e0be745 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 (
@@ -19,7 +19,7 @@
async_to_raw_response_wrapper,
async_to_streamed_response_wrapper,
)
-from ..pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorSortKey, AsyncCursorSortKey
+from ..pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorNoLimit, AsyncCursorNoLimit
from .._base_client import AsyncPaginator, make_request_options
from ..types.shared.message import Message
from ..types.message_send_response import MessageSendResponse
@@ -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
@@ -106,7 +106,7 @@ def list(
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
- ) -> SyncCursorSortKey[Message]:
+ ) -> SyncCursorNoLimit[Message]:
"""List all messages in a chat with cursor-based pagination.
Sorted by timestamp.
@@ -130,8 +130,8 @@ 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",
- page=SyncCursorSortKey[Message],
+ path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id),
+ page=SyncCursorNoLimit[Message],
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -172,7 +172,7 @@ def search(
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> SyncCursorSearch[Message]:
"""
- Search messages across chats using Beeper's message index
+ Search messages across chats.
Args:
account_ids: Limit search to specific account IDs.
@@ -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
@@ -382,7 +382,7 @@ def list(
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
- ) -> AsyncPaginator[Message, AsyncCursorSortKey[Message]]:
+ ) -> AsyncPaginator[Message, AsyncCursorNoLimit[Message]]:
"""List all messages in a chat with cursor-based pagination.
Sorted by timestamp.
@@ -406,8 +406,8 @@ 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",
- page=AsyncCursorSortKey[Message],
+ path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id),
+ page=AsyncCursorNoLimit[Message],
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -448,7 +448,7 @@ def search(
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> AsyncPaginator[Message, AsyncCursorSearch[Message]]:
"""
- Search messages across chats using Beeper's message index
+ Search messages across chats.
Args:
account_ids: Limit search to specific account IDs.
@@ -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/src/beeper_desktop_api/types/account.py b/src/beeper_desktop_api/types/account.py
index ff00c78..bb569a7 100644
--- a/src/beeper_desktop_api/types/account.py
+++ b/src/beeper_desktop_api/types/account.py
@@ -1,18 +1,43 @@
# 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
from .shared.user import User
-__all__ = ["Account"]
+__all__ = ["Account", "Bridge"]
+
+
+class Bridge(BaseModel):
+ """Bridge metadata for the account. Available in Beeper Desktop v4.2.789+."""
+
+ id: str
+ """Bridge instance identifier. Available in Beeper Desktop v4.2.789+."""
+
+ provider: Literal["cloud", "self-hosted", "local", "platform-sdk"]
+ """Bridge provider for the account. Available in Beeper Desktop v4.2.789+."""
+
+ type: str
+ """Bridge type. Available in Beeper Desktop v4.2.789+."""
class Account(BaseModel):
- """A chat account added to Beeper"""
+ """A chat account added to Beeper."""
account_id: str = FieldInfo(alias="accountID")
"""Chat account added to Beeper. Use this to route account-scoped actions."""
+ bridge: Bridge
+ """Bridge metadata for the account. Available in Beeper Desktop v4.2.789+."""
+
user: User
"""User the account belongs to."""
+
+ network: Optional[str] = None
+ """Human-friendly network name for the account.
+
+ Omitted when the network is unknown.
+ """
diff --git a/src/beeper_desktop_api/types/chat_create_params.py b/src/beeper_desktop_api/types/chat_create_params.py
index 93229c1..b63be4f 100644
--- a/src/beeper_desktop_api/types/chat_create_params.py
+++ b/src/beeper_desktop_api/types/chat_create_params.py
@@ -15,42 +15,46 @@ class ChatCreateParams(TypedDict, total=False):
"""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.
+ """Only used for mode='start'.
- Used for mode='start'.
+ 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."""
- mode: Literal["create", "start"]
- """Operation mode. Defaults to 'create' when omitted."""
+ mode: Literal["start", "create"]
+ """Operation mode.
+
+ Use 'start' to resolve a user/contact and start a direct chat. Omit or set
+ 'create' to create a chat directly.
+ """
participant_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")]
- """Required when mode='create'. User IDs to include in the new chat."""
+ """Required for create mode.
- title: str
- """
- Optional title for group chats when mode='create'; ignored for single chats on
- most platforms.
+ Provide exactly one user ID for 'single' chats and one or more for 'group'
+ chats.
"""
+ title: str
+ """Optional title for group chats; ignored for single chats on most networks."""
+
type: Literal["single", "group"]
- """Required when mode='create'.
+ """Required for create mode.
- 'single' requires exactly one participantID; 'group' supports multiple
- participants and optional title.
+ 'single' creates a direct message chat; 'group' creates a group chat.
"""
user: User
- """Required when mode='start'.
+ """Required for mode='start'.
Merged user-like contact payload used to resolve the best identifier.
"""
class User(TypedDict, total=False):
- """Required when mode='start'.
+ """Required for mode='start'.
Merged user-like contact payload used to resolve the best identifier.
"""
diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py
index 64927ac..f63f7bb 100644
--- a/tests/api_resources/test_assets.py
+++ b/tests/api_resources/test_assets.py
@@ -5,7 +5,9 @@
import os
from typing import Any, cast
+import httpx
import pytest
+from respx import MockRouter
from tests.utils import assert_matches_type
from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop
@@ -14,6 +16,12 @@
AssetDownloadResponse,
AssetUploadBase64Response,
)
+from beeper_desktop_api._response import (
+ BinaryAPIResponse,
+ AsyncBinaryAPIResponse,
+ StreamedBinaryAPIResponse,
+ AsyncStreamedBinaryAPIResponse,
+)
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
@@ -53,47 +61,58 @@ def test_streaming_response_download(self, client: BeeperDesktop) -> None:
assert cast(Any, response.is_closed) is True
@parametrize
- def test_method_serve(self, client: BeeperDesktop) -> None:
+ @pytest.mark.respx(base_url=base_url)
+ def test_method_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None:
+ respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
asset = client.assets.serve(
url="x",
)
- assert asset is None
+ assert asset.is_closed
+ assert asset.json() == {"foo": "bar"}
+ assert cast(Any, asset.is_closed) is True
+ assert isinstance(asset, BinaryAPIResponse)
@parametrize
- def test_raw_response_serve(self, client: BeeperDesktop) -> None:
- response = client.assets.with_raw_response.serve(
+ @pytest.mark.respx(base_url=base_url)
+ def test_raw_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None:
+ respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+
+ asset = client.assets.with_raw_response.serve(
url="x",
)
- assert response.is_closed is True
- assert response.http_request.headers.get("X-Stainless-Lang") == "python"
- asset = response.parse()
- assert asset is None
+ assert asset.is_closed is True
+ assert asset.http_request.headers.get("X-Stainless-Lang") == "python"
+ assert asset.json() == {"foo": "bar"}
+ assert isinstance(asset, BinaryAPIResponse)
@parametrize
- def test_streaming_response_serve(self, client: BeeperDesktop) -> None:
+ @pytest.mark.respx(base_url=base_url)
+ def test_streaming_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None:
+ respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
with client.assets.with_streaming_response.serve(
url="x",
- ) as response:
- assert not response.is_closed
- assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ ) as asset:
+ assert not asset.is_closed
+ assert asset.http_request.headers.get("X-Stainless-Lang") == "python"
- asset = response.parse()
- assert asset is None
+ assert asset.json() == {"foo": "bar"}
+ assert cast(Any, asset.is_closed) is True
+ assert isinstance(asset, StreamedBinaryAPIResponse)
- assert cast(Any, response.is_closed) is True
+ assert cast(Any, asset.is_closed) is True
@parametrize
def test_method_upload(self, client: BeeperDesktop) -> None:
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 +121,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 +132,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"
@@ -201,47 +220,58 @@ async def test_streaming_response_download(self, async_client: AsyncBeeperDeskto
assert cast(Any, response.is_closed) is True
@parametrize
- async def test_method_serve(self, async_client: AsyncBeeperDesktop) -> None:
+ @pytest.mark.respx(base_url=base_url)
+ async def test_method_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None:
+ respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
asset = await async_client.assets.serve(
url="x",
)
- assert asset is None
+ assert asset.is_closed
+ assert await asset.json() == {"foo": "bar"}
+ assert cast(Any, asset.is_closed) is True
+ assert isinstance(asset, AsyncBinaryAPIResponse)
@parametrize
- async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop) -> None:
- response = await async_client.assets.with_raw_response.serve(
+ @pytest.mark.respx(base_url=base_url)
+ async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None:
+ respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+
+ asset = await async_client.assets.with_raw_response.serve(
url="x",
)
- assert response.is_closed is True
- assert response.http_request.headers.get("X-Stainless-Lang") == "python"
- asset = await response.parse()
- assert asset is None
+ assert asset.is_closed is True
+ assert asset.http_request.headers.get("X-Stainless-Lang") == "python"
+ assert await asset.json() == {"foo": "bar"}
+ assert isinstance(asset, AsyncBinaryAPIResponse)
@parametrize
- async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop) -> None:
+ @pytest.mark.respx(base_url=base_url)
+ async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None:
+ respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
async with async_client.assets.with_streaming_response.serve(
url="x",
- ) as response:
- assert not response.is_closed
- assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ ) as asset:
+ assert not asset.is_closed
+ assert asset.http_request.headers.get("X-Stainless-Lang") == "python"
- asset = await response.parse()
- assert asset is None
+ assert await asset.json() == {"foo": "bar"}
+ assert cast(Any, asset.is_closed) is True
+ assert isinstance(asset, AsyncStreamedBinaryAPIResponse)
- assert cast(Any, response.is_closed) is True
+ assert cast(Any, asset.is_closed) is True
@parametrize
async def test_method_upload(self, async_client: AsyncBeeperDesktop) -> None:
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 +280,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 +291,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"
diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py
index b899add..f03276e 100644
--- a/tests/api_resources/test_chats.py
+++ b/tests/api_resources/test_chats.py
@@ -36,7 +36,7 @@ def test_method_create_with_all_params(self, client: BeeperDesktop) -> None:
account_id="accountID",
allow_invite=True,
message_text="messageText",
- mode="create",
+ mode="start",
participant_ids=["string"],
title="title",
type="single",
@@ -268,7 +268,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesk
account_id="accountID",
allow_invite=True,
message_text="messageText",
- mode="create",
+ mode="start",
participant_ids=["string"],
title="title",
type="single",
diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py
index a167221..fec66ee 100644
--- a/tests/api_resources/test_messages.py
+++ b/tests/api_resources/test_messages.py
@@ -14,7 +14,7 @@
MessageUpdateResponse,
)
from beeper_desktop_api._utils import parse_datetime
-from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorSortKey, AsyncCursorSortKey
+from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorNoLimit, AsyncCursorNoLimit
from beeper_desktop_api.types.shared import Message
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
@@ -81,7 +81,7 @@ def test_method_list(self, client: BeeperDesktop) -> None:
message = client.messages.list(
chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com",
)
- assert_matches_type(SyncCursorSortKey[Message], message, path=["response"])
+ assert_matches_type(SyncCursorNoLimit[Message], message, path=["response"])
@parametrize
def test_method_list_with_all_params(self, client: BeeperDesktop) -> None:
@@ -90,7 +90,7 @@ def test_method_list_with_all_params(self, client: BeeperDesktop) -> None:
cursor="1725489123456|c29tZUltc2dQYWdl",
direction="before",
)
- assert_matches_type(SyncCursorSortKey[Message], message, path=["response"])
+ assert_matches_type(SyncCursorNoLimit[Message], message, path=["response"])
@parametrize
def test_raw_response_list(self, client: BeeperDesktop) -> None:
@@ -101,7 +101,7 @@ def test_raw_response_list(self, client: BeeperDesktop) -> None:
assert response.is_closed is True
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
message = response.parse()
- assert_matches_type(SyncCursorSortKey[Message], message, path=["response"])
+ assert_matches_type(SyncCursorNoLimit[Message], message, path=["response"])
@parametrize
def test_streaming_response_list(self, client: BeeperDesktop) -> None:
@@ -112,7 +112,7 @@ def test_streaming_response_list(self, client: BeeperDesktop) -> None:
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
message = response.parse()
- assert_matches_type(SyncCursorSortKey[Message], message, path=["response"])
+ assert_matches_type(SyncCursorNoLimit[Message], message, path=["response"])
assert cast(Any, response.is_closed) is True
@@ -292,7 +292,7 @@ async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None:
message = await async_client.messages.list(
chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com",
)
- assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"])
+ assert_matches_type(AsyncCursorNoLimit[Message], message, path=["response"])
@parametrize
async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None:
@@ -301,7 +301,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncBeeperDeskto
cursor="1725489123456|c29tZUltc2dQYWdl",
direction="before",
)
- assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"])
+ assert_matches_type(AsyncCursorNoLimit[Message], message, path=["response"])
@parametrize
async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None:
@@ -312,7 +312,7 @@ async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None
assert response.is_closed is True
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
message = await response.parse()
- assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"])
+ assert_matches_type(AsyncCursorNoLimit[Message], message, path=["response"])
@parametrize
async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None:
@@ -323,7 +323,7 @@ async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -
assert response.http_request.headers.get("X-Stainless-Lang") == "python"
message = await response.parse()
- assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"])
+ assert_matches_type(AsyncCursorNoLimit[Message], message, path=["response"])
assert cast(Any, response.is_closed) is True
diff --git a/tests/test_client.py b/tests/test_client.py
index 5cbca10..d9ab686 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -443,6 +443,30 @@ def test_default_query_option(self) -> None:
client.close()
+ def test_hardcoded_query_params_in_url(self, client: BeeperDesktop) -> None:
+ request = client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true"))
+ url = httpx.URL(request.url)
+ assert dict(url.params) == {"beta": "true"}
+
+ request = client._build_request(
+ FinalRequestOptions(
+ method="get",
+ url="/foo?beta=true",
+ params={"limit": "10", "page": "abc"},
+ )
+ )
+ url = httpx.URL(request.url)
+ assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"}
+
+ request = client._build_request(
+ FinalRequestOptions(
+ method="get",
+ url="/files/a%2Fb?beta=true",
+ params={"limit": "10"},
+ )
+ )
+ assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10"
+
def test_request_extra_json(self, client: BeeperDesktop) -> None:
request = client._build_request(
FinalRequestOptions(
@@ -705,7 +729,7 @@ def test_base_url_setter(self) -> None:
client.close()
def test_base_url_env(self) -> None:
- with update_env(BEEPER_DESKTOP_BASE_URL="http://localhost:5000/from/env"):
+ with update_env(BEEPER_BASE_URL="http://localhost:5000/from/env"):
client = BeeperDesktop(access_token=access_token, _strict_response_validation=True)
assert client.base_url == "http://localhost:5000/from/env/"
@@ -984,6 +1008,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 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()
@@ -1358,6 +1390,30 @@ async def test_default_query_option(self) -> None:
await client.close()
+ async def test_hardcoded_query_params_in_url(self, async_client: AsyncBeeperDesktop) -> None:
+ request = async_client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true"))
+ url = httpx.URL(request.url)
+ assert dict(url.params) == {"beta": "true"}
+
+ request = async_client._build_request(
+ FinalRequestOptions(
+ method="get",
+ url="/foo?beta=true",
+ params={"limit": "10", "page": "abc"},
+ )
+ )
+ url = httpx.URL(request.url)
+ assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"}
+
+ request = async_client._build_request(
+ FinalRequestOptions(
+ method="get",
+ url="/files/a%2Fb?beta=true",
+ params={"limit": "10"},
+ )
+ )
+ assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10"
+
def test_request_extra_json(self, client: BeeperDesktop) -> None:
request = client._build_request(
FinalRequestOptions(
@@ -1624,7 +1680,7 @@ async def test_base_url_setter(self) -> None:
await client.close()
async def test_base_url_env(self) -> None:
- with update_env(BEEPER_DESKTOP_BASE_URL="http://localhost:5000/from/env"):
+ with update_env(BEEPER_BASE_URL="http://localhost:5000/from/env"):
client = AsyncBeeperDesktop(access_token=access_token, _strict_response_validation=True)
assert client.base_url == "http://localhost:5000/from/env/"
@@ -1916,6 +1972,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 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()
diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py
deleted file mode 100644
index 6288bda..0000000
--- a/tests/test_deepcopy.py
+++ /dev/null
@@ -1,58 +0,0 @@
-from beeper_desktop_api._utils import deepcopy_minimal
-
-
-def assert_different_identities(obj1: object, obj2: object) -> None:
- assert obj1 == obj2
- assert id(obj1) != id(obj2)
-
-
-def test_simple_dict() -> None:
- obj1 = {"foo": "bar"}
- obj2 = deepcopy_minimal(obj1)
- assert_different_identities(obj1, obj2)
-
-
-def test_nested_dict() -> None:
- obj1 = {"foo": {"bar": True}}
- obj2 = deepcopy_minimal(obj1)
- assert_different_identities(obj1, obj2)
- assert_different_identities(obj1["foo"], obj2["foo"])
-
-
-def test_complex_nested_dict() -> None:
- obj1 = {"foo": {"bar": [{"hello": "world"}]}}
- obj2 = deepcopy_minimal(obj1)
- assert_different_identities(obj1, obj2)
- assert_different_identities(obj1["foo"], obj2["foo"])
- assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"])
- assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0])
-
-
-def test_simple_list() -> None:
- obj1 = ["a", "b", "c"]
- obj2 = deepcopy_minimal(obj1)
- assert_different_identities(obj1, obj2)
-
-
-def test_nested_list() -> None:
- obj1 = ["a", [1, 2, 3]]
- obj2 = deepcopy_minimal(obj1)
- assert_different_identities(obj1, obj2)
- assert_different_identities(obj1[1], obj2[1])
-
-
-class MyObject: ...
-
-
-def test_ignores_other_types() -> None:
- # custom classes
- my_obj = MyObject()
- obj1 = {"foo": my_obj}
- obj2 = deepcopy_minimal(obj1)
- assert_different_identities(obj1, obj2)
- assert obj1["foo"] is my_obj
-
- # tuples
- obj3 = ("a", "b")
- obj4 = deepcopy_minimal(obj3)
- assert obj3 is obj4
diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py
index 497fb79..889c22e 100644
--- a/tests/test_extract_files.py
+++ b/tests/test_extract_files.py
@@ -4,7 +4,7 @@
import pytest
-from beeper_desktop_api._types import FileTypes
+from beeper_desktop_api._types import FileTypes, ArrayFormat
from beeper_desktop_api._utils import extract_files
@@ -35,6 +35,12 @@ def test_multiple_files() -> None:
assert query == {"documents": [{}, {}]}
+def test_top_level_file_array() -> None:
+ query = {"files": [b"file one", b"file two"], "title": "hello"}
+ assert extract_files(query, paths=[["files", ""]]) == [("files[]", b"file one"), ("files[]", b"file two")]
+ assert query == {"title": "hello"}
+
+
@pytest.mark.parametrize(
"query,paths,expected",
[
@@ -62,3 +68,24 @@ def test_ignores_incorrect_paths(
expected: list[tuple[str, FileTypes]],
) -> None:
assert extract_files(query, paths=paths) == expected
+
+
+@pytest.mark.parametrize(
+ "array_format,expected_top_level,expected_nested",
+ [
+ ("brackets", [("files[]", b"a"), ("files[]", b"b")], [("items[][file]", b"a"), ("items[][file]", b"b")]),
+ ("repeat", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]),
+ ("comma", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]),
+ ("indices", [("files[0]", b"a"), ("files[1]", b"b")], [("items[0][file]", b"a"), ("items[1][file]", b"b")]),
+ ],
+)
+def test_array_format_controls_file_field_names(
+ array_format: ArrayFormat,
+ expected_top_level: list[tuple[str, FileTypes]],
+ expected_nested: list[tuple[str, FileTypes]],
+) -> None:
+ top_level = {"files": [b"a", b"b"]}
+ assert extract_files(top_level, paths=[["files", ""]], array_format=array_format) == expected_top_level
+
+ nested = {"items": [{"file": b"a"}, {"file": b"b"}]}
+ assert extract_files(nested, paths=[["items", "", "file"]], array_format=array_format) == expected_nested
diff --git a/tests/test_files.py b/tests/test_files.py
index 76be5ce..c7492da 100644
--- a/tests/test_files.py
+++ b/tests/test_files.py
@@ -4,7 +4,8 @@
import pytest
from dirty_equals import IsDict, IsList, IsBytes, IsTuple
-from beeper_desktop_api._files import to_httpx_files, async_to_httpx_files
+from beeper_desktop_api._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files
+from beeper_desktop_api._utils import extract_files
readme_path = Path(__file__).parent.parent.joinpath("README.md")
@@ -49,3 +50,99 @@ def test_string_not_allowed() -> None:
"file": "foo", # type: ignore
}
)
+
+
+def assert_different_identities(obj1: object, obj2: object) -> None:
+ assert obj1 == obj2
+ assert obj1 is not obj2
+
+
+class TestDeepcopyWithPaths:
+ def test_copies_top_level_dict(self) -> None:
+ original = {"file": b"data", "other": "value"}
+ result = deepcopy_with_paths(original, [["file"]])
+ assert_different_identities(result, original)
+
+ def test_file_value_is_same_reference(self) -> None:
+ file_bytes = b"contents"
+ original = {"file": file_bytes}
+ result = deepcopy_with_paths(original, [["file"]])
+ assert_different_identities(result, original)
+ assert result["file"] is file_bytes
+
+ def test_list_popped_wholesale(self) -> None:
+ files = [b"f1", b"f2"]
+ original = {"files": files, "title": "t"}
+ result = deepcopy_with_paths(original, [["files", ""]])
+ assert_different_identities(result, original)
+ result_files = result["files"]
+ assert isinstance(result_files, list)
+ assert_different_identities(result_files, files)
+
+ def test_nested_array_path_copies_list_and_elements(self) -> None:
+ elem1 = {"file": b"f1", "extra": 1}
+ elem2 = {"file": b"f2", "extra": 2}
+ original = {"items": [elem1, elem2]}
+ result = deepcopy_with_paths(original, [["items", "", "file"]])
+ assert_different_identities(result, original)
+ result_items = result["items"]
+ assert isinstance(result_items, list)
+ assert_different_identities(result_items, original["items"])
+ assert_different_identities(result_items[0], elem1)
+ assert_different_identities(result_items[1], elem2)
+
+ def test_empty_paths_returns_same_object(self) -> None:
+ original = {"foo": "bar"}
+ result = deepcopy_with_paths(original, [])
+ assert result is original
+
+ def test_multiple_paths(self) -> None:
+ f1 = b"file1"
+ f2 = b"file2"
+ original = {"a": f1, "b": f2, "c": "unchanged"}
+ result = deepcopy_with_paths(original, [["a"], ["b"]])
+ assert_different_identities(result, original)
+ assert result["a"] is f1
+ assert result["b"] is f2
+ assert result["c"] is original["c"]
+
+ def test_extract_files_does_not_mutate_original_top_level(self) -> None:
+ file_bytes = b"contents"
+ original = {"file": file_bytes, "other": "value"}
+
+ copied = deepcopy_with_paths(original, [["file"]])
+ extracted = extract_files(copied, paths=[["file"]])
+
+ assert extracted == [("file", file_bytes)]
+ assert original == {"file": file_bytes, "other": "value"}
+ assert copied == {"other": "value"}
+
+ def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None:
+ file1 = b"f1"
+ file2 = b"f2"
+ original = {
+ "items": [
+ {"file": file1, "extra": 1},
+ {"file": file2, "extra": 2},
+ ],
+ "title": "example",
+ }
+
+ copied = deepcopy_with_paths(original, [["items", "", "file"]])
+ extracted = extract_files(copied, paths=[["items", "", "file"]])
+
+ assert [entry for _, entry in extracted] == [file1, file2]
+ assert original == {
+ "items": [
+ {"file": file1, "extra": 1},
+ {"file": file2, "extra": 2},
+ ],
+ "title": "example",
+ }
+ assert copied == {
+ "items": [
+ {"extra": 1},
+ {"extra": 2},
+ ],
+ "title": "example",
+ }
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)