From 0c7a328c881cf7f03177404ce892b1478c36feb6 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 21:09:24 +0000 Subject: [PATCH 001/176] chore: configure new SDK language --- .devcontainer/Dockerfile | 9 + .devcontainer/devcontainer.json | 43 + .github/workflows/ci.yml | 76 + .gitignore | 16 + .python-version | 1 + .stats.yml | 4 + Brewfile | 2 + CONTRIBUTING.md | 129 ++ LICENSE | 201 ++ README.md | 349 ++- SECURITY.md | 23 + api.md | 21 + bin/publish-pypi | 6 + examples/.keep | 4 + mypy.ini | 50 + noxfile.py | 9 + pyproject.toml | 207 ++ requirements-dev.lock | 104 + requirements.lock | 45 + scripts/bootstrap | 19 + scripts/format | 8 + scripts/lint | 11 + scripts/mock | 41 + scripts/test | 61 + scripts/utils/ruffen-docs.py | 167 ++ scripts/utils/upload-artifact.sh | 25 + src/brand/__init__.py | 1 + src/brand/dev/__init__.py | 99 + src/brand/dev/_base_client.py | 1943 +++++++++++++++++ src/brand/dev/_client.py | 403 ++++ src/brand/dev/_compat.py | 219 ++ src/brand/dev/_constants.py | 14 + src/brand/dev/_exceptions.py | 108 + src/brand/dev/_files.py | 123 ++ src/brand/dev/_models.py | 803 +++++++ src/brand/dev/_qs.py | 150 ++ src/brand/dev/_resource.py | 43 + src/brand/dev/_response.py | 830 +++++++ src/brand/dev/_streaming.py | 333 +++ src/brand/dev/_types.py | 217 ++ src/brand/dev/_utils/__init__.py | 57 + src/brand/dev/_utils/_logs.py | 25 + src/brand/dev/_utils/_proxy.py | 65 + src/brand/dev/_utils/_reflection.py | 42 + src/brand/dev/_utils/_resources_proxy.py | 24 + src/brand/dev/_utils/_streams.py | 12 + src/brand/dev/_utils/_sync.py | 86 + src/brand/dev/_utils/_transform.py | 447 ++++ src/brand/dev/_utils/_typing.py | 151 ++ src/brand/dev/_utils/_utils.py | 422 ++++ src/brand/dev/_version.py | 4 + src/brand/dev/lib/.keep | 4 + src/brand/dev/py.typed | 0 src/brand/dev/resources/__init__.py | 19 + src/brand/dev/resources/brand.py | 673 ++++++ src/brand/dev/types/__init__.py | 18 + .../brand_identify_from_transaction_params.py | 12 + ...rand_identify_from_transaction_response.py | 185 ++ .../types/brand_retrieve_by_ticker_params.py | 12 + .../brand_retrieve_by_ticker_response.py | 185 ++ .../dev/types/brand_retrieve_naics_params.py | 16 + .../types/brand_retrieve_naics_response.py | 29 + src/brand/dev/types/brand_retrieve_params.py | 68 + .../dev/types/brand_retrieve_response.py | 185 ++ src/brand/dev/types/brand_search_params.py | 12 + src/brand/dev/types/brand_search_response.py | 22 + tests/__init__.py | 1 + tests/api_resources/__init__.py | 1 + tests/api_resources/test_brand.py | 386 ++++ tests/conftest.py | 51 + tests/sample_file.txt | 1 + tests/test_client.py | 1637 ++++++++++++++ tests/test_deepcopy.py | 58 + tests/test_extract_files.py | 64 + tests/test_files.py | 51 + tests/test_models.py | 891 ++++++++ tests/test_qs.py | 78 + tests/test_required_args.py | 111 + tests/test_response.py | 277 +++ tests/test_streaming.py | 248 +++ tests/test_transform.py | 453 ++++ tests/test_utils/test_proxy.py | 34 + tests/test_utils/test_typing.py | 73 + tests/utils.py | 159 ++ 84 files changed, 14265 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 .stats.yml create mode 100644 Brewfile create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 api.md create mode 100644 bin/publish-pypi create mode 100644 examples/.keep create mode 100644 mypy.ini create mode 100644 noxfile.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.lock create mode 100644 requirements.lock create mode 100755 scripts/bootstrap create mode 100755 scripts/format create mode 100755 scripts/lint create mode 100755 scripts/mock create mode 100755 scripts/test create mode 100644 scripts/utils/ruffen-docs.py create mode 100755 scripts/utils/upload-artifact.sh create mode 100644 src/brand/__init__.py create mode 100644 src/brand/dev/__init__.py create mode 100644 src/brand/dev/_base_client.py create mode 100644 src/brand/dev/_client.py create mode 100644 src/brand/dev/_compat.py create mode 100644 src/brand/dev/_constants.py create mode 100644 src/brand/dev/_exceptions.py create mode 100644 src/brand/dev/_files.py create mode 100644 src/brand/dev/_models.py create mode 100644 src/brand/dev/_qs.py create mode 100644 src/brand/dev/_resource.py create mode 100644 src/brand/dev/_response.py create mode 100644 src/brand/dev/_streaming.py create mode 100644 src/brand/dev/_types.py create mode 100644 src/brand/dev/_utils/__init__.py create mode 100644 src/brand/dev/_utils/_logs.py create mode 100644 src/brand/dev/_utils/_proxy.py create mode 100644 src/brand/dev/_utils/_reflection.py create mode 100644 src/brand/dev/_utils/_resources_proxy.py create mode 100644 src/brand/dev/_utils/_streams.py create mode 100644 src/brand/dev/_utils/_sync.py create mode 100644 src/brand/dev/_utils/_transform.py create mode 100644 src/brand/dev/_utils/_typing.py create mode 100644 src/brand/dev/_utils/_utils.py create mode 100644 src/brand/dev/_version.py create mode 100644 src/brand/dev/lib/.keep create mode 100644 src/brand/dev/py.typed create mode 100644 src/brand/dev/resources/__init__.py create mode 100644 src/brand/dev/resources/brand.py create mode 100644 src/brand/dev/types/__init__.py create mode 100644 src/brand/dev/types/brand_identify_from_transaction_params.py create mode 100644 src/brand/dev/types/brand_identify_from_transaction_response.py create mode 100644 src/brand/dev/types/brand_retrieve_by_ticker_params.py create mode 100644 src/brand/dev/types/brand_retrieve_by_ticker_response.py create mode 100644 src/brand/dev/types/brand_retrieve_naics_params.py create mode 100644 src/brand/dev/types/brand_retrieve_naics_response.py create mode 100644 src/brand/dev/types/brand_retrieve_params.py create mode 100644 src/brand/dev/types/brand_retrieve_response.py create mode 100644 src/brand/dev/types/brand_search_params.py create mode 100644 src/brand/dev/types/brand_search_response.py create mode 100644 tests/__init__.py create mode 100644 tests/api_resources/__init__.py create mode 100644 tests/api_resources/test_brand.py create mode 100644 tests/conftest.py create mode 100644 tests/sample_file.txt create mode 100644 tests/test_client.py create mode 100644 tests/test_deepcopy.py create mode 100644 tests/test_extract_files.py create mode 100644 tests/test_files.py create mode 100644 tests/test_models.py create mode 100644 tests/test_qs.py create mode 100644 tests/test_required_args.py create mode 100644 tests/test_response.py create mode 100644 tests/test_streaming.py create mode 100644 tests/test_transform.py create mode 100644 tests/test_utils/test_proxy.py create mode 100644 tests/test_utils/test_typing.py create mode 100644 tests/utils.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..ff261ba --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +ARG VARIANT="3.9" +FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} + +USER vscode + +RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.44.0" RYE_INSTALL_OPTION="--yes" bash +ENV PATH=/home/vscode/.rye/shims:$PATH + +RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..c17fdc1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,43 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/debian +{ + "name": "Debian", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + + "postStartCommand": "rye sync --all-features", + + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python" + ], + "settings": { + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": ".venv/bin/python", + "python.defaultInterpreterPath": ".venv/bin/python", + "python.typeChecking": "basic", + "terminal.integrated.env.linux": { + "PATH": "/home/vscode/.rye/shims:${env:PATH}" + } + } + } + }, + "features": { + "ghcr.io/devcontainers/features/node:1": {} + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c5f2211 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI +on: + push: + branches-ignore: + - 'generated' + - 'codegen/**' + - 'integrated/**' + - 'stl-preview-head/**' + - 'stl-preview-base/**' + +jobs: + lint: + timeout-minutes: 10 + name: lint + runs-on: ${{ github.repository == 'stainless-sdks/brand.dev-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Install dependencies + run: rye sync --all-features + + - name: Run lints + run: ./scripts/lint + + upload: + if: github.repository == 'stainless-sdks/brand.dev-python' + timeout-minutes: 10 + name: upload + permissions: + contents: read + id-token: write + runs-on: depot-ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Get GitHub OIDC Token + id: github-oidc + uses: actions/github-script@v6 + with: + script: core.setOutput('github_token', await core.getIDToken()); + + - name: Upload tarball + env: + URL: https://pkg.stainless.com/s + AUTH: ${{ steps.github-oidc.outputs.github_token }} + SHA: ${{ github.sha }} + run: ./scripts/utils/upload-artifact.sh + + test: + timeout-minutes: 10 + name: test + runs-on: ${{ github.repository == 'stainless-sdks/brand.dev-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Run tests + run: ./scripts/test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8779740 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.prism.log +.vscode +_dev + +__pycache__ +.mypy_cache + +dist + +.venv +.idea + +.env +.envrc +codegen.log +Brewfile.lock.json diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..43077b2 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9.18 diff --git a/.stats.yml b/.stats.yml new file mode 100644 index 0000000..5bd2afb --- /dev/null +++ b/.stats.yml @@ -0,0 +1,4 @@ +configured_endpoints: 5 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-bf411a5dc003606804e8544b1ae67679048e1e709ee932d0ee84d005507a3095.yml +openapi_spec_hash: b910fd6625c8b2f3451a03df123cd420 +config_hash: a98599ff88bf44ea1ecb26366b802452 diff --git a/Brewfile b/Brewfile new file mode 100644 index 0000000..492ca37 --- /dev/null +++ b/Brewfile @@ -0,0 +1,2 @@ +brew "rye" + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..112a797 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,129 @@ +## Setting up the environment + +### With Rye + +We use [Rye](https://rye.astral.sh/) to manage dependencies because it will automatically provision a Python environment with the expected Python version. To set it up, run: + +```sh +$ ./scripts/bootstrap +``` + +Or [install Rye manually](https://rye.astral.sh/guide/installation/) and run: + +```sh +$ rye sync --all-features +``` + +You can then run scripts using `rye run python script.py` or by activating the virtual environment: + +```sh +$ rye shell +# or manually activate - https://docs.python.org/3/library/venv.html#how-venvs-work +$ source .venv/bin/activate + +# now you can omit the `rye run` prefix +$ python script.py +``` + +### Without Rye + +Alternatively if you don't want to install `Rye`, you can stick with the standard `pip` setup by ensuring you have the Python version specified in `.python-version`, create a virtual environment however you desire and then install dependencies using this command: + +```sh +$ pip install -r requirements-dev.lock +``` + +## Modifying/Adding code + +Most of the SDK is generated code. Modifications to code will be persisted between generations, but may +result in merge conflicts between manual patches and changes from the generator. The generator will never +modify the contents of the `src/brand/dev/lib/` and `examples/` directories. + +## Adding and running examples + +All files in the `examples/` directory are not modified by the generator and can be freely edited or added to. + +```py +# add an example to examples/.py + +#!/usr/bin/env -S rye run python +… +``` + +```sh +$ chmod +x examples/.py +# run the example against your api +$ ./examples/.py +``` + +## Using the repository from source + +If you’d like to use the repository from source, you can either install from git or link to a cloned repository: + +To install via git: + +```sh +$ pip install git+ssh://git@github.com/stainless-sdks/brand.dev-python.git +``` + +Alternatively, you can build from source and install the wheel file: + +Building this package will create two files in the `dist/` directory, a `.tar.gz` containing the source files and a `.whl` that can be used to install the package efficiently. + +To create a distributable version of the library, all you have to do is run this command: + +```sh +$ rye build +# or +$ python -m build +``` + +Then to install: + +```sh +$ pip install ./path-to-wheel-file.whl +``` + +## Running tests + +Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. + +```sh +# you will need npm installed +$ npx prism mock path/to/your/openapi.yml +``` + +```sh +$ ./scripts/test +``` + +## Linting and formatting + +This repository uses [ruff](https://github.com/astral-sh/ruff) and +[black](https://github.com/psf/black) to format the code in the repository. + +To lint: + +```sh +$ ./scripts/lint +``` + +To format and fix all ruff issues automatically: + +```sh +$ ./scripts/format +``` + +## Publishing and releases + +Changes made to this repository via the automated release PR pipeline should publish to PyPI automatically. If +the changes aren't made through the automated pipeline, you may want to make releases manually. + +### Publish with a GitHub workflow + +You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/stainless-sdks/brand.dev-python/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. + +### Publish manually + +If you need to manually release a package, you can run the `bin/publish-pypi` script with a `PYPI_TOKEN` set on +the environment. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d93da50 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Brand Dev + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 35d13de..9fd47dc 100644 --- a/README.md +++ b/README.md @@ -1 +1,348 @@ -# brand.dev-python \ No newline at end of file +# Brand Dev Python API library + +[![PyPI version](https://img.shields.io/pypi/v/brand.dev.svg)](https://pypi.org/project/brand.dev/) + +The Brand Dev Python library provides convenient access to the Brand Dev REST API from any Python 3.8+ +application. The library includes type definitions for all request params and response fields, +and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). + +It is generated with [Stainless](https://www.stainless.com/). + +## Documentation + +The full API of this library can be found in [api.md](api.md). + +## Installation + +```sh +# install from this staging repo +pip install git+ssh://git@github.com/stainless-sdks/brand.dev-python.git +``` + +> [!NOTE] +> Once this package is [published to PyPI](https://app.stainless.com/docs/guides/publish), this will become: `pip install --pre brand.dev` + +## Usage + +The full API of this library can be found in [api.md](api.md). + +```python +import os +from brand.dev import BrandDev + +client = BrandDev( + api_key=os.environ.get("BRAND_DEV_API_KEY"), # This is the default and can be omitted +) + +brand = client.brand.retrieve( + domain="REPLACE_ME", +) +print(brand.brand) +``` + +While you can provide an `api_key` keyword argument, +we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) +to add `BRAND_DEV_API_KEY="My API Key"` to your `.env` file +so that your API Key is not stored in source control. + +## Async usage + +Simply import `AsyncBrandDev` instead of `BrandDev` and use `await` with each API call: + +```python +import os +import asyncio +from brand.dev import AsyncBrandDev + +client = AsyncBrandDev( + api_key=os.environ.get("BRAND_DEV_API_KEY"), # This is the default and can be omitted +) + + +async def main() -> None: + brand = await client.brand.retrieve( + domain="REPLACE_ME", + ) + print(brand.brand) + + +asyncio.run(main()) +``` + +Functionality between the synchronous and asynchronous clients is otherwise identical. + +## Using types + +Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like: + +- Serializing back into JSON, `model.to_json()` +- Converting to a dictionary, `model.to_dict()` + +Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. + +## Handling errors + +When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `brand.dev.APIConnectionError` is raised. + +When the API returns a non-success status code (that is, 4xx or 5xx +response), a subclass of `brand.dev.APIStatusError` is raised, containing `status_code` and `response` properties. + +All errors inherit from `brand.dev.APIError`. + +```python +import brand.dev +from brand.dev import BrandDev + +client = BrandDev() + +try: + client.brand.retrieve( + domain="REPLACE_ME", + ) +except brand.dev.APIConnectionError as e: + print("The server could not be reached") + print(e.__cause__) # an underlying Exception, likely raised within httpx. +except brand.dev.RateLimitError as e: + print("A 429 status code was received; we should back off a bit.") +except brand.dev.APIStatusError as e: + print("Another non-200-range status code was received") + print(e.status_code) + print(e.response) +``` + +Error codes are as follows: + +| Status Code | Error Type | +| ----------- | -------------------------- | +| 400 | `BadRequestError` | +| 401 | `AuthenticationError` | +| 403 | `PermissionDeniedError` | +| 404 | `NotFoundError` | +| 422 | `UnprocessableEntityError` | +| 429 | `RateLimitError` | +| >=500 | `InternalServerError` | +| N/A | `APIConnectionError` | + +### Retries + +Certain errors are automatically retried 2 times by default, with a short exponential backoff. +Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, +429 Rate Limit, and >=500 Internal errors are all retried by default. + +You can use the `max_retries` option to configure or disable retry settings: + +```python +from brand.dev import BrandDev + +# Configure the default for all requests: +client = BrandDev( + # default is 2 + max_retries=0, +) + +# Or, configure per-request: +client.with_options(max_retries=5).brand.retrieve( + domain="REPLACE_ME", +) +``` + +### Timeouts + +By default requests time out after 1 minute. You can configure this with a `timeout` option, +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/#fine-tuning-the-configuration) object: + +```python +from brand.dev import BrandDev + +# Configure the default for all requests: +client = BrandDev( + # 20 seconds (default is 1 minute) + timeout=20.0, +) + +# More granular control: +client = BrandDev( + timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), +) + +# Override per-request: +client.with_options(timeout=5.0).brand.retrieve( + domain="REPLACE_ME", +) +``` + +On timeout, an `APITimeoutError` is thrown. + +Note that requests that time out are [retried twice by default](#retries). + +## Advanced + +### Logging + +We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. + +You can enable logging by setting the environment variable `BRAND_DEV_LOG` to `info`. + +```shell +$ export BRAND_DEV_LOG=info +``` + +Or to `debug` for more verbose logging. + +### How to tell whether `None` means `null` or missing + +In an API response, a field may be explicitly `null`, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: + +```py +if response.my_field is None: + if 'my_field' not in response.model_fields_set: + print('Got json like {}, without a "my_field" key present at all.') + else: + print('Got json like {"my_field": null}.') +``` + +### Accessing raw response data (e.g. headers) + +The "raw" Response object can be accessed by prefixing `.with_raw_response.` to any HTTP method call, e.g., + +```py +from brand.dev import BrandDev + +client = BrandDev() +response = client.brand.with_raw_response.retrieve( + domain="REPLACE_ME", +) +print(response.headers.get('X-My-Header')) + +brand = response.parse() # get the object that `brand.retrieve()` would have returned +print(brand.brand) +``` + +These methods return an [`APIResponse`](https://github.com/stainless-sdks/brand.dev-python/tree/main/src/brand/dev/_response.py) object. + +The async client returns an [`AsyncAPIResponse`](https://github.com/stainless-sdks/brand.dev-python/tree/main/src/brand/dev/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. + +#### `.with_streaming_response` + +The above interface eagerly reads the full response body when you make the request, which may not always be what you want. + +To stream the response body, use `.with_streaming_response` instead, which requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. In the async client, these are async methods. + +```python +with client.brand.with_streaming_response.retrieve( + domain="REPLACE_ME", +) as response: + print(response.headers.get("X-My-Header")) + + for line in response.iter_lines(): + print(line) +``` + +The context manager is required so that the response will reliably be closed. + +### Making custom/undocumented requests + +This library is typed for convenient access to the documented API. + +If you need to access undocumented endpoints, params, or response properties, the library can still be used. + +#### Undocumented endpoints + +To make requests to undocumented endpoints, you can make requests using `client.get`, `client.post`, and other +http verbs. Options on the client will be respected (such as retries) when making this request. + +```py +import httpx + +response = client.post( + "/foo", + cast_to=httpx.Response, + body={"my_param": True}, +) + +print(response.headers.get("x-foo")) +``` + +#### Undocumented request params + +If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` request +options. + +#### Undocumented response properties + +To access undocumented response properties, you can access the extra fields like `response.unknown_prop`. You +can also get all the extra fields on the Pydantic model as a dict with +[`response.model_extra`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_extra). + +### Configuring the HTTP client + +You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: + +- Support for [proxies](https://www.python-httpx.org/advanced/proxies/) +- Custom [transports](https://www.python-httpx.org/advanced/transports/) +- Additional [advanced](https://www.python-httpx.org/advanced/clients/) functionality + +```python +import httpx +from brand.dev import BrandDev, DefaultHttpxClient + +client = BrandDev( + # Or use the `BRAND_DEV_BASE_URL` env var + base_url="http://my.test.server.example.com:8083", + http_client=DefaultHttpxClient( + proxy="http://my.test.proxy.example.com", + transport=httpx.HTTPTransport(local_address="0.0.0.0"), + ), +) +``` + +You can also customize the client on a per-request basis by using `with_options()`: + +```python +client.with_options(http_client=DefaultHttpxClient(...)) +``` + +### Managing HTTP resources + +By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. + +```py +from brand.dev import BrandDev + +with BrandDev() as client: + # make requests here + ... + +# HTTP client is now closed +``` + +## Versioning + +This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: + +1. Changes that only affect static types, without breaking runtime behavior. +2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ +3. Changes that we do not expect to impact the vast majority of users in practice. + +We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. + +We are keen for your feedback; please open an [issue](https://www.github.com/stainless-sdks/brand.dev-python/issues) with questions, bugs, or suggestions. + +### Determining the installed version + +If you've upgraded to the latest version but aren't seeing any new features you were expecting then your python environment is likely still using an older version. + +You can determine the version that is being used at runtime with: + +```py +import brand.dev +print(brand.dev.__version__) +``` + +## Requirements + +Python 3.8 or higher. + +## Contributing + +See [the contributing documentation](./CONTRIBUTING.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1c1a2e0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Reporting Security Issues + +This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. + +To report a security issue, please contact the Stainless team at security@stainless.com. + +## Responsible Disclosure + +We appreciate the efforts of security researchers and individuals who help us maintain the security of +SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible +disclosure practices by allowing us a reasonable amount of time to investigate and address the issue +before making any information public. + +## Reporting Non-SDK Related Security Issues + +If you encounter security issues that are not directly related to SDKs but pertain to the services +or products provided by Brand Dev, please follow the respective company's security reporting guidelines. + +--- + +Thank you for helping us keep the SDKs and systems they interact with secure. diff --git a/api.md b/api.md new file mode 100644 index 0000000..c69b8c3 --- /dev/null +++ b/api.md @@ -0,0 +1,21 @@ +# Brand + +Types: + +```python +from brand.dev.types import ( + BrandRetrieveResponse, + BrandIdentifyFromTransactionResponse, + BrandRetrieveByTickerResponse, + BrandRetrieveNaicsResponse, + BrandSearchResponse, +) +``` + +Methods: + +- client.brand.retrieve(\*\*params) -> BrandRetrieveResponse +- client.brand.identify_from_transaction(\*\*params) -> BrandIdentifyFromTransactionResponse +- client.brand.retrieve_by_ticker(\*\*params) -> BrandRetrieveByTickerResponse +- client.brand.retrieve_naics(\*\*params) -> BrandRetrieveNaicsResponse +- client.brand.search(\*\*params) -> BrandSearchResponse diff --git a/bin/publish-pypi b/bin/publish-pypi new file mode 100644 index 0000000..826054e --- /dev/null +++ b/bin/publish-pypi @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -eux +mkdir -p dist +rye build --clean +rye publish --yes --token=$PYPI_TOKEN diff --git a/examples/.keep b/examples/.keep new file mode 100644 index 0000000..d8c73e9 --- /dev/null +++ b/examples/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store example files demonstrating usage of this SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..37c197f --- /dev/null +++ b/mypy.ini @@ -0,0 +1,50 @@ +[mypy] +pretty = True +show_error_codes = True + +# Exclude _files.py because mypy isn't smart enough to apply +# the correct type narrowing and as this is an internal module +# it's fine to just use Pyright. +# +# We also exclude our `tests` as mypy doesn't always infer +# types correctly and Pyright will still catch any type errors. +exclude = ^(src/brand/dev/_files\.py|_dev/.*\.py|tests/.*)$ + +strict_equality = True +implicit_reexport = True +check_untyped_defs = True +no_implicit_optional = True + +warn_return_any = True +warn_unreachable = True +warn_unused_configs = True + +# Turn these options off as it could cause conflicts +# with the Pyright options. +warn_unused_ignores = False +warn_redundant_casts = False + +disallow_any_generics = True +disallow_untyped_defs = True +disallow_untyped_calls = True +disallow_subclassing_any = True +disallow_incomplete_defs = True +disallow_untyped_decorators = True +cache_fine_grained = True + +# By default, mypy reports an error if you assign a value to the result +# of a function call that doesn't return anything. We do this in our test +# cases: +# ``` +# result = ... +# assert result is None +# ``` +# Changing this codegen to make mypy happy would increase complexity +# and would not be worth it. +disable_error_code = func-returns-value,overload-cannot-match + +# https://github.com/python/mypy/issues/12162 +[mypy.overrides] +module = "black.files.*" +ignore_errors = true +ignore_missing_imports = true diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..53bca7f --- /dev/null +++ b/noxfile.py @@ -0,0 +1,9 @@ +import nox + + +@nox.session(reuse_venv=True, name="test-pydantic-v1") +def test_pydantic_v1(session: nox.Session) -> None: + session.install("-r", "requirements-dev.lock") + session.install("pydantic<2") + + session.run("pytest", "--showlocals", "--ignore=tests/functional", *session.posargs) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..342003b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,207 @@ +[project] +name = "brand.dev" +version = "0.0.1-alpha.0" +description = "The official Python library for the brand.dev API" +dynamic = ["readme"] +license = "Apache-2.0" +authors = [ +{ name = "Brand Dev", email = "" }, +] +dependencies = [ + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.10, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", +] +requires-python = ">= 3.8" +classifiers = [ + "Typing :: Typed", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: OS Independent", + "Operating System :: POSIX", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: Apache Software License" +] + +[project.urls] +Homepage = "https://github.com/stainless-sdks/brand.dev-python" +Repository = "https://github.com/stainless-sdks/brand.dev-python" + + +[tool.rye] +managed = true +# version pins are in requirements-dev.lock +dev-dependencies = [ + "pyright==1.1.399", + "mypy", + "respx", + "pytest", + "pytest-asyncio", + "ruff", + "time-machine", + "nox", + "dirty-equals>=0.6.0", + "importlib-metadata>=6.7.0", + "rich>=13.7.1", + "nest_asyncio==1.6.0", +] + +[tool.rye.scripts] +format = { chain = [ + "format:ruff", + "format:docs", + "fix:ruff", + # run formatting again to fix any inconsistencies when imports are stripped + "format:ruff", +]} +"format:docs" = "python scripts/utils/ruffen-docs.py README.md api.md" +"format:ruff" = "ruff format" + +"lint" = { chain = [ + "check:ruff", + "typecheck", + "check:importable", +]} +"check:ruff" = "ruff check ." +"fix:ruff" = "ruff check --fix ." + +"check:importable" = "python -c 'import brand.dev'" + +typecheck = { chain = [ + "typecheck:pyright", + "typecheck:mypy" +]} +"typecheck:pyright" = "pyright" +"typecheck:verify-types" = "pyright --verifytypes brand.dev --ignoreexternal" +"typecheck:mypy" = "mypy ." + +[build-system] +requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"] +build-backend = "hatchling.build" + +[tool.hatch.build] +include = [ + "src/*" +] + +[tool.hatch.build.targets.wheel] +packages = ["src/brand"] + +[tool.hatch.build.targets.sdist] +# Basically everything except hidden files/directories (such as .github, .devcontainers, .python-version, etc) +include = [ + "/*.toml", + "/*.json", + "/*.lock", + "/*.md", + "/mypy.ini", + "/noxfile.py", + "bin/*", + "examples/*", + "src/*", + "tests/*", +] + +[tool.hatch.metadata.hooks.fancy-pypi-readme] +content-type = "text/markdown" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "README.md" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] +# replace relative links with absolute links +pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' +replacement = '[\1](https://github.com/stainless-sdks/brand.dev-python/tree/main/\g<2>)' + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--tb=short" +xfail_strict = true +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +filterwarnings = [ + "error" +] + +[tool.pyright] +# this enables practically every flag given by pyright. +# there are a couple of flags that are still disabled by +# default in strict mode as they are experimental and niche. +typeCheckingMode = "strict" +pythonVersion = "3.8" + +exclude = [ + "_dev", + ".venv", + ".nox", +] + +reportImplicitOverride = true +reportOverlappingOverload = false + +reportImportCycles = false +reportPrivateUsage = false + +[tool.ruff] +line-length = 120 +output-format = "grouped" +target-version = "py37" + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +select = [ + # isort + "I", + # bugbear rules + "B", + # remove unused imports + "F401", + # bare except statements + "E722", + # unused arguments + "ARG", + # print statements + "T201", + "T203", + # misuse of typing.TYPE_CHECKING + "TC004", + # import rules + "TID251", +] +ignore = [ + # mutable defaults + "B006", +] +unfixable = [ + # disable auto fix for print statements + "T201", + "T203", +] + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"functools.lru_cache".msg = "This function does not retain type information for the wrapped function's arguments; The `lru_cache` function from `_utils` should be used instead" + +[tool.ruff.lint.isort] +length-sort = true +length-sort-straight = true +combine-as-imports = true +extra-standard-library = ["typing_extensions"] +known-first-party = ["brand.dev", "tests"] + +[tool.ruff.lint.per-file-ignores] +"bin/**.py" = ["T201", "T203"] +"scripts/**.py" = ["T201", "T203"] +"tests/**.py" = ["T201", "T203"] +"examples/**.py" = ["T201", "T203"] diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 0000000..f169f3b --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,104 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: true +# with-sources: false +# generate-hashes: false +# universal: false + +-e file:. +annotated-types==0.6.0 + # via pydantic +anyio==4.4.0 + # via brand-dev + # via httpx +argcomplete==3.1.2 + # via nox +certifi==2023.7.22 + # via httpcore + # via httpx +colorlog==6.7.0 + # via nox +dirty-equals==0.6.0 +distlib==0.3.7 + # via virtualenv +distro==1.8.0 + # via brand-dev +exceptiongroup==1.2.2 + # via anyio + # via pytest +filelock==3.12.4 + # via virtualenv +h11==0.14.0 + # via httpcore +httpcore==1.0.2 + # via httpx +httpx==0.28.1 + # via brand-dev + # via respx +idna==3.4 + # via anyio + # via httpx +importlib-metadata==7.0.0 +iniconfig==2.0.0 + # via pytest +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +mypy==1.14.1 +mypy-extensions==1.0.0 + # via mypy +nest-asyncio==1.6.0 +nodeenv==1.8.0 + # via pyright +nox==2023.4.22 +packaging==23.2 + # via nox + # via pytest +platformdirs==3.11.0 + # via virtualenv +pluggy==1.5.0 + # via pytest +pydantic==2.10.3 + # via brand-dev +pydantic-core==2.27.1 + # via pydantic +pygments==2.18.0 + # via rich +pyright==1.1.399 +pytest==8.3.3 + # via pytest-asyncio +pytest-asyncio==0.24.0 +python-dateutil==2.8.2 + # via time-machine +pytz==2023.3.post1 + # via dirty-equals +respx==0.22.0 +rich==13.7.1 +ruff==0.9.4 +setuptools==68.2.2 + # via nodeenv +six==1.16.0 + # via python-dateutil +sniffio==1.3.0 + # via anyio + # via brand-dev +time-machine==2.9.0 +tomli==2.0.2 + # via mypy + # via pytest +typing-extensions==4.12.2 + # via anyio + # via brand-dev + # via mypy + # via pydantic + # via pydantic-core + # via pyright +virtualenv==20.24.5 + # via nox +zipp==3.17.0 + # via importlib-metadata diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..5d6d6a4 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,45 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: true +# with-sources: false +# generate-hashes: false +# universal: false + +-e file:. +annotated-types==0.6.0 + # via pydantic +anyio==4.4.0 + # via brand-dev + # via httpx +certifi==2023.7.22 + # via httpcore + # via httpx +distro==1.8.0 + # via brand-dev +exceptiongroup==1.2.2 + # via anyio +h11==0.14.0 + # via httpcore +httpcore==1.0.2 + # via httpx +httpx==0.28.1 + # via brand-dev +idna==3.4 + # via anyio + # via httpx +pydantic==2.10.3 + # via brand-dev +pydantic-core==2.27.1 + # via pydantic +sniffio==1.3.0 + # via anyio + # via brand-dev +typing-extensions==4.12.2 + # via anyio + # via brand-dev + # via pydantic + # via pydantic-core diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 0000000..e84fe62 --- /dev/null +++ b/scripts/bootstrap @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if ! command -v rye >/dev/null 2>&1 && [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ]; then + brew bundle check >/dev/null 2>&1 || { + echo "==> Installing Homebrew dependencies…" + brew bundle + } +fi + +echo "==> Installing Python dependencies…" + +# experimental uv support makes installations significantly faster +rye config --set-bool behavior.use-uv=true + +rye sync --all-features diff --git a/scripts/format b/scripts/format new file mode 100755 index 0000000..667ec2d --- /dev/null +++ b/scripts/format @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running formatters" +rye run format diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..6ba0912 --- /dev/null +++ b/scripts/lint @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running lints" +rye run lint + +echo "==> Making sure it imports" +rye run python -c 'import brand.dev' diff --git a/scripts/mock b/scripts/mock new file mode 100755 index 0000000..d2814ae --- /dev/null +++ b/scripts/mock @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [[ -n "$1" && "$1" != '--'* ]]; then + URL="$1" + shift +else + URL="$(grep 'openapi_spec_url' .stats.yml | cut -d' ' -f2)" +fi + +# Check if the URL is empty +if [ -z "$URL" ]; then + echo "Error: No OpenAPI spec path/url provided or found in .stats.yml" + exit 1 +fi + +echo "==> Starting mock server with URL ${URL}" + +# Run prism mock on the given spec +if [ "$1" == "--daemon" ]; then + npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" &> .prism.log & + + # Wait for server to come online + echo -n "Waiting for server" + while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + echo -n "." + sleep 0.1 + done + + if grep -q "✖ fatal" ".prism.log"; then + cat .prism.log + exit 1 + fi + + echo +else + npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" +fi diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..2b87845 --- /dev/null +++ b/scripts/test @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +function prism_is_running() { + curl --silent "http://localhost:4010" >/dev/null 2>&1 +} + +kill_server_on_port() { + pids=$(lsof -t -i tcp:"$1" || echo "") + if [ "$pids" != "" ]; then + kill "$pids" + echo "Stopped $pids." + fi +} + +function is_overriding_api_base_url() { + [ -n "$TEST_API_BASE_URL" ] +} + +if ! is_overriding_api_base_url && ! prism_is_running ; then + # When we exit this script, make sure to kill the background mock server process + trap 'kill_server_on_port 4010' EXIT + + # Start the dev server + ./scripts/mock --daemon +fi + +if is_overriding_api_base_url ; then + echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" + echo +elif ! prism_is_running ; then + echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" + echo -e "running against your OpenAPI spec." + echo + echo -e "To run the server, pass in the path or url of your OpenAPI" + echo -e "spec to the prism command:" + echo + echo -e " \$ ${YELLOW}npm exec --package=@stoplight/prism-cli@~5.3.2 -- prism mock path/to/your.openapi.yml${NC}" + echo + + exit 1 +else + echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" + echo +fi + +export DEFER_PYDANTIC_BUILD=false + +echo "==> Running tests" +rye run pytest "$@" + +echo "==> Running Pydantic v1 tests" +rye run nox -s test-pydantic-v1 -- "$@" diff --git a/scripts/utils/ruffen-docs.py b/scripts/utils/ruffen-docs.py new file mode 100644 index 0000000..0cf2bd2 --- /dev/null +++ b/scripts/utils/ruffen-docs.py @@ -0,0 +1,167 @@ +# fork of https://github.com/asottile/blacken-docs adapted for ruff +from __future__ import annotations + +import re +import sys +import argparse +import textwrap +import contextlib +import subprocess +from typing import Match, Optional, Sequence, Generator, NamedTuple, cast + +MD_RE = re.compile( + r"(?P^(?P *)```\s*python\n)" r"(?P.*?)" r"(?P^(?P=indent)```\s*$)", + re.DOTALL | re.MULTILINE, +) +MD_PYCON_RE = re.compile( + r"(?P^(?P *)```\s*pycon\n)" r"(?P.*?)" r"(?P^(?P=indent)```.*$)", + re.DOTALL | re.MULTILINE, +) +PYCON_PREFIX = ">>> " +PYCON_CONTINUATION_PREFIX = "..." +PYCON_CONTINUATION_RE = re.compile( + rf"^{re.escape(PYCON_CONTINUATION_PREFIX)}( |$)", +) +DEFAULT_LINE_LENGTH = 100 + + +class CodeBlockError(NamedTuple): + offset: int + exc: Exception + + +def format_str( + src: str, +) -> tuple[str, Sequence[CodeBlockError]]: + errors: list[CodeBlockError] = [] + + @contextlib.contextmanager + def _collect_error(match: Match[str]) -> Generator[None, None, None]: + try: + yield + except Exception as e: + errors.append(CodeBlockError(match.start(), e)) + + def _md_match(match: Match[str]) -> str: + code = textwrap.dedent(match["code"]) + with _collect_error(match): + code = format_code_block(code) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + def _pycon_match(match: Match[str]) -> str: + code = "" + fragment = cast(Optional[str], None) + + def finish_fragment() -> None: + nonlocal code + nonlocal fragment + + if fragment is not None: + with _collect_error(match): + fragment = format_code_block(fragment) + fragment_lines = fragment.splitlines() + code += f"{PYCON_PREFIX}{fragment_lines[0]}\n" + for line in fragment_lines[1:]: + # Skip blank lines to handle Black adding a blank above + # functions within blocks. A blank line would end the REPL + # continuation prompt. + # + # >>> if True: + # ... def f(): + # ... pass + # ... + if line: + code += f"{PYCON_CONTINUATION_PREFIX} {line}\n" + if fragment_lines[-1].startswith(" "): + code += f"{PYCON_CONTINUATION_PREFIX}\n" + fragment = None + + indentation = None + for line in match["code"].splitlines(): + orig_line, line = line, line.lstrip() + if indentation is None and line: + indentation = len(orig_line) - len(line) + continuation_match = PYCON_CONTINUATION_RE.match(line) + if continuation_match and fragment is not None: + fragment += line[continuation_match.end() :] + "\n" + else: + finish_fragment() + if line.startswith(PYCON_PREFIX): + fragment = line[len(PYCON_PREFIX) :] + "\n" + else: + code += orig_line[indentation:] + "\n" + finish_fragment() + return code + + def _md_pycon_match(match: Match[str]) -> str: + code = _pycon_match(match) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + src = MD_RE.sub(_md_match, src) + src = MD_PYCON_RE.sub(_md_pycon_match, src) + return src, errors + + +def format_code_block(code: str) -> str: + return subprocess.check_output( + [ + sys.executable, + "-m", + "ruff", + "format", + "--stdin-filename=script.py", + f"--line-length={DEFAULT_LINE_LENGTH}", + ], + encoding="utf-8", + input=code, + ) + + +def format_file( + filename: str, + skip_errors: bool, +) -> int: + with open(filename, encoding="UTF-8") as f: + contents = f.read() + new_contents, errors = format_str(contents) + for error in errors: + lineno = contents[: error.offset].count("\n") + 1 + print(f"{filename}:{lineno}: code block parse error {error.exc}") + if errors and not skip_errors: + return 1 + if contents != new_contents: + print(f"{filename}: Rewriting...") + with open(filename, "w", encoding="UTF-8") as f: + f.write(new_contents) + return 0 + else: + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "-l", + "--line-length", + type=int, + default=DEFAULT_LINE_LENGTH, + ) + parser.add_argument( + "-S", + "--skip-string-normalization", + action="store_true", + ) + parser.add_argument("-E", "--skip-errors", action="store_true") + parser.add_argument("filenames", nargs="*") + args = parser.parse_args(argv) + + retv = 0 + for filename in args.filenames: + retv |= format_file(filename, skip_errors=args.skip_errors) + return retv + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh new file mode 100755 index 0000000..0f72d31 --- /dev/null +++ b/scripts/utils/upload-artifact.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -exuo pipefail + +RESPONSE=$(curl -X POST "$URL" \ + -H "Authorization: Bearer $AUTH" \ + -H "Content-Type: application/json") + +SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') + +if [[ "$SIGNED_URL" == "null" ]]; then + echo -e "\033[31mFailed to get signed URL.\033[0m" + exit 1 +fi + +UPLOAD_RESPONSE=$(tar -cz . | curl -v -X PUT \ + -H "Content-Type: application/gzip" \ + --data-binary @- "$SIGNED_URL" 2>&1) + +if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then + echo -e "\033[32mUploaded build to Stainless storage.\033[0m" + echo -e "\033[32mInstallation: pip install --pre 'https://pkg.stainless.com/s/brand.dev-python/$SHA'\033[0m" +else + echo -e "\033[31mFailed to upload artifact.\033[0m" + exit 1 +fi diff --git a/src/brand/__init__.py b/src/brand/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/src/brand/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/src/brand/dev/__init__.py b/src/brand/dev/__init__.py new file mode 100644 index 0000000..0de5457 --- /dev/null +++ b/src/brand/dev/__init__.py @@ -0,0 +1,99 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import typing as _t + +from . import types +from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes +from ._utils import file_from_path +from ._client import ( + Client, + Stream, + Timeout, + BrandDev, + Transport, + AsyncClient, + AsyncStream, + AsyncBrandDev, + RequestOptions, +) +from ._models import BaseModel +from ._version import __title__, __version__ +from ._response import APIResponse as APIResponse, AsyncAPIResponse as AsyncAPIResponse +from ._constants import DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES, DEFAULT_CONNECTION_LIMITS +from ._exceptions import ( + APIError, + BrandDevError, + ConflictError, + NotFoundError, + APIStatusError, + RateLimitError, + APITimeoutError, + BadRequestError, + APIConnectionError, + AuthenticationError, + InternalServerError, + PermissionDeniedError, + UnprocessableEntityError, + APIResponseValidationError, +) +from ._base_client import DefaultHttpxClient, DefaultAsyncHttpxClient +from ._utils._logs import setup_logging as _setup_logging + +__all__ = [ + "types", + "__version__", + "__title__", + "NoneType", + "Transport", + "ProxiesTypes", + "NotGiven", + "NOT_GIVEN", + "Omit", + "BrandDevError", + "APIError", + "APIStatusError", + "APITimeoutError", + "APIConnectionError", + "APIResponseValidationError", + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", + "Timeout", + "RequestOptions", + "Client", + "AsyncClient", + "Stream", + "AsyncStream", + "BrandDev", + "AsyncBrandDev", + "file_from_path", + "BaseModel", + "DEFAULT_TIMEOUT", + "DEFAULT_MAX_RETRIES", + "DEFAULT_CONNECTION_LIMITS", + "DefaultHttpxClient", + "DefaultAsyncHttpxClient", +] + +if not _t.TYPE_CHECKING: + from ._utils._resources_proxy import resources as resources + +_setup_logging() + +# Update the __module__ attribute for exported symbols so that +# error messages point to this module instead of the module +# it was originally defined in, e.g. +# brand.dev._exceptions.NotFoundError -> brand.dev.NotFoundError +__locals = locals() +for __name in __all__: + if not __name.startswith("__"): + try: + __locals[__name].__module__ = "brand.dev" + except (TypeError, AttributeError): + # Some of our exported symbols are builtins which we can't set attributes for. + pass diff --git a/src/brand/dev/_base_client.py b/src/brand/dev/_base_client.py new file mode 100644 index 0000000..67fcdf8 --- /dev/null +++ b/src/brand/dev/_base_client.py @@ -0,0 +1,1943 @@ +from __future__ import annotations + +import sys +import json +import time +import uuid +import email +import asyncio +import inspect +import logging +import platform +import email.utils +from types import TracebackType +from random import random +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Type, + Union, + Generic, + Mapping, + TypeVar, + Iterable, + Iterator, + Optional, + Generator, + AsyncIterator, + cast, + overload, +) +from typing_extensions import Literal, override, get_origin + +import anyio +import httpx +import distro +import pydantic +from httpx import URL +from pydantic import PrivateAttr + +from . import _exceptions +from ._qs import Querystring +from ._files import to_httpx_files, async_to_httpx_files +from ._types import ( + NOT_GIVEN, + Body, + Omit, + Query, + Headers, + Timeout, + NotGiven, + ResponseT, + AnyMapping, + PostParser, + RequestFiles, + HttpxSendArgs, + RequestOptions, + HttpxRequestFiles, + ModelBuilderProtocol, +) +from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping +from ._compat import PYDANTIC_V2, model_copy, model_dump +from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type +from ._response import ( + APIResponse, + BaseAPIResponse, + AsyncAPIResponse, + extract_response_type, +) +from ._constants import ( + DEFAULT_TIMEOUT, + MAX_RETRY_DELAY, + DEFAULT_MAX_RETRIES, + INITIAL_RETRY_DELAY, + RAW_RESPONSE_HEADER, + OVERRIDE_CAST_TO_HEADER, + DEFAULT_CONNECTION_LIMITS, +) +from ._streaming import Stream, SSEDecoder, AsyncStream, SSEBytesDecoder +from ._exceptions import ( + APIStatusError, + APITimeoutError, + APIConnectionError, + APIResponseValidationError, +) + +log: logging.Logger = logging.getLogger(__name__) + +# TODO: make base page type vars covariant +SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]") +AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]") + + +_T = TypeVar("_T") +_T_co = TypeVar("_T_co", covariant=True) + +_StreamT = TypeVar("_StreamT", bound=Stream[Any]) +_AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any]) + +if TYPE_CHECKING: + from httpx._config import ( + DEFAULT_TIMEOUT_CONFIG, # pyright: ignore[reportPrivateImportUsage] + ) + + HTTPX_DEFAULT_TIMEOUT = DEFAULT_TIMEOUT_CONFIG +else: + try: + from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT + except ImportError: + # taken from https://github.com/encode/httpx/blob/3ba5fe0d7ac70222590e759c31442b1cab263791/httpx/_config.py#L366 + HTTPX_DEFAULT_TIMEOUT = Timeout(5.0) + + +class PageInfo: + """Stores the necessary information to build the request to retrieve the next page. + + Either `url` or `params` must be set. + """ + + url: URL | NotGiven + params: Query | NotGiven + json: Body | NotGiven + + @overload + def __init__( + self, + *, + url: URL, + ) -> None: ... + + @overload + def __init__( + self, + *, + params: Query, + ) -> None: ... + + @overload + def __init__( + self, + *, + json: Body, + ) -> None: ... + + def __init__( + self, + *, + url: URL | NotGiven = NOT_GIVEN, + json: Body | NotGiven = NOT_GIVEN, + params: Query | NotGiven = NOT_GIVEN, + ) -> None: + self.url = url + self.json = json + self.params = params + + @override + def __repr__(self) -> str: + if self.url: + return f"{self.__class__.__name__}(url={self.url})" + if self.json: + return f"{self.__class__.__name__}(json={self.json})" + return f"{self.__class__.__name__}(params={self.params})" + + +class BasePage(GenericModel, Generic[_T]): + """ + Defines the core interface for pagination. + + Type Args: + ModelT: The pydantic model that represents an item in the response. + + Methods: + has_next_page(): Check if there is another page available + next_page_info(): Get the necessary information to make a request for the next page + """ + + _options: FinalRequestOptions = PrivateAttr() + _model: Type[_T] = PrivateAttr() + + def has_next_page(self) -> bool: + items = self._get_page_items() + if not items: + return False + return self.next_page_info() is not None + + def next_page_info(self) -> Optional[PageInfo]: ... + + def _get_page_items(self) -> Iterable[_T]: # type: ignore[empty-body] + ... + + def _params_from_url(self, url: URL) -> httpx.QueryParams: + # TODO: do we have to preprocess params here? + return httpx.QueryParams(cast(Any, self._options.params)).merge(url.params) + + def _info_to_options(self, info: PageInfo) -> FinalRequestOptions: + options = model_copy(self._options) + options._strip_raw_response_header() + + if not isinstance(info.params, NotGiven): + options.params = {**options.params, **info.params} + return options + + if not isinstance(info.url, NotGiven): + params = self._params_from_url(info.url) + url = info.url.copy_with(params=params) + options.params = dict(url.params) + options.url = str(url) + return options + + if not isinstance(info.json, NotGiven): + if not is_mapping(info.json): + raise TypeError("Pagination is only supported with mappings") + + if not options.json_data: + options.json_data = {**info.json} + else: + if not is_mapping(options.json_data): + raise TypeError("Pagination is only supported with mappings") + + options.json_data = {**options.json_data, **info.json} + return options + + raise ValueError("Unexpected PageInfo state") + + +class BaseSyncPage(BasePage[_T], Generic[_T]): + _client: SyncAPIClient = pydantic.PrivateAttr() + + def _set_private_attributes( + self, + client: SyncAPIClient, + model: Type[_T], + options: FinalRequestOptions, + ) -> None: + if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + + self._model = model + self._client = client + self._options = options + + # Pydantic uses a custom `__iter__` method to support casting BaseModels + # to dictionaries. e.g. dict(model). + # As we want to support `for item in page`, this is inherently incompatible + # with the default pydantic behaviour. It is not possible to support both + # use cases at once. Fortunately, this is not a big deal as all other pydantic + # methods should continue to work as expected as there is an alternative method + # to cast a model to a dictionary, model.dict(), which is used internally + # by pydantic. + def __iter__(self) -> Iterator[_T]: # type: ignore + for page in self.iter_pages(): + for item in page._get_page_items(): + yield item + + def iter_pages(self: SyncPageT) -> Iterator[SyncPageT]: + page = self + while True: + yield page + if page.has_next_page(): + page = page.get_next_page() + else: + return + + def get_next_page(self: SyncPageT) -> SyncPageT: + info = self.next_page_info() + if not info: + raise RuntimeError( + "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." + ) + + options = self._info_to_options(info) + return self._client._request_api_list(self._model, page=self.__class__, options=options) + + +class AsyncPaginator(Generic[_T, AsyncPageT]): + def __init__( + self, + client: AsyncAPIClient, + options: FinalRequestOptions, + page_cls: Type[AsyncPageT], + model: Type[_T], + ) -> None: + self._model = model + self._client = client + self._options = options + self._page_cls = page_cls + + def __await__(self) -> Generator[Any, None, AsyncPageT]: + return self._get_page().__await__() + + async def _get_page(self) -> AsyncPageT: + def _parser(resp: AsyncPageT) -> AsyncPageT: + resp._set_private_attributes( + model=self._model, + options=self._options, + client=self._client, + ) + return resp + + self._options.post_parser = _parser + + return await self._client.request(self._page_cls, self._options) + + async def __aiter__(self) -> AsyncIterator[_T]: + # https://github.com/microsoft/pyright/issues/3464 + page = cast( + AsyncPageT, + await self, # type: ignore + ) + async for item in page: + yield item + + +class BaseAsyncPage(BasePage[_T], Generic[_T]): + _client: AsyncAPIClient = pydantic.PrivateAttr() + + def _set_private_attributes( + self, + model: Type[_T], + client: AsyncAPIClient, + options: FinalRequestOptions, + ) -> None: + if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + + self._model = model + self._client = client + self._options = options + + async def __aiter__(self) -> AsyncIterator[_T]: + async for page in self.iter_pages(): + for item in page._get_page_items(): + yield item + + async def iter_pages(self: AsyncPageT) -> AsyncIterator[AsyncPageT]: + page = self + while True: + yield page + if page.has_next_page(): + page = await page.get_next_page() + else: + return + + async def get_next_page(self: AsyncPageT) -> AsyncPageT: + info = self.next_page_info() + if not info: + raise RuntimeError( + "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." + ) + + options = self._info_to_options(info) + return await self._client._request_api_list(self._model, page=self.__class__, options=options) + + +_HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient]) +_DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]]) + + +class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]): + _client: _HttpxClientT + _version: str + _base_url: URL + max_retries: int + timeout: Union[float, Timeout, None] + _strict_response_validation: bool + _idempotency_header: str | None + _default_stream_cls: type[_DefaultStreamT] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + _strict_response_validation: bool, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None = DEFAULT_TIMEOUT, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + ) -> None: + self._version = version + self._base_url = self._enforce_trailing_slash(URL(base_url)) + self.max_retries = max_retries + self.timeout = timeout + self._custom_headers = custom_headers or {} + self._custom_query = custom_query or {} + self._strict_response_validation = _strict_response_validation + self._idempotency_header = None + self._platform: Platform | None = None + + if max_retries is None: # pyright: ignore[reportUnnecessaryComparison] + raise TypeError( + "max_retries cannot be None. If you want to disable retries, pass `0`; if you want unlimited retries, pass `math.inf` or a very high number; if you want the default behavior, pass `brand.dev.DEFAULT_MAX_RETRIES`" + ) + + def _enforce_trailing_slash(self, url: URL) -> URL: + if url.raw_path.endswith(b"/"): + return url + return url.copy_with(raw_path=url.raw_path + b"/") + + def _make_status_error_from_response( + self, + response: httpx.Response, + ) -> APIStatusError: + if response.is_closed and not response.is_stream_consumed: + # We can't read the response body as it has been closed + # before it was read. This can happen if an event hook + # raises a status error. + body = None + err_msg = f"Error code: {response.status_code}" + else: + err_text = response.text.strip() + body = err_text + + try: + body = json.loads(err_text) + err_msg = f"Error code: {response.status_code} - {body}" + except Exception: + err_msg = err_text or f"Error code: {response.status_code}" + + return self._make_status_error(err_msg, body=body, response=response) + + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> _exceptions.APIStatusError: + raise NotImplementedError() + + def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0) -> httpx.Headers: + custom_headers = options.headers or {} + headers_dict = _merge_mappings(self.default_headers, custom_headers) + self._validate_headers(headers_dict, custom_headers) + + # headers are case-insensitive while dictionaries are not. + headers = httpx.Headers(headers_dict) + + idempotency_header = self._idempotency_header + if idempotency_header and options.idempotency_key and idempotency_header not in headers: + headers[idempotency_header] = options.idempotency_key + + # Don't set these headers if they were already set or removed by the caller. We check + # `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case. + lower_custom_headers = [header.lower() for header in custom_headers] + if "x-stainless-retry-count" not in lower_custom_headers: + headers["x-stainless-retry-count"] = str(retries_taken) + if "x-stainless-read-timeout" not in lower_custom_headers: + timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout + if isinstance(timeout, Timeout): + timeout = timeout.read + if timeout is not None: + headers["x-stainless-read-timeout"] = str(timeout) + + return headers + + def _prepare_url(self, url: str) -> URL: + """ + Merge a URL argument together with any 'base_url' on the client, + to create the URL used for the outgoing request. + """ + # Copied from httpx's `_merge_url` method. + merge_url = URL(url) + if merge_url.is_relative_url: + merge_raw_path = self.base_url.raw_path + merge_url.raw_path.lstrip(b"/") + return self.base_url.copy_with(raw_path=merge_raw_path) + + return merge_url + + def _make_sse_decoder(self) -> SSEDecoder | SSEBytesDecoder: + return SSEDecoder() + + def _build_request( + self, + options: FinalRequestOptions, + *, + retries_taken: int = 0, + ) -> httpx.Request: + if log.isEnabledFor(logging.DEBUG): + log.debug("Request options: %s", model_dump(options, exclude_unset=True)) + + kwargs: dict[str, Any] = {} + + json_data = options.json_data + if options.extra_json is not None: + if json_data is None: + json_data = cast(Body, options.extra_json) + elif is_mapping(json_data): + json_data = _merge_mappings(json_data, options.extra_json) + else: + raise RuntimeError(f"Unexpected JSON data type, {type(json_data)}, cannot merge with `extra_body`") + + headers = self._build_headers(options, retries_taken=retries_taken) + params = _merge_mappings(self.default_query, options.params) + content_type = headers.get("Content-Type") + files = options.files + + # If the given Content-Type header is multipart/form-data then it + # has to be removed so that httpx can generate the header with + # additional information for us as it has to be in this form + # for the server to be able to correctly parse the request: + # multipart/form-data; boundary=---abc-- + if content_type is not None and content_type.startswith("multipart/form-data"): + if "boundary" not in content_type: + # only remove the header if the boundary hasn't been explicitly set + # as the caller doesn't want httpx to come up with their own boundary + headers.pop("Content-Type") + + # As we are now sending multipart/form-data instead of application/json + # we need to tell httpx to use it, https://www.python-httpx.org/advanced/clients/#multipart-file-encoding + if json_data: + if not is_dict(json_data): + raise TypeError( + f"Expected query input to be a dictionary for multipart requests but got {type(json_data)} instead." + ) + kwargs["data"] = self._serialize_multipartform(json_data) + + # httpx determines whether or not to send a "multipart/form-data" + # request based on the truthiness of the "files" argument. + # This gets around that issue by generating a dict value that + # evaluates to true. + # + # https://github.com/encode/httpx/discussions/2399#discussioncomment-3814186 + if not files: + files = cast(HttpxRequestFiles, ForceMultipartDict()) + + prepared_url = self._prepare_url(options.url) + if "_" in prepared_url.host: + # work around https://github.com/encode/httpx/discussions/2880 + kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} + + # TODO: report this error to httpx + return self._client.build_request( # pyright: ignore[reportUnknownMemberType] + headers=headers, + timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout, + method=options.method, + url=prepared_url, + # the `Query` type that we use is incompatible with qs' + # `Params` type as it needs to be typed as `Mapping[str, object]` + # so that passing a `TypedDict` doesn't cause an error. + # https://github.com/microsoft/pyright/issues/3526#event-6715453066 + params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, + json=json_data if is_given(json_data) else None, + files=files, + **kwargs, + ) + + def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, object]: + items = self.qs.stringify_items( + # TODO: type ignore is required as stringify_items is well typed but we can't be + # well typed without heavy validation. + data, # type: ignore + array_format="brackets", + ) + serialized: dict[str, object] = {} + for key, value in items: + existing = serialized.get(key) + + if not existing: + serialized[key] = value + continue + + # If a value has already been set for this key then that + # means we're sending data like `array[]=[1, 2, 3]` and we + # need to tell httpx that we want to send multiple values with + # the same key which is done by using a list or a tuple. + # + # Note: 2d arrays should never result in the same key at both + # levels so it's safe to assume that if the value is a list, + # it was because we changed it to be a list. + if is_list(existing): + existing.append(value) + else: + serialized[key] = [existing, value] + + return serialized + + def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalRequestOptions) -> type[ResponseT]: + if not is_given(options.headers): + return cast_to + + # make a copy of the headers so we don't mutate user-input + headers = dict(options.headers) + + # we internally support defining a temporary header to override the + # default `cast_to` type for use with `.with_raw_response` and `.with_streaming_response` + # see _response.py for implementation details + override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, NOT_GIVEN) + if is_given(override_cast_to): + options.headers = headers + return cast(Type[ResponseT], override_cast_to) + + return cast_to + + def _should_stream_response_body(self, request: httpx.Request) -> bool: + return request.headers.get(RAW_RESPONSE_HEADER) == "stream" # type: ignore[no-any-return] + + def _process_response_data( + self, + *, + data: object, + cast_to: type[ResponseT], + response: httpx.Response, + ) -> ResponseT: + if data is None: + return cast(ResponseT, None) + + if cast_to is object: + return cast(ResponseT, data) + + try: + if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol): + return cast(ResponseT, cast_to.build(response=response, data=data)) + + if self._strict_response_validation: + return cast(ResponseT, validate_type(type_=cast_to, value=data)) + + return cast(ResponseT, construct_type(type_=cast_to, value=data)) + except pydantic.ValidationError as err: + raise APIResponseValidationError(response=response, body=data) from err + + @property + def qs(self) -> Querystring: + return Querystring() + + @property + def custom_auth(self) -> httpx.Auth | None: + return None + + @property + def auth_headers(self) -> dict[str, str]: + return {} + + @property + def default_headers(self) -> dict[str, str | Omit]: + return { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": self.user_agent, + **self.platform_headers(), + **self.auth_headers, + **self._custom_headers, + } + + @property + def default_query(self) -> dict[str, object]: + return { + **self._custom_query, + } + + def _validate_headers( + self, + headers: Headers, # noqa: ARG002 + custom_headers: Headers, # noqa: ARG002 + ) -> None: + """Validate the given default headers and custom headers. + + Does nothing by default. + """ + return + + @property + def user_agent(self) -> str: + return f"{self.__class__.__name__}/Python {self._version}" + + @property + def base_url(self) -> URL: + return self._base_url + + @base_url.setter + def base_url(self, url: URL | str) -> None: + self._base_url = self._enforce_trailing_slash(url if isinstance(url, URL) else URL(url)) + + def platform_headers(self) -> Dict[str, str]: + # the actual implementation is in a separate `lru_cache` decorated + # function because adding `lru_cache` to methods will leak memory + # https://github.com/python/cpython/issues/88476 + return platform_headers(self._version, platform=self._platform) + + def _parse_retry_after_header(self, response_headers: Optional[httpx.Headers] = None) -> float | None: + """Returns a float of the number of seconds (not milliseconds) to wait after retrying, or None if unspecified. + + About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After + See also https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After#syntax + """ + if response_headers is None: + return None + + # First, try the non-standard `retry-after-ms` header for milliseconds, + # which is more precise than integer-seconds `retry-after` + try: + retry_ms_header = response_headers.get("retry-after-ms", None) + return float(retry_ms_header) / 1000 + except (TypeError, ValueError): + pass + + # Next, try parsing `retry-after` header as seconds (allowing nonstandard floats). + retry_header = response_headers.get("retry-after") + try: + # note: the spec indicates that this should only ever be an integer + # but if someone sends a float there's no reason for us to not respect it + return float(retry_header) + except (TypeError, ValueError): + pass + + # Last, try parsing `retry-after` as a date. + retry_date_tuple = email.utils.parsedate_tz(retry_header) + if retry_date_tuple is None: + return None + + retry_date = email.utils.mktime_tz(retry_date_tuple) + return float(retry_date - time.time()) + + def _calculate_retry_timeout( + self, + remaining_retries: int, + options: FinalRequestOptions, + response_headers: Optional[httpx.Headers] = None, + ) -> float: + max_retries = options.get_max_retries(self.max_retries) + + # If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says. + retry_after = self._parse_retry_after_header(response_headers) + if retry_after is not None and 0 < retry_after <= 60: + return retry_after + + # Also cap retry count to 1000 to avoid any potential overflows with `pow` + nb_retries = min(max_retries - remaining_retries, 1000) + + # Apply exponential backoff, but not more than the max. + sleep_seconds = min(INITIAL_RETRY_DELAY * pow(2.0, nb_retries), MAX_RETRY_DELAY) + + # Apply some jitter, plus-or-minus half a second. + jitter = 1 - 0.25 * random() + timeout = sleep_seconds * jitter + return timeout if timeout >= 0 else 0 + + def _should_retry(self, response: httpx.Response) -> bool: + # Note: this is not a standard header + should_retry_header = response.headers.get("x-should-retry") + + # If the server explicitly says whether or not to retry, obey. + if should_retry_header == "true": + log.debug("Retrying as header `x-should-retry` is set to `true`") + return True + if should_retry_header == "false": + log.debug("Not retrying as header `x-should-retry` is set to `false`") + return False + + # Retry on request timeouts. + if response.status_code == 408: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry on lock timeouts. + if response.status_code == 409: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry on rate limits. + if response.status_code == 429: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry internal errors. + if response.status_code >= 500: + log.debug("Retrying due to status code %i", response.status_code) + return True + + log.debug("Not retrying") + return False + + def _idempotency_key(self) -> str: + return f"stainless-python-retry-{uuid.uuid4()}" + + +class _DefaultHttpxClient(httpx.Client): + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +if TYPE_CHECKING: + DefaultHttpxClient = httpx.Client + """An alias to `httpx.Client` that provides the same defaults that this SDK + uses internally. + + This is useful because overriding the `http_client` with your own instance of + `httpx.Client` will result in httpx's defaults being used, not ours. + """ +else: + DefaultHttpxClient = _DefaultHttpxClient + + +class SyncHttpxClientWrapper(DefaultHttpxClient): + def __del__(self) -> None: + if self.is_closed: + return + + try: + self.close() + except Exception: + pass + + +class SyncAPIClient(BaseClient[httpx.Client, Stream[Any]]): + _client: httpx.Client + _default_stream_cls: type[Stream[Any]] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + http_client: httpx.Client | None = None, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + _strict_response_validation: bool, + ) -> None: + if not is_given(timeout): + # if the user passed in a custom http client with a non-default + # timeout set then we use that timeout. + # + # note: there is an edge case here where the user passes in a client + # where they've explicitly set the timeout to match the default timeout + # as this check is structural, meaning that we'll think they didn't + # pass in a timeout and will ignore it + if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = http_client.timeout + else: + timeout = DEFAULT_TIMEOUT + + if http_client is not None and not isinstance(http_client, httpx.Client): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"Invalid `http_client` argument; Expected an instance of `httpx.Client` but got {type(http_client)}" + ) + + super().__init__( + version=version, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + base_url=base_url, + max_retries=max_retries, + custom_query=custom_query, + custom_headers=custom_headers, + _strict_response_validation=_strict_response_validation, + ) + self._client = http_client or SyncHttpxClientWrapper( + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + ) + + def is_closed(self) -> bool: + return self._client.is_closed + + def close(self) -> None: + """Close the underlying HTTPX client. + + The client will *not* be usable after this. + """ + # If an error is thrown while constructing a client, self._client + # may not be present + if hasattr(self, "_client"): + self._client.close() + + def __enter__(self: _T) -> _T: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def _prepare_options( + self, + options: FinalRequestOptions, # noqa: ARG002 + ) -> FinalRequestOptions: + """Hook for mutating the given options""" + return options + + def _prepare_request( + self, + request: httpx.Request, # noqa: ARG002 + ) -> None: + """This method is used as a callback for mutating the `Request` object + after it has been constructed. + This is useful for cases where you want to add certain headers based off of + the request properties, e.g. `url`, `method` etc. + """ + return None + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[True], + stream_cls: Type[_StreamT], + ) -> _StreamT: ... + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: Type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + cast_to = self._maybe_override_cast_to(cast_to, options) + + # create a copy of the options we were given so that if the + # options are mutated later & we then retry, the retries are + # given the original options + input_options = model_copy(options) + if input_options.idempotency_key is None and input_options.method.lower() != "get": + # ensure the idempotency key is reused between requests + input_options.idempotency_key = self._idempotency_key() + + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) + + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = self._prepare_options(options) + + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + self._prepare_request(request) + + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth + + log.debug("Sending HTTP Request: %s %s", request.method, request.url) + + response = None + try: + response = self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, + ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + err.response.close() + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue + + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + err.response.read() + + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None + + break + + assert response is not None, "could not resolve response (should never happen)" + return self._process_response( + cast_to=cast_to, + options=options, + response=response, + stream=stream, + stream_cls=stream_cls, + retries_taken=retries_taken, + ) + + def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken + if remaining_retries == 1: + log.debug("1 retry left") + else: + log.debug("%i retries left", remaining_retries) + + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) + log.info("Retrying request to %s in %f seconds", options.url, timeout) + + time.sleep(timeout) + + def _process_response( + self, + *, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + response: httpx.Response, + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + retries_taken: int = 0, + ) -> ResponseT: + origin = get_origin(cast_to) or cast_to + + if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse): + if not issubclass(origin, APIResponse): + raise TypeError(f"API Response types must subclass {APIResponse}; Received {origin}") + + response_cls = cast("type[BaseAPIResponse[Any]]", cast_to) + return cast( + ResponseT, + response_cls( + raw=response, + client=self, + cast_to=extract_response_type(response_cls), + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ), + ) + + if cast_to == httpx.Response: + return cast(ResponseT, response) + + api_response = APIResponse( + raw=response, + client=self, + cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast] + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ) + if bool(response.request.headers.get(RAW_RESPONSE_HEADER)): + return cast(ResponseT, api_response) + + return api_response.parse() + + def _request_api_list( + self, + model: Type[object], + page: Type[SyncPageT], + options: FinalRequestOptions, + ) -> SyncPageT: + def _parser(resp: SyncPageT) -> SyncPageT: + resp._set_private_attributes( + client=self, + model=model, + options=options, + ) + return resp + + options.post_parser = _parser + + return self.request(page, options, stream=False) + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_StreamT], + ) -> _StreamT: ... + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + opts = FinalRequestOptions.construct(method="get", url=path, **options) + # cast is required because mypy complains about returning Any even though + # it understands the type variables + return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: Literal[True], + stream_cls: type[_StreamT], + ) -> _StreamT: ... + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: bool, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + opts = FinalRequestOptions.construct( + method="post", url=path, json_data=body, files=to_httpx_files(files), **options + ) + return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) + + def patch( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + return self.request(cast_to, opts) + + def put( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct( + method="put", url=path, json_data=body, files=to_httpx_files(files), **options + ) + return self.request(cast_to, opts) + + def delete( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + return self.request(cast_to, opts) + + def get_api_list( + self, + path: str, + *, + model: Type[object], + page: Type[SyncPageT], + body: Body | None = None, + options: RequestOptions = {}, + method: str = "get", + ) -> SyncPageT: + opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options) + return self._request_api_list(model, page, opts) + + +class _DefaultAsyncHttpxClient(httpx.AsyncClient): + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +if TYPE_CHECKING: + DefaultAsyncHttpxClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that provides the same defaults that this SDK + uses internally. + + This is useful because overriding the `http_client` with your own instance of + `httpx.AsyncClient` will result in httpx's defaults being used, not ours. + """ +else: + DefaultAsyncHttpxClient = _DefaultAsyncHttpxClient + + +class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient): + def __del__(self) -> None: + if self.is_closed: + return + + try: + # TODO(someday): support non asyncio runtimes here + asyncio.get_running_loop().create_task(self.aclose()) + except Exception: + pass + + +class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]): + _client: httpx.AsyncClient + _default_stream_cls: type[AsyncStream[Any]] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + _strict_response_validation: bool, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + http_client: httpx.AsyncClient | None = None, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + ) -> None: + if not is_given(timeout): + # if the user passed in a custom http client with a non-default + # timeout set then we use that timeout. + # + # note: there is an edge case here where the user passes in a client + # where they've explicitly set the timeout to match the default timeout + # as this check is structural, meaning that we'll think they didn't + # pass in a timeout and will ignore it + if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = http_client.timeout + else: + timeout = DEFAULT_TIMEOUT + + if http_client is not None and not isinstance(http_client, httpx.AsyncClient): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` but got {type(http_client)}" + ) + + super().__init__( + version=version, + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + max_retries=max_retries, + custom_query=custom_query, + custom_headers=custom_headers, + _strict_response_validation=_strict_response_validation, + ) + self._client = http_client or AsyncHttpxClientWrapper( + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + ) + + def is_closed(self) -> bool: + return self._client.is_closed + + async def close(self) -> None: + """Close the underlying HTTPX client. + + The client will *not* be usable after this. + """ + await self._client.aclose() + + async def __aenter__(self: _T) -> _T: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() + + async def _prepare_options( + self, + options: FinalRequestOptions, # noqa: ARG002 + ) -> FinalRequestOptions: + """Hook for mutating the given options""" + return options + + async def _prepare_request( + self, + request: httpx.Request, # noqa: ARG002 + ) -> None: + """This method is used as a callback for mutating the `Request` object + after it has been constructed. + This is useful for cases where you want to add certain headers based off of + the request properties, e.g. `url`, `method` etc. + """ + return None + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + if self._platform is None: + # `get_platform` can make blocking IO calls so we + # execute it earlier while we are in an async context + self._platform = await asyncify(get_platform)() + + cast_to = self._maybe_override_cast_to(cast_to, options) + + # create a copy of the options we were given so that if the + # options are mutated later & we then retry, the retries are + # given the original options + input_options = model_copy(options) + if input_options.idempotency_key is None and input_options.method.lower() != "get": + # ensure the idempotency key is reused between requests + input_options.idempotency_key = self._idempotency_key() + + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) + + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = await self._prepare_options(options) + + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + await self._prepare_request(request) + + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth + + log.debug("Sending HTTP Request: %s %s", request.method, request.url) + + response = None + try: + response = await self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, + ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + await err.response.aclose() + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue + + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + await err.response.aread() + + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None + + break + + assert response is not None, "could not resolve response (should never happen)" + return await self._process_response( + cast_to=cast_to, + options=options, + response=response, + stream=stream, + stream_cls=stream_cls, + retries_taken=retries_taken, + ) + + async def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken + if remaining_retries == 1: + log.debug("1 retry left") + else: + log.debug("%i retries left", remaining_retries) + + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) + log.info("Retrying request to %s in %f seconds", options.url, timeout) + + await anyio.sleep(timeout) + + async def _process_response( + self, + *, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + response: httpx.Response, + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + retries_taken: int = 0, + ) -> ResponseT: + origin = get_origin(cast_to) or cast_to + + if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse): + if not issubclass(origin, AsyncAPIResponse): + raise TypeError(f"API Response types must subclass {AsyncAPIResponse}; Received {origin}") + + response_cls = cast("type[BaseAPIResponse[Any]]", cast_to) + return cast( + "ResponseT", + response_cls( + raw=response, + client=self, + cast_to=extract_response_type(response_cls), + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ), + ) + + if cast_to == httpx.Response: + return cast(ResponseT, response) + + api_response = AsyncAPIResponse( + raw=response, + client=self, + cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast] + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ) + if bool(response.request.headers.get(RAW_RESPONSE_HEADER)): + return cast(ResponseT, api_response) + + return await api_response.parse() + + def _request_api_list( + self, + model: Type[_T], + page: Type[AsyncPageT], + options: FinalRequestOptions, + ) -> AsyncPaginator[_T, AsyncPageT]: + return AsyncPaginator(client=self, options=options, page_cls=page, model=model) + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + opts = FinalRequestOptions.construct(method="get", url=path, **options) + return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + opts = FinalRequestOptions.construct( + method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options + ) + return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) + + async def patch( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + return await self.request(cast_to, opts) + + async def put( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct( + method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options + ) + return await self.request(cast_to, opts) + + async def delete( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + return await self.request(cast_to, opts) + + def get_api_list( + self, + path: str, + *, + model: Type[_T], + page: Type[AsyncPageT], + body: Body | None = None, + options: RequestOptions = {}, + method: str = "get", + ) -> AsyncPaginator[_T, AsyncPageT]: + opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options) + return self._request_api_list(model, page, opts) + + +def make_request_options( + *, + query: Query | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + idempotency_key: str | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + post_parser: PostParser | NotGiven = NOT_GIVEN, +) -> RequestOptions: + """Create a dict of type RequestOptions without keys of NotGiven values.""" + options: RequestOptions = {} + if extra_headers is not None: + options["headers"] = extra_headers + + if extra_body is not None: + options["extra_json"] = cast(AnyMapping, extra_body) + + if query is not None: + options["params"] = query + + if extra_query is not None: + options["params"] = {**options.get("params", {}), **extra_query} + + if not isinstance(timeout, NotGiven): + options["timeout"] = timeout + + if idempotency_key is not None: + options["idempotency_key"] = idempotency_key + + if is_given(post_parser): + # internal + options["post_parser"] = post_parser # type: ignore + + return options + + +class ForceMultipartDict(Dict[str, None]): + def __bool__(self) -> bool: + return True + + +class OtherPlatform: + def __init__(self, name: str) -> None: + self.name = name + + @override + def __str__(self) -> str: + return f"Other:{self.name}" + + +Platform = Union[ + OtherPlatform, + Literal[ + "MacOS", + "Linux", + "Windows", + "FreeBSD", + "OpenBSD", + "iOS", + "Android", + "Unknown", + ], +] + + +def get_platform() -> Platform: + try: + system = platform.system().lower() + platform_name = platform.platform().lower() + except Exception: + return "Unknown" + + if "iphone" in platform_name or "ipad" in platform_name: + # Tested using Python3IDE on an iPhone 11 and Pythonista on an iPad 7 + # system is Darwin and platform_name is a string like: + # - Darwin-21.6.0-iPhone12,1-64bit + # - Darwin-21.6.0-iPad7,11-64bit + return "iOS" + + if system == "darwin": + return "MacOS" + + if system == "windows": + return "Windows" + + if "android" in platform_name: + # Tested using Pydroid 3 + # system is Linux and platform_name is a string like 'Linux-5.10.81-android12-9-00001-geba40aecb3b7-ab8534902-aarch64-with-libc' + return "Android" + + if system == "linux": + # https://distro.readthedocs.io/en/latest/#distro.id + distro_id = distro.id() + if distro_id == "freebsd": + return "FreeBSD" + + if distro_id == "openbsd": + return "OpenBSD" + + return "Linux" + + if platform_name: + return OtherPlatform(platform_name) + + return "Unknown" + + +@lru_cache(maxsize=None) +def platform_headers(version: str, *, platform: Platform | None) -> Dict[str, str]: + return { + "X-Stainless-Lang": "python", + "X-Stainless-Package-Version": version, + "X-Stainless-OS": str(platform or get_platform()), + "X-Stainless-Arch": str(get_architecture()), + "X-Stainless-Runtime": get_python_runtime(), + "X-Stainless-Runtime-Version": get_python_version(), + } + + +class OtherArch: + def __init__(self, name: str) -> None: + self.name = name + + @override + def __str__(self) -> str: + return f"other:{self.name}" + + +Arch = Union[OtherArch, Literal["x32", "x64", "arm", "arm64", "unknown"]] + + +def get_python_runtime() -> str: + try: + return platform.python_implementation() + except Exception: + return "unknown" + + +def get_python_version() -> str: + try: + return platform.python_version() + except Exception: + return "unknown" + + +def get_architecture() -> Arch: + try: + machine = platform.machine().lower() + except Exception: + return "unknown" + + if machine in ("arm64", "aarch64"): + return "arm64" + + # TODO: untested + if machine == "arm": + return "arm" + + if machine == "x86_64": + return "x64" + + # TODO: untested + if sys.maxsize <= 2**32: + return "x32" + + if machine: + return OtherArch(machine) + + return "unknown" + + +def _merge_mappings( + obj1: Mapping[_T_co, Union[_T, Omit]], + obj2: Mapping[_T_co, Union[_T, Omit]], +) -> Dict[_T_co, _T]: + """Merge two mappings of the same type, removing any values that are instances of `Omit`. + + In cases with duplicate keys the second mapping takes precedence. + """ + merged = {**obj1, **obj2} + return {key: value for key, value in merged.items() if not isinstance(value, Omit)} diff --git a/src/brand/dev/_client.py b/src/brand/dev/_client.py new file mode 100644 index 0000000..ae48f13 --- /dev/null +++ b/src/brand/dev/_client.py @@ -0,0 +1,403 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, Union, Mapping +from typing_extensions import Self, override + +import httpx + +from . import _exceptions +from ._qs import Querystring +from ._types import ( + NOT_GIVEN, + Omit, + Timeout, + NotGiven, + Transport, + ProxiesTypes, + RequestOptions, +) +from ._utils import is_given, get_async_library +from ._version import __version__ +from .resources import brand +from ._streaming import Stream as Stream, AsyncStream as AsyncStream +from ._exceptions import BrandDevError, APIStatusError +from ._base_client import ( + DEFAULT_MAX_RETRIES, + SyncAPIClient, + AsyncAPIClient, +) + +__all__ = [ + "Timeout", + "Transport", + "ProxiesTypes", + "RequestOptions", + "BrandDev", + "AsyncBrandDev", + "Client", + "AsyncClient", +] + + +class BrandDev(SyncAPIClient): + brand: brand.BrandResource + with_raw_response: BrandDevWithRawResponse + with_streaming_response: BrandDevWithStreamedResponse + + # client options + api_key: str + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + # Configure a custom httpx client. + # We provide a `DefaultHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. + # See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details. + http_client: httpx.Client | None = None, + # Enable or disable schema validation for data returned by the API. + # When enabled an error APIResponseValidationError is raised + # if the API responds with invalid data for the expected schema. + # + # This parameter may be removed or changed in the future. + # If you rely on this feature, please open a GitHub issue + # outlining your use-case to help us decide if it should be + # part of our public interface in the future. + _strict_response_validation: bool = False, + ) -> None: + """Construct a new synchronous BrandDev client instance. + + This automatically infers the `api_key` argument from the `BRAND_DEV_API_KEY` environment variable if it is not provided. + """ + if api_key is None: + api_key = os.environ.get("BRAND_DEV_API_KEY") + if api_key is None: + raise BrandDevError( + "The api_key client option must be set either by passing api_key to the client or by setting the BRAND_DEV_API_KEY environment variable" + ) + self.api_key = api_key + + if base_url is None: + base_url = os.environ.get("BRAND_DEV_BASE_URL") + if base_url is None: + base_url = f"https://api.brand.dev/v1" + + super().__init__( + version=__version__, + base_url=base_url, + max_retries=max_retries, + timeout=timeout, + http_client=http_client, + custom_headers=default_headers, + custom_query=default_query, + _strict_response_validation=_strict_response_validation, + ) + + self.brand = brand.BrandResource(self) + self.with_raw_response = BrandDevWithRawResponse(self) + self.with_streaming_response = BrandDevWithStreamedResponse(self) + + @property + @override + def qs(self) -> Querystring: + return Querystring(array_format="comma") + + @property + @override + def auth_headers(self) -> dict[str, str]: + api_key = self.api_key + return {"Authorization": f"Bearer {api_key}"} + + @property + @override + def default_headers(self) -> dict[str, str | Omit]: + return { + **super().default_headers, + "X-Stainless-Async": "false", + **self._custom_headers, + } + + def copy( + self, + *, + api_key: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + http_client: httpx.Client | None = None, + max_retries: int | NotGiven = NOT_GIVEN, + default_headers: Mapping[str, str] | None = None, + set_default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + set_default_query: Mapping[str, object] | None = None, + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: + """ + Create a new client instance re-using the same options given to the current client with optional overriding. + """ + if default_headers is not None and set_default_headers is not None: + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") + + if default_query is not None and set_default_query is not None: + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") + + headers = self._custom_headers + if default_headers is not None: + headers = {**headers, **default_headers} + elif set_default_headers is not None: + headers = set_default_headers + + params = self._custom_query + if default_query is not None: + params = {**params, **default_query} + elif set_default_query is not None: + params = set_default_query + + http_client = http_client or self._client + return self.__class__( + api_key=api_key or self.api_key, + base_url=base_url or self.base_url, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy + + @override + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + + +class AsyncBrandDev(AsyncAPIClient): + brand: brand.AsyncBrandResource + with_raw_response: AsyncBrandDevWithRawResponse + with_streaming_response: AsyncBrandDevWithStreamedResponse + + # client options + api_key: str + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + # Configure a custom httpx client. + # We provide a `DefaultAsyncHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. + # See the [httpx documentation](https://www.python-httpx.org/api/#asyncclient) for more details. + http_client: httpx.AsyncClient | None = None, + # Enable or disable schema validation for data returned by the API. + # When enabled an error APIResponseValidationError is raised + # if the API responds with invalid data for the expected schema. + # + # This parameter may be removed or changed in the future. + # If you rely on this feature, please open a GitHub issue + # outlining your use-case to help us decide if it should be + # part of our public interface in the future. + _strict_response_validation: bool = False, + ) -> None: + """Construct a new async AsyncBrandDev client instance. + + This automatically infers the `api_key` argument from the `BRAND_DEV_API_KEY` environment variable if it is not provided. + """ + if api_key is None: + api_key = os.environ.get("BRAND_DEV_API_KEY") + if api_key is None: + raise BrandDevError( + "The api_key client option must be set either by passing api_key to the client or by setting the BRAND_DEV_API_KEY environment variable" + ) + self.api_key = api_key + + if base_url is None: + base_url = os.environ.get("BRAND_DEV_BASE_URL") + if base_url is None: + base_url = f"https://api.brand.dev/v1" + + super().__init__( + version=__version__, + base_url=base_url, + max_retries=max_retries, + timeout=timeout, + http_client=http_client, + custom_headers=default_headers, + custom_query=default_query, + _strict_response_validation=_strict_response_validation, + ) + + self.brand = brand.AsyncBrandResource(self) + self.with_raw_response = AsyncBrandDevWithRawResponse(self) + self.with_streaming_response = AsyncBrandDevWithStreamedResponse(self) + + @property + @override + def qs(self) -> Querystring: + return Querystring(array_format="comma") + + @property + @override + def auth_headers(self) -> dict[str, str]: + api_key = self.api_key + return {"Authorization": f"Bearer {api_key}"} + + @property + @override + def default_headers(self) -> dict[str, str | Omit]: + return { + **super().default_headers, + "X-Stainless-Async": f"async:{get_async_library()}", + **self._custom_headers, + } + + def copy( + self, + *, + api_key: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + http_client: httpx.AsyncClient | None = None, + max_retries: int | NotGiven = NOT_GIVEN, + default_headers: Mapping[str, str] | None = None, + set_default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + set_default_query: Mapping[str, object] | None = None, + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: + """ + Create a new client instance re-using the same options given to the current client with optional overriding. + """ + if default_headers is not None and set_default_headers is not None: + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") + + if default_query is not None and set_default_query is not None: + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") + + headers = self._custom_headers + if default_headers is not None: + headers = {**headers, **default_headers} + elif set_default_headers is not None: + headers = set_default_headers + + params = self._custom_query + if default_query is not None: + params = {**params, **default_query} + elif set_default_query is not None: + params = set_default_query + + http_client = http_client or self._client + return self.__class__( + api_key=api_key or self.api_key, + base_url=base_url or self.base_url, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy + + @override + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + + +class BrandDevWithRawResponse: + def __init__(self, client: BrandDev) -> None: + self.brand = brand.BrandResourceWithRawResponse(client.brand) + + +class AsyncBrandDevWithRawResponse: + def __init__(self, client: AsyncBrandDev) -> None: + self.brand = brand.AsyncBrandResourceWithRawResponse(client.brand) + + +class BrandDevWithStreamedResponse: + def __init__(self, client: BrandDev) -> None: + self.brand = brand.BrandResourceWithStreamingResponse(client.brand) + + +class AsyncBrandDevWithStreamedResponse: + def __init__(self, client: AsyncBrandDev) -> None: + self.brand = brand.AsyncBrandResourceWithStreamingResponse(client.brand) + + +Client = BrandDev + +AsyncClient = AsyncBrandDev diff --git a/src/brand/dev/_compat.py b/src/brand/dev/_compat.py new file mode 100644 index 0000000..92d9ee6 --- /dev/null +++ b/src/brand/dev/_compat.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload +from datetime import date, datetime +from typing_extensions import Self, Literal + +import pydantic +from pydantic.fields import FieldInfo + +from ._types import IncEx, StrBytesIntFloat + +_T = TypeVar("_T") +_ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel) + +# --------------- Pydantic v2 compatibility --------------- + +# Pyright incorrectly reports some of our functions as overriding a method when they don't +# pyright: reportIncompatibleMethodOverride=false + +PYDANTIC_V2 = pydantic.VERSION.startswith("2.") + +# v1 re-exports +if TYPE_CHECKING: + + def parse_date(value: date | StrBytesIntFloat) -> date: # noqa: ARG001 + ... + + def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: # noqa: ARG001 + ... + + def get_args(t: type[Any]) -> tuple[Any, ...]: # noqa: ARG001 + ... + + def is_union(tp: type[Any] | None) -> bool: # noqa: ARG001 + ... + + def get_origin(t: type[Any]) -> type[Any] | None: # noqa: ARG001 + ... + + def is_literal_type(type_: type[Any]) -> bool: # noqa: ARG001 + ... + + def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001 + ... + +else: + if PYDANTIC_V2: + from pydantic.v1.typing import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, + ) + from pydantic.v1.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + else: + from pydantic.typing import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, + ) + from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + + +# refactored config +if TYPE_CHECKING: + from pydantic import ConfigDict as ConfigDict +else: + if PYDANTIC_V2: + from pydantic import ConfigDict + else: + # TODO: provide an error message here? + ConfigDict = None + + +# renamed methods / properties +def parse_obj(model: type[_ModelT], value: object) -> _ModelT: + if PYDANTIC_V2: + return model.model_validate(value) + else: + return cast(_ModelT, model.parse_obj(value)) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + + +def field_is_required(field: FieldInfo) -> bool: + if PYDANTIC_V2: + return field.is_required() + return field.required # type: ignore + + +def field_get_default(field: FieldInfo) -> Any: + value = field.get_default() + if PYDANTIC_V2: + from pydantic_core import PydanticUndefined + + if value == PydanticUndefined: + return None + return value + return value + + +def field_outer_type(field: FieldInfo) -> Any: + if PYDANTIC_V2: + return field.annotation + return field.outer_type_ # type: ignore + + +def get_model_config(model: type[pydantic.BaseModel]) -> Any: + if PYDANTIC_V2: + return model.model_config + return model.__config__ # type: ignore + + +def get_model_fields(model: type[pydantic.BaseModel]) -> dict[str, FieldInfo]: + if PYDANTIC_V2: + return model.model_fields + return model.__fields__ # type: ignore + + +def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT: + if PYDANTIC_V2: + return model.model_copy(deep=deep) + return model.copy(deep=deep) # type: ignore + + +def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: + if PYDANTIC_V2: + return model.model_dump_json(indent=indent) + return model.json(indent=indent) # type: ignore + + +def model_dump( + model: pydantic.BaseModel, + *, + exclude: IncEx | None = None, + exclude_unset: bool = False, + exclude_defaults: bool = False, + warnings: bool = True, + mode: Literal["json", "python"] = "python", +) -> dict[str, Any]: + if PYDANTIC_V2 or hasattr(model, "model_dump"): + return model.model_dump( + mode=mode, + exclude=exclude, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + # warnings are not supported in Pydantic v1 + warnings=warnings if PYDANTIC_V2 else True, + ) + return cast( + "dict[str, Any]", + model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + exclude=exclude, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + ), + ) + + +def model_parse(model: type[_ModelT], data: Any) -> _ModelT: + if PYDANTIC_V2: + return model.model_validate(data) + return model.parse_obj(data) # pyright: ignore[reportDeprecated] + + +# generic models +if TYPE_CHECKING: + + class GenericModel(pydantic.BaseModel): ... + +else: + if PYDANTIC_V2: + # there no longer needs to be a distinction in v2 but + # we still have to create our own subclass to avoid + # inconsistent MRO ordering errors + class GenericModel(pydantic.BaseModel): ... + + else: + import pydantic.generics + + class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... + + +# cached properties +if TYPE_CHECKING: + cached_property = property + + # we define a separate type (copied from typeshed) + # that represents that `cached_property` is `set`able + # at runtime, which differs from `@property`. + # + # this is a separate type as editors likely special case + # `@property` and we don't want to cause issues just to have + # more helpful internal types. + + class typed_cached_property(Generic[_T]): + func: Callable[[Any], _T] + attrname: str | None + + def __init__(self, func: Callable[[Any], _T]) -> None: ... + + @overload + def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ... + + @overload + def __get__(self, instance: object, owner: type[Any] | None = None) -> _T: ... + + def __get__(self, instance: object, owner: type[Any] | None = None) -> _T | Self: + raise NotImplementedError() + + def __set_name__(self, owner: type[Any], name: str) -> None: ... + + # __set__ is not defined at runtime, but @cached_property is designed to be settable + def __set__(self, instance: object, value: _T) -> None: ... +else: + from functools import cached_property as cached_property + + typed_cached_property = cached_property diff --git a/src/brand/dev/_constants.py b/src/brand/dev/_constants.py new file mode 100644 index 0000000..6ddf2c7 --- /dev/null +++ b/src/brand/dev/_constants.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import httpx + +RAW_RESPONSE_HEADER = "X-Stainless-Raw-Response" +OVERRIDE_CAST_TO_HEADER = "____stainless_override_cast_to" + +# default timeout is 1 minute +DEFAULT_TIMEOUT = httpx.Timeout(timeout=60, connect=5.0) +DEFAULT_MAX_RETRIES = 2 +DEFAULT_CONNECTION_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20) + +INITIAL_RETRY_DELAY = 0.5 +MAX_RETRY_DELAY = 8.0 diff --git a/src/brand/dev/_exceptions.py b/src/brand/dev/_exceptions.py new file mode 100644 index 0000000..00fb366 --- /dev/null +++ b/src/brand/dev/_exceptions.py @@ -0,0 +1,108 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal + +import httpx + +__all__ = [ + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", +] + + +class BrandDevError(Exception): + pass + + +class APIError(BrandDevError): + message: str + request: httpx.Request + + body: object | None + """The API response body. + + If the API responded with a valid JSON structure then this property will be the + decoded result. + + If it isn't a valid JSON structure then this will be the raw response. + + If there was no response associated with this error then it will be `None`. + """ + + def __init__(self, message: str, request: httpx.Request, *, body: object | None) -> None: # noqa: ARG002 + super().__init__(message) + self.request = request + self.message = message + self.body = body + + +class APIResponseValidationError(APIError): + response: httpx.Response + status_code: int + + def __init__(self, response: httpx.Response, body: object | None, *, message: str | None = None) -> None: + super().__init__(message or "Data returned by API invalid for expected schema.", response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIStatusError(APIError): + """Raised when an API response has a status code of 4xx or 5xx.""" + + response: httpx.Response + status_code: int + + def __init__(self, message: str, *, response: httpx.Response, body: object | None) -> None: + super().__init__(message, response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIConnectionError(APIError): + def __init__(self, *, message: str = "Connection error.", request: httpx.Request) -> None: + super().__init__(message, request, body=None) + + +class APITimeoutError(APIConnectionError): + def __init__(self, request: httpx.Request) -> None: + super().__init__(message="Request timed out.", request=request) + + +class BadRequestError(APIStatusError): + status_code: Literal[400] = 400 # pyright: ignore[reportIncompatibleVariableOverride] + + +class AuthenticationError(APIStatusError): + status_code: Literal[401] = 401 # pyright: ignore[reportIncompatibleVariableOverride] + + +class PermissionDeniedError(APIStatusError): + status_code: Literal[403] = 403 # pyright: ignore[reportIncompatibleVariableOverride] + + +class NotFoundError(APIStatusError): + status_code: Literal[404] = 404 # pyright: ignore[reportIncompatibleVariableOverride] + + +class ConflictError(APIStatusError): + status_code: Literal[409] = 409 # pyright: ignore[reportIncompatibleVariableOverride] + + +class UnprocessableEntityError(APIStatusError): + status_code: Literal[422] = 422 # pyright: ignore[reportIncompatibleVariableOverride] + + +class RateLimitError(APIStatusError): + status_code: Literal[429] = 429 # pyright: ignore[reportIncompatibleVariableOverride] + + +class InternalServerError(APIStatusError): + pass diff --git a/src/brand/dev/_files.py b/src/brand/dev/_files.py new file mode 100644 index 0000000..715cc20 --- /dev/null +++ b/src/brand/dev/_files.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import io +import os +import pathlib +from typing import overload +from typing_extensions import TypeGuard + +import anyio + +from ._types import ( + FileTypes, + FileContent, + RequestFiles, + HttpxFileTypes, + Base64FileInput, + HttpxFileContent, + HttpxRequestFiles, +) +from ._utils import is_tuple_t, is_mapping_t, is_sequence_t + + +def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: + return isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) + + +def is_file_content(obj: object) -> TypeGuard[FileContent]: + return ( + isinstance(obj, bytes) or isinstance(obj, tuple) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) + ) + + +def assert_is_file_content(obj: object, *, key: str | None = None) -> None: + if not is_file_content(obj): + prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`" + raise RuntimeError( + f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead." + ) from None + + +@overload +def to_httpx_files(files: None) -> None: ... + + +@overload +def to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... + + +def to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: + if files is None: + return None + + if is_mapping_t(files): + files = {key: _transform_file(file) for key, file in files.items()} + elif is_sequence_t(files): + files = [(key, _transform_file(file)) for key, file in files] + else: + raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence") + + return files + + +def _transform_file(file: FileTypes) -> HttpxFileTypes: + if is_file_content(file): + if isinstance(file, os.PathLike): + path = pathlib.Path(file) + return (path.name, path.read_bytes()) + + return file + + if is_tuple_t(file): + return (file[0], _read_file_content(file[1]), *file[2:]) + + raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") + + +def _read_file_content(file: FileContent) -> HttpxFileContent: + if isinstance(file, os.PathLike): + return pathlib.Path(file).read_bytes() + return file + + +@overload +async def async_to_httpx_files(files: None) -> None: ... + + +@overload +async def async_to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... + + +async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: + if files is None: + return None + + if is_mapping_t(files): + files = {key: await _async_transform_file(file) for key, file in files.items()} + elif is_sequence_t(files): + files = [(key, await _async_transform_file(file)) for key, file in files] + else: + raise TypeError("Unexpected file type input {type(files)}, expected mapping or sequence") + + return files + + +async def _async_transform_file(file: FileTypes) -> HttpxFileTypes: + if is_file_content(file): + if isinstance(file, os.PathLike): + path = anyio.Path(file) + return (path.name, await path.read_bytes()) + + return file + + if is_tuple_t(file): + return (file[0], await _async_read_file_content(file[1]), *file[2:]) + + raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") + + +async def _async_read_file_content(file: FileContent) -> HttpxFileContent: + if isinstance(file, os.PathLike): + return await anyio.Path(file).read_bytes() + + return file diff --git a/src/brand/dev/_models.py b/src/brand/dev/_models.py new file mode 100644 index 0000000..798956f --- /dev/null +++ b/src/brand/dev/_models.py @@ -0,0 +1,803 @@ +from __future__ import annotations + +import os +import inspect +from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, cast +from datetime import date, datetime +from typing_extensions import ( + Unpack, + Literal, + ClassVar, + Protocol, + Required, + ParamSpec, + TypedDict, + TypeGuard, + final, + override, + runtime_checkable, +) + +import pydantic +from pydantic.fields import FieldInfo + +from ._types import ( + Body, + IncEx, + Query, + ModelT, + Headers, + Timeout, + NotGiven, + AnyMapping, + HttpxRequestFiles, +) +from ._utils import ( + PropertyInfo, + is_list, + is_given, + json_safe, + lru_cache, + is_mapping, + parse_date, + coerce_boolean, + parse_datetime, + strip_not_given, + extract_type_arg, + is_annotated_type, + is_type_alias_type, + strip_annotated_type, +) +from ._compat import ( + PYDANTIC_V2, + ConfigDict, + GenericModel as BaseGenericModel, + get_args, + is_union, + parse_obj, + get_origin, + is_literal_type, + get_model_config, + get_model_fields, + field_get_default, +) +from ._constants import RAW_RESPONSE_HEADER + +if TYPE_CHECKING: + from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema + +__all__ = ["BaseModel", "GenericModel"] + +_T = TypeVar("_T") +_BaseModelT = TypeVar("_BaseModelT", bound="BaseModel") + +P = ParamSpec("P") + + +@runtime_checkable +class _ConfigProtocol(Protocol): + allow_population_by_field_name: bool + + +class BaseModel(pydantic.BaseModel): + if PYDANTIC_V2: + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) + ) + else: + + @property + @override + def model_fields_set(self) -> set[str]: + # a forwards-compat shim for pydantic v2 + return self.__fields_set__ # type: ignore + + class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] + extra: Any = pydantic.Extra.allow # type: ignore + + def to_dict( + self, + *, + mode: Literal["json", "python"] = "python", + use_api_names: bool = True, + exclude_unset: bool = True, + exclude_defaults: bool = False, + exclude_none: bool = False, + warnings: bool = True, + ) -> dict[str, object]: + """Recursively generate a dictionary representation of the model, optionally specifying which fields to include or exclude. + + By default, fields that were not set by the API will not be included, + and keys will match the API response, *not* the property names from the model. + + For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property, + the output will use the `"fooBar"` key (unless `use_api_names=False` is passed). + + Args: + mode: + If mode is 'json', the dictionary will only contain JSON serializable types. e.g. `datetime` will be turned into a string, `"2024-3-22T18:11:19.117000Z"`. + If mode is 'python', the dictionary may contain any Python objects. e.g. `datetime(2024, 3, 22)` + + use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value from the output. + exclude_none: Whether to exclude fields that have a value of `None` from the output. + warnings: Whether to log warnings when invalid fields are encountered. This is only supported in Pydantic v2. + """ + return self.model_dump( + mode=mode, + by_alias=use_api_names, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + warnings=warnings, + ) + + def to_json( + self, + *, + indent: int | None = 2, + use_api_names: bool = True, + exclude_unset: bool = True, + exclude_defaults: bool = False, + exclude_none: bool = False, + warnings: bool = True, + ) -> str: + """Generates a JSON string representing this model as it would be received from or sent to the API (but with indentation). + + By default, fields that were not set by the API will not be included, + and keys will match the API response, *not* the property names from the model. + + For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property, + the output will use the `"fooBar"` key (unless `use_api_names=False` is passed). + + Args: + indent: Indentation to use in the JSON output. If `None` is passed, the output will be compact. Defaults to `2` + use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that have the default value. + exclude_none: Whether to exclude fields that have a value of `None`. + warnings: Whether to show any warnings that occurred during serialization. This is only supported in Pydantic v2. + """ + return self.model_dump_json( + indent=indent, + by_alias=use_api_names, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + warnings=warnings, + ) + + @override + def __str__(self) -> str: + # mypy complains about an invalid self arg + return f"{self.__repr_name__()}({self.__repr_str__(', ')})" # type: ignore[misc] + + # Override the 'construct' method in a way that supports recursive parsing without validation. + # Based on https://github.com/samuelcolvin/pydantic/issues/1168#issuecomment-817742836. + @classmethod + @override + def construct( # pyright: ignore[reportIncompatibleMethodOverride] + __cls: Type[ModelT], + _fields_set: set[str] | None = None, + **values: object, + ) -> ModelT: + m = __cls.__new__(__cls) + fields_values: dict[str, object] = {} + + config = get_model_config(__cls) + populate_by_name = ( + config.allow_population_by_field_name + if isinstance(config, _ConfigProtocol) + else config.get("populate_by_name") + ) + + if _fields_set is None: + _fields_set = set() + + model_fields = get_model_fields(__cls) + for name, field in model_fields.items(): + key = field.alias + if key is None or (key not in values and populate_by_name): + key = name + + if key in values: + fields_values[name] = _construct_field(value=values[key], field=field, key=key) + _fields_set.add(name) + else: + fields_values[name] = field_get_default(field) + + _extra = {} + for key, value in values.items(): + if key not in model_fields: + if PYDANTIC_V2: + _extra[key] = value + else: + _fields_set.add(key) + fields_values[key] = value + + object.__setattr__(m, "__dict__", fields_values) + + if PYDANTIC_V2: + # these properties are copied from Pydantic's `model_construct()` method + object.__setattr__(m, "__pydantic_private__", None) + object.__setattr__(m, "__pydantic_extra__", _extra) + object.__setattr__(m, "__pydantic_fields_set__", _fields_set) + else: + # init_private_attributes() does not exist in v2 + m._init_private_attributes() # type: ignore + + # copied from Pydantic v1's `construct()` method + object.__setattr__(m, "__fields_set__", _fields_set) + + return m + + if not TYPE_CHECKING: + # type checkers incorrectly complain about this assignment + # because the type signatures are technically different + # although not in practice + model_construct = construct + + if not PYDANTIC_V2: + # we define aliases for some of the new pydantic v2 methods so + # that we can just document these methods without having to specify + # a specific pydantic version as some users may not know which + # pydantic version they are currently using + + @override + def model_dump( + self, + *, + mode: Literal["json", "python"] | str = "python", + include: IncEx | None = None, + exclude: IncEx | None = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool | Literal["none", "warn", "error"] = True, + context: dict[str, Any] | None = None, + serialize_as_any: bool = False, + ) -> dict[str, Any]: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump + + Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. + + Args: + mode: The mode in which `to_python` should run. + If mode is 'json', the dictionary will only contain JSON serializable types. + If mode is 'python', the dictionary may contain any Python objects. + include: A list of fields to include in the output. + exclude: A list of fields to exclude from the output. + by_alias: Whether to use the field's alias in the dictionary key if defined. + exclude_unset: Whether to exclude fields that are unset or None from the output. + exclude_defaults: Whether to exclude fields that are set to their default value from the output. + exclude_none: Whether to exclude fields that have a value of `None` from the output. + round_trip: Whether to enable serialization and deserialization round-trip support. + warnings: Whether to log warnings when invalid fields are encountered. + + Returns: + A dictionary representation of the model. + """ + if mode not in {"json", "python"}: + raise ValueError("mode must be either 'json' or 'python'") + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + if context is not None: + raise ValueError("context is only supported in Pydantic v2") + if serialize_as_any != False: + raise ValueError("serialize_as_any is only supported in Pydantic v2") + dumped = super().dict( # pyright: ignore[reportDeprecated] + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + return cast(dict[str, Any], json_safe(dumped)) if mode == "json" else dumped + + @override + def model_dump_json( + self, + *, + indent: int | None = None, + include: IncEx | None = None, + exclude: IncEx | None = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool | Literal["none", "warn", "error"] = True, + context: dict[str, Any] | None = None, + serialize_as_any: bool = False, + ) -> str: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json + + Generates a JSON representation of the model using Pydantic's `to_json` method. + + Args: + indent: Indentation to use in the JSON output. If None is passed, the output will be compact. + include: Field(s) to include in the JSON output. Can take either a string or set of strings. + exclude: Field(s) to exclude from the JSON output. Can take either a string or set of strings. + by_alias: Whether to serialize using field aliases. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that have the default value. + exclude_none: Whether to exclude fields that have a value of `None`. + round_trip: Whether to use serialization/deserialization between JSON and class instance. + warnings: Whether to show any warnings that occurred during serialization. + + Returns: + A JSON string representation of the model. + """ + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + if context is not None: + raise ValueError("context is only supported in Pydantic v2") + if serialize_as_any != False: + raise ValueError("serialize_as_any is only supported in Pydantic v2") + return super().json( # type: ignore[reportDeprecated] + indent=indent, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + +def _construct_field(value: object, field: FieldInfo, key: str) -> object: + if value is None: + return field_get_default(field) + + if PYDANTIC_V2: + type_ = field.annotation + else: + type_ = cast(type, field.outer_type_) # type: ignore + + if type_ is None: + raise RuntimeError(f"Unexpected field type is None for {key}") + + return construct_type(value=value, type_=type_) + + +def is_basemodel(type_: type) -> bool: + """Returns whether or not the given type is either a `BaseModel` or a union of `BaseModel`""" + if is_union(type_): + for variant in get_args(type_): + if is_basemodel(variant): + return True + + return False + + return is_basemodel_type(type_) + + +def is_basemodel_type(type_: type) -> TypeGuard[type[BaseModel] | type[GenericModel]]: + origin = get_origin(type_) or type_ + if not inspect.isclass(origin): + return False + return issubclass(origin, BaseModel) or issubclass(origin, GenericModel) + + +def build( + base_model_cls: Callable[P, _BaseModelT], + *args: P.args, + **kwargs: P.kwargs, +) -> _BaseModelT: + """Construct a BaseModel class without validation. + + This is useful for cases where you need to instantiate a `BaseModel` + from an API response as this provides type-safe params which isn't supported + by helpers like `construct_type()`. + + ```py + build(MyModel, my_field_a="foo", my_field_b=123) + ``` + """ + if args: + raise TypeError( + "Received positional arguments which are not supported; Keyword arguments must be used instead", + ) + + return cast(_BaseModelT, construct_type(type_=base_model_cls, value=kwargs)) + + +def construct_type_unchecked(*, value: object, type_: type[_T]) -> _T: + """Loose coercion to the expected type with construction of nested values. + + Note: the returned value from this function is not guaranteed to match the + given type. + """ + return cast(_T, construct_type(value=value, type_=type_)) + + +def construct_type(*, value: object, type_: object) -> object: + """Loose coercion to the expected type with construction of nested values. + + If the given value does not match the expected type then it is returned as-is. + """ + + # store a reference to the original type we were given before we extract any inner + # types so that we can properly resolve forward references in `TypeAliasType` annotations + original_type = None + + # we allow `object` as the input type because otherwise, passing things like + # `Literal['value']` will be reported as a type error by type checkers + type_ = cast("type[object]", type_) + if is_type_alias_type(type_): + original_type = type_ # type: ignore[unreachable] + type_ = type_.__value__ # type: ignore[unreachable] + + # unwrap `Annotated[T, ...]` -> `T` + if is_annotated_type(type_): + meta: tuple[Any, ...] = get_args(type_)[1:] + type_ = extract_type_arg(type_, 0) + else: + meta = tuple() + + # we need to use the origin class for any types that are subscripted generics + # e.g. Dict[str, object] + origin = get_origin(type_) or type_ + args = get_args(type_) + + if is_union(origin): + try: + return validate_type(type_=cast("type[object]", original_type or type_), value=value) + except Exception: + pass + + # if the type is a discriminated union then we want to construct the right variant + # in the union, even if the data doesn't match exactly, otherwise we'd break code + # that relies on the constructed class types, e.g. + # + # class FooType: + # kind: Literal['foo'] + # value: str + # + # class BarType: + # kind: Literal['bar'] + # value: int + # + # without this block, if the data we get is something like `{'kind': 'bar', 'value': 'foo'}` then + # we'd end up constructing `FooType` when it should be `BarType`. + discriminator = _build_discriminated_union_meta(union=type_, meta_annotations=meta) + if discriminator and is_mapping(value): + variant_value = value.get(discriminator.field_alias_from or discriminator.field_name) + if variant_value and isinstance(variant_value, str): + variant_type = discriminator.mapping.get(variant_value) + if variant_type: + return construct_type(type_=variant_type, value=value) + + # if the data is not valid, use the first variant that doesn't fail while deserializing + for variant in args: + try: + return construct_type(value=value, type_=variant) + except Exception: + continue + + raise RuntimeError(f"Could not convert data into a valid instance of {type_}") + + if origin == dict: + if not is_mapping(value): + return value + + _, items_type = get_args(type_) # Dict[_, items_type] + return {key: construct_type(value=item, type_=items_type) for key, item in value.items()} + + if ( + not is_literal_type(type_) + and inspect.isclass(origin) + and (issubclass(origin, BaseModel) or issubclass(origin, GenericModel)) + ): + if is_list(value): + return [cast(Any, type_).construct(**entry) if is_mapping(entry) else entry for entry in value] + + if is_mapping(value): + if issubclass(type_, BaseModel): + return type_.construct(**value) # type: ignore[arg-type] + + return cast(Any, type_).construct(**value) + + if origin == list: + if not is_list(value): + return value + + inner_type = args[0] # List[inner_type] + return [construct_type(value=entry, type_=inner_type) for entry in value] + + if origin == float: + if isinstance(value, int): + coerced = float(value) + if coerced != value: + return value + return coerced + + return value + + if type_ == datetime: + try: + return parse_datetime(value) # type: ignore + except Exception: + return value + + if type_ == date: + try: + return parse_date(value) # type: ignore + except Exception: + return value + + return value + + +@runtime_checkable +class CachedDiscriminatorType(Protocol): + __discriminator__: DiscriminatorDetails + + +class DiscriminatorDetails: + field_name: str + """The name of the discriminator field in the variant class, e.g. + + ```py + class Foo(BaseModel): + type: Literal['foo'] + ``` + + Will result in field_name='type' + """ + + field_alias_from: str | None + """The name of the discriminator field in the API response, e.g. + + ```py + class Foo(BaseModel): + type: Literal['foo'] = Field(alias='type_from_api') + ``` + + Will result in field_alias_from='type_from_api' + """ + + mapping: dict[str, type] + """Mapping of discriminator value to variant type, e.g. + + {'foo': FooVariant, 'bar': BarVariant} + """ + + def __init__( + self, + *, + mapping: dict[str, type], + discriminator_field: str, + discriminator_alias: str | None, + ) -> None: + self.mapping = mapping + self.field_name = discriminator_field + self.field_alias_from = discriminator_alias + + +def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None: + if isinstance(union, CachedDiscriminatorType): + return union.__discriminator__ + + discriminator_field_name: str | None = None + + for annotation in meta_annotations: + if isinstance(annotation, PropertyInfo) and annotation.discriminator is not None: + discriminator_field_name = annotation.discriminator + break + + if not discriminator_field_name: + return None + + mapping: dict[str, type] = {} + discriminator_alias: str | None = None + + for variant in get_args(union): + variant = strip_annotated_type(variant) + if is_basemodel_type(variant): + if PYDANTIC_V2: + field = _extract_field_schema_pv2(variant, discriminator_field_name) + if not field: + continue + + # Note: if one variant defines an alias then they all should + discriminator_alias = field.get("serialization_alias") + + field_schema = field["schema"] + + if field_schema["type"] == "literal": + for entry in cast("LiteralSchema", field_schema)["expected"]: + if isinstance(entry, str): + mapping[entry] = variant + else: + field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + if not field_info: + continue + + # Note: if one variant defines an alias then they all should + discriminator_alias = field_info.alias + + if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): + for entry in get_args(annotation): + if isinstance(entry, str): + mapping[entry] = variant + + if not mapping: + return None + + details = DiscriminatorDetails( + mapping=mapping, + discriminator_field=discriminator_field_name, + discriminator_alias=discriminator_alias, + ) + cast(CachedDiscriminatorType, union).__discriminator__ = details + return details + + +def _extract_field_schema_pv2(model: type[BaseModel], field_name: str) -> ModelField | None: + schema = model.__pydantic_core_schema__ + if schema["type"] == "definitions": + schema = schema["schema"] + + if schema["type"] != "model": + return None + + schema = cast("ModelSchema", schema) + fields_schema = schema["schema"] + if fields_schema["type"] != "model-fields": + return None + + fields_schema = cast("ModelFieldsSchema", fields_schema) + field = fields_schema["fields"].get(field_name) + if not field: + return None + + return cast("ModelField", field) # pyright: ignore[reportUnnecessaryCast] + + +def validate_type(*, type_: type[_T], value: object) -> _T: + """Strict validation that the given value matches the expected type""" + if inspect.isclass(type_) and issubclass(type_, pydantic.BaseModel): + return cast(_T, parse_obj(type_, value)) + + return cast(_T, _validate_non_model_type(type_=type_, value=value)) + + +def set_pydantic_config(typ: Any, config: pydantic.ConfigDict) -> None: + """Add a pydantic config for the given type. + + Note: this is a no-op on Pydantic v1. + """ + setattr(typ, "__pydantic_config__", config) # noqa: B010 + + +# our use of subclassing here causes weirdness for type checkers, +# so we just pretend that we don't subclass +if TYPE_CHECKING: + GenericModel = BaseModel +else: + + class GenericModel(BaseGenericModel, BaseModel): + pass + + +if PYDANTIC_V2: + from pydantic import TypeAdapter as _TypeAdapter + + _CachedTypeAdapter = cast("TypeAdapter[object]", lru_cache(maxsize=None)(_TypeAdapter)) + + if TYPE_CHECKING: + from pydantic import TypeAdapter + else: + TypeAdapter = _CachedTypeAdapter + + def _validate_non_model_type(*, type_: type[_T], value: object) -> _T: + return TypeAdapter(type_).validate_python(value) + +elif not TYPE_CHECKING: # TODO: condition is weird + + class RootModel(GenericModel, Generic[_T]): + """Used as a placeholder to easily convert runtime types to a Pydantic format + to provide validation. + + For example: + ```py + validated = RootModel[int](__root__="5").__root__ + # validated: 5 + ``` + """ + + __root__: _T + + def _validate_non_model_type(*, type_: type[_T], value: object) -> _T: + model = _create_pydantic_model(type_).validate(value) + return cast(_T, model.__root__) + + def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]: + return RootModel[type_] # type: ignore + + +class FinalRequestOptionsInput(TypedDict, total=False): + method: Required[str] + url: Required[str] + params: Query + headers: Headers + max_retries: int + timeout: float | Timeout | None + files: HttpxRequestFiles | None + idempotency_key: str + json_data: Body + extra_json: AnyMapping + + +@final +class FinalRequestOptions(pydantic.BaseModel): + method: str + url: str + params: Query = {} + headers: Union[Headers, NotGiven] = NotGiven() + max_retries: Union[int, NotGiven] = NotGiven() + timeout: Union[float, Timeout, None, NotGiven] = NotGiven() + files: Union[HttpxRequestFiles, None] = None + idempotency_key: Union[str, None] = None + post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() + + # It should be noted that we cannot use `json` here as that would override + # a BaseModel method in an incompatible fashion. + json_data: Union[Body, None] = None + extra_json: Union[AnyMapping, None] = None + + if PYDANTIC_V2: + model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) + else: + + class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] + arbitrary_types_allowed: bool = True + + def get_max_retries(self, max_retries: int) -> int: + if isinstance(self.max_retries, NotGiven): + return max_retries + return self.max_retries + + def _strip_raw_response_header(self) -> None: + if not is_given(self.headers): + return + + if self.headers.get(RAW_RESPONSE_HEADER): + self.headers = {**self.headers} + self.headers.pop(RAW_RESPONSE_HEADER) + + # override the `construct` method so that we can run custom transformations. + # this is necessary as we don't want to do any actual runtime type checking + # (which means we can't use validators) but we do want to ensure that `NotGiven` + # values are not present + # + # type ignore required because we're adding explicit types to `**values` + @classmethod + def construct( # type: ignore + cls, + _fields_set: set[str] | None = None, + **values: Unpack[FinalRequestOptionsInput], + ) -> FinalRequestOptions: + kwargs: dict[str, Any] = { + # we unconditionally call `strip_not_given` on any value + # as it will just ignore any non-mapping types + key: strip_not_given(value) + for key, value in values.items() + } + if PYDANTIC_V2: + return super().model_construct(_fields_set, **kwargs) + return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] + + if not TYPE_CHECKING: + # type checkers incorrectly complain about this assignment + model_construct = construct diff --git a/src/brand/dev/_qs.py b/src/brand/dev/_qs.py new file mode 100644 index 0000000..274320c --- /dev/null +++ b/src/brand/dev/_qs.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from typing import Any, List, Tuple, Union, Mapping, TypeVar +from urllib.parse import parse_qs, urlencode +from typing_extensions import Literal, get_args + +from ._types import NOT_GIVEN, NotGiven, NotGivenOr +from ._utils import flatten + +_T = TypeVar("_T") + + +ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] +NestedFormat = Literal["dots", "brackets"] + +PrimitiveData = Union[str, int, float, bool, None] +# this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"] +# https://github.com/microsoft/pyright/issues/3555 +Data = Union[PrimitiveData, List[Any], Tuple[Any], "Mapping[str, Any]"] +Params = Mapping[str, Data] + + +class Querystring: + array_format: ArrayFormat + nested_format: NestedFormat + + def __init__( + self, + *, + array_format: ArrayFormat = "repeat", + nested_format: NestedFormat = "brackets", + ) -> None: + self.array_format = array_format + self.nested_format = nested_format + + def parse(self, query: str) -> Mapping[str, object]: + # Note: custom format syntax is not supported yet + return parse_qs(query) + + def stringify( + self, + params: Params, + *, + array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, + nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + ) -> str: + return urlencode( + self.stringify_items( + params, + array_format=array_format, + nested_format=nested_format, + ) + ) + + def stringify_items( + self, + params: Params, + *, + array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, + nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + ) -> list[tuple[str, str]]: + opts = Options( + qs=self, + array_format=array_format, + nested_format=nested_format, + ) + return flatten([self._stringify_item(key, value, opts) for key, value in params.items()]) + + def _stringify_item( + self, + key: str, + value: Data, + opts: Options, + ) -> list[tuple[str, str]]: + if isinstance(value, Mapping): + items: list[tuple[str, str]] = [] + nested_format = opts.nested_format + for subkey, subvalue in value.items(): + items.extend( + self._stringify_item( + # TODO: error if unknown format + f"{key}.{subkey}" if nested_format == "dots" else f"{key}[{subkey}]", + subvalue, + opts, + ) + ) + return items + + if isinstance(value, (list, tuple)): + array_format = opts.array_format + if array_format == "comma": + return [ + ( + key, + ",".join(self._primitive_value_to_str(item) for item in value if item is not None), + ), + ] + elif array_format == "repeat": + items = [] + for item in value: + items.extend(self._stringify_item(key, item, opts)) + return items + elif array_format == "indices": + raise NotImplementedError("The array indices format is not supported yet") + elif array_format == "brackets": + items = [] + key = key + "[]" + for item in value: + items.extend(self._stringify_item(key, item, opts)) + return items + else: + raise NotImplementedError( + f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" + ) + + serialised = self._primitive_value_to_str(value) + if not serialised: + return [] + return [(key, serialised)] + + def _primitive_value_to_str(self, value: PrimitiveData) -> str: + # copied from httpx + if value is True: + return "true" + elif value is False: + return "false" + elif value is None: + return "" + return str(value) + + +_qs = Querystring() +parse = _qs.parse +stringify = _qs.stringify +stringify_items = _qs.stringify_items + + +class Options: + array_format: ArrayFormat + nested_format: NestedFormat + + def __init__( + self, + qs: Querystring = _qs, + *, + array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, + nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + ) -> None: + self.array_format = qs.array_format if isinstance(array_format, NotGiven) else array_format + self.nested_format = qs.nested_format if isinstance(nested_format, NotGiven) else nested_format diff --git a/src/brand/dev/_resource.py b/src/brand/dev/_resource.py new file mode 100644 index 0000000..d6387b9 --- /dev/null +++ b/src/brand/dev/_resource.py @@ -0,0 +1,43 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import anyio + +if TYPE_CHECKING: + from ._client import BrandDev, AsyncBrandDev + + +class SyncAPIResource: + _client: BrandDev + + def __init__(self, client: BrandDev) -> None: + self._client = client + self._get = client.get + self._post = client.post + self._patch = client.patch + self._put = client.put + self._delete = client.delete + self._get_api_list = client.get_api_list + + def _sleep(self, seconds: float) -> None: + time.sleep(seconds) + + +class AsyncAPIResource: + _client: AsyncBrandDev + + def __init__(self, client: AsyncBrandDev) -> None: + self._client = client + self._get = client.get + self._post = client.post + self._patch = client.patch + self._put = client.put + self._delete = client.delete + self._get_api_list = client.get_api_list + + async def _sleep(self, seconds: float) -> None: + await anyio.sleep(seconds) diff --git a/src/brand/dev/_response.py b/src/brand/dev/_response.py new file mode 100644 index 0000000..4e25cd6 --- /dev/null +++ b/src/brand/dev/_response.py @@ -0,0 +1,830 @@ +from __future__ import annotations + +import os +import inspect +import logging +import datetime +import functools +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Any, + Union, + Generic, + TypeVar, + Callable, + Iterator, + AsyncIterator, + cast, + overload, +) +from typing_extensions import Awaitable, ParamSpec, override, get_origin + +import anyio +import httpx +import pydantic + +from ._types import NoneType +from ._utils import is_given, extract_type_arg, is_annotated_type, is_type_alias_type, extract_type_var_from_base +from ._models import BaseModel, is_basemodel +from ._constants import RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER +from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type +from ._exceptions import BrandDevError, APIResponseValidationError + +if TYPE_CHECKING: + from ._models import FinalRequestOptions + from ._base_client import BaseClient + + +P = ParamSpec("P") +R = TypeVar("R") +_T = TypeVar("_T") +_APIResponseT = TypeVar("_APIResponseT", bound="APIResponse[Any]") +_AsyncAPIResponseT = TypeVar("_AsyncAPIResponseT", bound="AsyncAPIResponse[Any]") + +log: logging.Logger = logging.getLogger(__name__) + + +class BaseAPIResponse(Generic[R]): + _cast_to: type[R] + _client: BaseClient[Any, Any] + _parsed_by_type: dict[type[Any], Any] + _is_sse_stream: bool + _stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None + _options: FinalRequestOptions + + http_response: httpx.Response + + retries_taken: int + """The number of retries made. If no retries happened this will be `0`""" + + def __init__( + self, + *, + raw: httpx.Response, + cast_to: type[R], + client: BaseClient[Any, Any], + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + options: FinalRequestOptions, + retries_taken: int = 0, + ) -> None: + self._cast_to = cast_to + self._client = client + self._parsed_by_type = {} + self._is_sse_stream = stream + self._stream_cls = stream_cls + self._options = options + self.http_response = raw + self.retries_taken = retries_taken + + @property + def headers(self) -> httpx.Headers: + return self.http_response.headers + + @property + def http_request(self) -> httpx.Request: + """Returns the httpx Request instance associated with the current response.""" + return self.http_response.request + + @property + def status_code(self) -> int: + return self.http_response.status_code + + @property + def url(self) -> httpx.URL: + """Returns the URL for which the request was made.""" + return self.http_response.url + + @property + def method(self) -> str: + return self.http_request.method + + @property + def http_version(self) -> str: + return self.http_response.http_version + + @property + def elapsed(self) -> datetime.timedelta: + """The time taken for the complete request/response cycle to complete.""" + return self.http_response.elapsed + + @property + def is_closed(self) -> bool: + """Whether or not the response body has been closed. + + If this is False then there is response data that has not been read yet. + You must either fully consume the response body or call `.close()` + before discarding the response to prevent resource leaks. + """ + return self.http_response.is_closed + + @override + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__} [{self.status_code} {self.http_response.reason_phrase}] type={self._cast_to}>" + ) + + def _parse(self, *, to: type[_T] | None = None) -> R | _T: + cast_to = to if to is not None else self._cast_to + + # unwrap `TypeAlias('Name', T)` -> `T` + if is_type_alias_type(cast_to): + cast_to = cast_to.__value__ # type: ignore[unreachable] + + # unwrap `Annotated[T, ...]` -> `T` + if cast_to and is_annotated_type(cast_to): + cast_to = extract_type_arg(cast_to, 0) + + origin = get_origin(cast_to) or cast_to + + if self._is_sse_stream: + if to: + if not is_stream_class_type(to): + raise TypeError(f"Expected custom parse type to be a subclass of {Stream} or {AsyncStream}") + + return cast( + _T, + to( + cast_to=extract_stream_chunk_type( + to, + failure_message="Expected custom stream type to be passed with a type argument, e.g. Stream[ChunkType]", + ), + response=self.http_response, + client=cast(Any, self._client), + ), + ) + + if self._stream_cls: + return cast( + R, + self._stream_cls( + cast_to=extract_stream_chunk_type(self._stream_cls), + response=self.http_response, + client=cast(Any, self._client), + ), + ) + + stream_cls = cast("type[Stream[Any]] | type[AsyncStream[Any]] | None", self._client._default_stream_cls) + if stream_cls is None: + raise MissingStreamClassError() + + return cast( + R, + stream_cls( + cast_to=cast_to, + response=self.http_response, + client=cast(Any, self._client), + ), + ) + + if cast_to is NoneType: + return cast(R, None) + + response = self.http_response + if cast_to == str: + return cast(R, response.text) + + if cast_to == bytes: + return cast(R, response.content) + + if cast_to == int: + return cast(R, int(response.text)) + + if cast_to == float: + return cast(R, float(response.text)) + + if cast_to == bool: + return cast(R, response.text.lower() == "true") + + if origin == APIResponse: + raise RuntimeError("Unexpected state - cast_to is `APIResponse`") + + if inspect.isclass(origin) and issubclass(origin, httpx.Response): + # Because of the invariance of our ResponseT TypeVar, users can subclass httpx.Response + # and pass that class to our request functions. We cannot change the variance to be either + # covariant or contravariant as that makes our usage of ResponseT illegal. We could construct + # the response class ourselves but that is something that should be supported directly in httpx + # as it would be easy to incorrectly construct the Response object due to the multitude of arguments. + if cast_to != httpx.Response: + raise ValueError(f"Subclasses of httpx.Response cannot be passed to `cast_to`") + return cast(R, response) + + if ( + inspect.isclass( + origin # pyright: ignore[reportUnknownArgumentType] + ) + and not issubclass(origin, BaseModel) + and issubclass(origin, pydantic.BaseModel) + ): + raise TypeError("Pydantic models must subclass our base model type, e.g. `from brand.dev import BaseModel`") + + if ( + cast_to is not object + and not origin is list + and not origin is dict + and not origin is Union + and not issubclass(origin, BaseModel) + ): + raise RuntimeError( + f"Unsupported type, expected {cast_to} to be a subclass of {BaseModel}, {dict}, {list}, {Union}, {NoneType}, {str} or {httpx.Response}." + ) + + # split is required to handle cases where additional information is included + # in the response, e.g. application/json; charset=utf-8 + content_type, *_ = response.headers.get("content-type", "*").split(";") + if not content_type.endswith("json"): + if is_basemodel(cast_to): + try: + data = response.json() + except Exception as exc: + log.debug("Could not read JSON from response data due to %s - %s", type(exc), exc) + else: + return self._client._process_response_data( + data=data, + cast_to=cast_to, # type: ignore + response=response, + ) + + if self._client._strict_response_validation: + raise APIResponseValidationError( + response=response, + message=f"Expected Content-Type response header to be `application/json` but received `{content_type}` instead.", + body=response.text, + ) + + # If the API responds with content that isn't JSON then we just return + # the (decoded) text without performing any parsing so that you can still + # handle the response however you need to. + return response.text # type: ignore + + data = response.json() + + return self._client._process_response_data( + data=data, + cast_to=cast_to, # type: ignore + response=response, + ) + + +class APIResponse(BaseAPIResponse[R]): + @overload + def parse(self, *, to: type[_T]) -> _T: ... + + @overload + def parse(self) -> R: ... + + def parse(self, *, to: type[_T] | None = None) -> R | _T: + """Returns the rich python representation of this response's data. + + For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`. + + You can customise the type that the response is parsed into through + the `to` argument, e.g. + + ```py + from brand.dev import BaseModel + + + class MyModel(BaseModel): + foo: str + + + obj = response.parse(to=MyModel) + print(obj.foo) + ``` + + We support parsing: + - `BaseModel` + - `dict` + - `list` + - `Union` + - `str` + - `int` + - `float` + - `httpx.Response` + """ + cache_key = to if to is not None else self._cast_to + cached = self._parsed_by_type.get(cache_key) + if cached is not None: + return cached # type: ignore[no-any-return] + + if not self._is_sse_stream: + self.read() + + parsed = self._parse(to=to) + if is_given(self._options.post_parser): + parsed = self._options.post_parser(parsed) + + self._parsed_by_type[cache_key] = parsed + return parsed + + def read(self) -> bytes: + """Read and return the binary response content.""" + try: + return self.http_response.read() + except httpx.StreamConsumed as exc: + # The default error raised by httpx isn't very + # helpful in our case so we re-raise it with + # a different error message. + raise StreamAlreadyConsumed() from exc + + def text(self) -> str: + """Read and decode the response content into a string.""" + self.read() + return self.http_response.text + + def json(self) -> object: + """Read and decode the JSON response content.""" + self.read() + return self.http_response.json() + + def close(self) -> None: + """Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + self.http_response.close() + + def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]: + """ + A byte-iterator over the decoded response content. + + This automatically handles gzip, deflate and brotli encoded responses. + """ + for chunk in self.http_response.iter_bytes(chunk_size): + yield chunk + + def iter_text(self, chunk_size: int | None = None) -> Iterator[str]: + """A str-iterator over the decoded response content + that handles both gzip, deflate, etc but also detects the content's + string encoding. + """ + for chunk in self.http_response.iter_text(chunk_size): + yield chunk + + def iter_lines(self) -> Iterator[str]: + """Like `iter_text()` but will only yield chunks for each line""" + for chunk in self.http_response.iter_lines(): + yield chunk + + +class AsyncAPIResponse(BaseAPIResponse[R]): + @overload + async def parse(self, *, to: type[_T]) -> _T: ... + + @overload + async def parse(self) -> R: ... + + async def parse(self, *, to: type[_T] | None = None) -> R | _T: + """Returns the rich python representation of this response's data. + + For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`. + + You can customise the type that the response is parsed into through + the `to` argument, e.g. + + ```py + from brand.dev import BaseModel + + + class MyModel(BaseModel): + foo: str + + + obj = response.parse(to=MyModel) + print(obj.foo) + ``` + + We support parsing: + - `BaseModel` + - `dict` + - `list` + - `Union` + - `str` + - `httpx.Response` + """ + cache_key = to if to is not None else self._cast_to + cached = self._parsed_by_type.get(cache_key) + if cached is not None: + return cached # type: ignore[no-any-return] + + if not self._is_sse_stream: + await self.read() + + parsed = self._parse(to=to) + if is_given(self._options.post_parser): + parsed = self._options.post_parser(parsed) + + self._parsed_by_type[cache_key] = parsed + return parsed + + async def read(self) -> bytes: + """Read and return the binary response content.""" + try: + return await self.http_response.aread() + except httpx.StreamConsumed as exc: + # the default error raised by httpx isn't very + # helpful in our case so we re-raise it with + # a different error message + raise StreamAlreadyConsumed() from exc + + async def text(self) -> str: + """Read and decode the response content into a string.""" + await self.read() + return self.http_response.text + + async def json(self) -> object: + """Read and decode the JSON response content.""" + await self.read() + return self.http_response.json() + + async def close(self) -> None: + """Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + await self.http_response.aclose() + + async def iter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]: + """ + A byte-iterator over the decoded response content. + + This automatically handles gzip, deflate and brotli encoded responses. + """ + async for chunk in self.http_response.aiter_bytes(chunk_size): + yield chunk + + async def iter_text(self, chunk_size: int | None = None) -> AsyncIterator[str]: + """A str-iterator over the decoded response content + that handles both gzip, deflate, etc but also detects the content's + string encoding. + """ + async for chunk in self.http_response.aiter_text(chunk_size): + yield chunk + + async def iter_lines(self) -> AsyncIterator[str]: + """Like `iter_text()` but will only yield chunks for each line""" + async for chunk in self.http_response.aiter_lines(): + yield chunk + + +class BinaryAPIResponse(APIResponse[bytes]): + """Subclass of APIResponse providing helpers for dealing with binary data. + + Note: If you want to stream the response data instead of eagerly reading it + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + + def write_to_file( + self, + file: str | os.PathLike[str], + ) -> None: + """Write the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + + Note: if you want to stream the data to the file instead of writing + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + with open(file, mode="wb") as f: + for data in self.iter_bytes(): + f.write(data) + + +class AsyncBinaryAPIResponse(AsyncAPIResponse[bytes]): + """Subclass of APIResponse providing helpers for dealing with binary data. + + Note: If you want to stream the response data instead of eagerly reading it + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + + async def write_to_file( + self, + file: str | os.PathLike[str], + ) -> None: + """Write the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + + Note: if you want to stream the data to the file instead of writing + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + path = anyio.Path(file) + async with await path.open(mode="wb") as f: + async for data in self.iter_bytes(): + await f.write(data) + + +class StreamedBinaryAPIResponse(APIResponse[bytes]): + def stream_to_file( + self, + file: str | os.PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: + """Streams the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + """ + with open(file, mode="wb") as f: + for data in self.iter_bytes(chunk_size): + f.write(data) + + +class AsyncStreamedBinaryAPIResponse(AsyncAPIResponse[bytes]): + async def stream_to_file( + self, + file: str | os.PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: + """Streams the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + """ + path = anyio.Path(file) + async with await path.open(mode="wb") as f: + async for data in self.iter_bytes(chunk_size): + await f.write(data) + + +class MissingStreamClassError(TypeError): + def __init__(self) -> None: + super().__init__( + "The `stream` argument was set to `True` but the `stream_cls` argument was not given. See `brand.dev._streaming` for reference", + ) + + +class StreamAlreadyConsumed(BrandDevError): + """ + Attempted to read or stream content, but the content has already + been streamed. + + This can happen if you use a method like `.iter_lines()` and then attempt + to read th entire response body afterwards, e.g. + + ```py + response = await client.post(...) + async for line in response.iter_lines(): + ... # do something with `line` + + content = await response.read() + # ^ error + ``` + + If you want this behaviour you'll need to either manually accumulate the response + content or call `await response.read()` before iterating over the stream. + """ + + def __init__(self) -> None: + message = ( + "Attempted to read or stream some content, but the content has " + "already been streamed. " + "This could be due to attempting to stream the response " + "content more than once." + "\n\n" + "You can fix this by manually accumulating the response content while streaming " + "or by calling `.read()` before starting to stream." + ) + super().__init__(message) + + +class ResponseContextManager(Generic[_APIResponseT]): + """Context manager for ensuring that a request is not made + until it is entered and that the response will always be closed + when the context manager exits + """ + + def __init__(self, request_func: Callable[[], _APIResponseT]) -> None: + self._request_func = request_func + self.__response: _APIResponseT | None = None + + def __enter__(self) -> _APIResponseT: + self.__response = self._request_func() + return self.__response + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self.__response is not None: + self.__response.close() + + +class AsyncResponseContextManager(Generic[_AsyncAPIResponseT]): + """Context manager for ensuring that a request is not made + until it is entered and that the response will always be closed + when the context manager exits + """ + + def __init__(self, api_request: Awaitable[_AsyncAPIResponseT]) -> None: + self._api_request = api_request + self.__response: _AsyncAPIResponseT | None = None + + async def __aenter__(self) -> _AsyncAPIResponseT: + self.__response = await self._api_request + return self.__response + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self.__response is not None: + await self.__response.close() + + +def to_streamed_response_wrapper(func: Callable[P, R]) -> Callable[P, ResponseContextManager[APIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support streaming and returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[APIResponse[R]]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + + kwargs["extra_headers"] = extra_headers + + make_request = functools.partial(func, *args, **kwargs) + + return ResponseContextManager(cast(Callable[[], APIResponse[R]], make_request)) + + return wrapped + + +def async_to_streamed_response_wrapper( + func: Callable[P, Awaitable[R]], +) -> Callable[P, AsyncResponseContextManager[AsyncAPIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support streaming and returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[AsyncAPIResponse[R]]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + + kwargs["extra_headers"] = extra_headers + + make_request = func(*args, **kwargs) + + return AsyncResponseContextManager(cast(Awaitable[AsyncAPIResponse[R]], make_request)) + + return wrapped + + +def to_custom_streamed_response_wrapper( + func: Callable[P, object], + response_cls: type[_APIResponseT], +) -> Callable[P, ResponseContextManager[_APIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support streaming and returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[_APIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + make_request = functools.partial(func, *args, **kwargs) + + return ResponseContextManager(cast(Callable[[], _APIResponseT], make_request)) + + return wrapped + + +def async_to_custom_streamed_response_wrapper( + func: Callable[P, Awaitable[object]], + response_cls: type[_AsyncAPIResponseT], +) -> Callable[P, AsyncResponseContextManager[_AsyncAPIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support streaming and returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[_AsyncAPIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + make_request = func(*args, **kwargs) + + return AsyncResponseContextManager(cast(Awaitable[_AsyncAPIResponseT], make_request)) + + return wrapped + + +def to_raw_response_wrapper(func: Callable[P, R]) -> Callable[P, APIResponse[R]]: + """Higher order function that takes one of our bound API methods and wraps it + to support returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> APIResponse[R]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + + kwargs["extra_headers"] = extra_headers + + return cast(APIResponse[R], func(*args, **kwargs)) + + return wrapped + + +def async_to_raw_response_wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[AsyncAPIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + async def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncAPIResponse[R]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + + kwargs["extra_headers"] = extra_headers + + return cast(AsyncAPIResponse[R], await func(*args, **kwargs)) + + return wrapped + + +def to_custom_raw_response_wrapper( + func: Callable[P, object], + response_cls: type[_APIResponseT], +) -> Callable[P, _APIResponseT]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> _APIResponseT: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + return cast(_APIResponseT, func(*args, **kwargs)) + + return wrapped + + +def async_to_custom_raw_response_wrapper( + func: Callable[P, Awaitable[object]], + response_cls: type[_AsyncAPIResponseT], +) -> Callable[P, Awaitable[_AsyncAPIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> Awaitable[_AsyncAPIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + return cast(Awaitable[_AsyncAPIResponseT], func(*args, **kwargs)) + + return wrapped + + +def extract_response_type(typ: type[BaseAPIResponse[Any]]) -> type: + """Given a type like `APIResponse[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyResponse(APIResponse[bytes]): + ... + + extract_response_type(MyResponse) -> bytes + ``` + """ + return extract_type_var_from_base( + typ, + generic_bases=cast("tuple[type, ...]", (BaseAPIResponse, APIResponse, AsyncAPIResponse)), + index=0, + ) diff --git a/src/brand/dev/_streaming.py b/src/brand/dev/_streaming.py new file mode 100644 index 0000000..0ecd094 --- /dev/null +++ b/src/brand/dev/_streaming.py @@ -0,0 +1,333 @@ +# Note: initially copied from https://github.com/florimondmanca/httpx-sse/blob/master/src/httpx_sse/_decoders.py +from __future__ import annotations + +import json +import inspect +from types import TracebackType +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, AsyncIterator, cast +from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable + +import httpx + +from ._utils import extract_type_var_from_base + +if TYPE_CHECKING: + from ._client import BrandDev, AsyncBrandDev + + +_T = TypeVar("_T") + + +class Stream(Generic[_T]): + """Provides the core interface to iterate over a synchronous stream response.""" + + response: httpx.Response + + _decoder: SSEBytesDecoder + + def __init__( + self, + *, + cast_to: type[_T], + response: httpx.Response, + client: BrandDev, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._decoder = client._make_sse_decoder() + self._iterator = self.__stream__() + + def __next__(self) -> _T: + return self._iterator.__next__() + + def __iter__(self) -> Iterator[_T]: + for item in self._iterator: + yield item + + def _iter_events(self) -> Iterator[ServerSentEvent]: + yield from self._decoder.iter_bytes(self.response.iter_bytes()) + + def __stream__(self) -> Iterator[_T]: + cast_to = cast(Any, self._cast_to) + response = self.response + process_data = self._client._process_response_data + iterator = self._iter_events() + + for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + + # Ensure the entire stream is consumed + for _sse in iterator: + ... + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def close(self) -> None: + """ + Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + self.response.close() + + +class AsyncStream(Generic[_T]): + """Provides the core interface to iterate over an asynchronous stream response.""" + + response: httpx.Response + + _decoder: SSEDecoder | SSEBytesDecoder + + def __init__( + self, + *, + cast_to: type[_T], + response: httpx.Response, + client: AsyncBrandDev, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._decoder = client._make_sse_decoder() + self._iterator = self.__stream__() + + async def __anext__(self) -> _T: + return await self._iterator.__anext__() + + async def __aiter__(self) -> AsyncIterator[_T]: + async for item in self._iterator: + yield item + + async def _iter_events(self) -> AsyncIterator[ServerSentEvent]: + async for sse in self._decoder.aiter_bytes(self.response.aiter_bytes()): + yield sse + + async def __stream__(self) -> AsyncIterator[_T]: + cast_to = cast(Any, self._cast_to) + response = self.response + process_data = self._client._process_response_data + iterator = self._iter_events() + + async for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + + # Ensure the entire stream is consumed + async for _sse in iterator: + ... + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() + + async def close(self) -> None: + """ + Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + await self.response.aclose() + + +class ServerSentEvent: + def __init__( + self, + *, + event: str | None = None, + data: str | None = None, + id: str | None = None, + retry: int | None = None, + ) -> None: + if data is None: + data = "" + + self._id = id + self._data = data + self._event = event or None + self._retry = retry + + @property + def event(self) -> str | None: + return self._event + + @property + def id(self) -> str | None: + return self._id + + @property + def retry(self) -> int | None: + return self._retry + + @property + def data(self) -> str: + return self._data + + def json(self) -> Any: + return json.loads(self.data) + + @override + def __repr__(self) -> str: + return f"ServerSentEvent(event={self.event}, data={self.data}, id={self.id}, retry={self.retry})" + + +class SSEDecoder: + _data: list[str] + _event: str | None + _retry: int | None + _last_event_id: str | None + + def __init__(self) -> None: + self._event = None + self._data = [] + self._last_event_id = None + self._retry = None + + def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + for chunk in self._iter_chunks(iterator): + # Split before decoding so splitlines() only uses \r and \n + for raw_line in chunk.splitlines(): + line = raw_line.decode("utf-8") + sse = self.decode(line) + if sse: + yield sse + + def _iter_chunks(self, iterator: Iterator[bytes]) -> Iterator[bytes]: + """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" + data = b"" + for chunk in iterator: + for line in chunk.splitlines(keepends=True): + data += line + if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): + yield data + data = b"" + if data: + yield data + + async def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + async for chunk in self._aiter_chunks(iterator): + # Split before decoding so splitlines() only uses \r and \n + for raw_line in chunk.splitlines(): + line = raw_line.decode("utf-8") + sse = self.decode(line) + if sse: + yield sse + + async def _aiter_chunks(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[bytes]: + """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" + data = b"" + async for chunk in iterator: + for line in chunk.splitlines(keepends=True): + data += line + if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): + yield data + data = b"" + if data: + yield data + + def decode(self, line: str) -> ServerSentEvent | None: + # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 + + if not line: + if not self._event and not self._data and not self._last_event_id and self._retry is None: + return None + + sse = ServerSentEvent( + event=self._event, + data="\n".join(self._data), + id=self._last_event_id, + retry=self._retry, + ) + + # NOTE: as per the SSE spec, do not reset last_event_id. + self._event = None + self._data = [] + self._retry = None + + return sse + + if line.startswith(":"): + return None + + fieldname, _, value = line.partition(":") + + if value.startswith(" "): + value = value[1:] + + if fieldname == "event": + self._event = value + elif fieldname == "data": + self._data.append(value) + elif fieldname == "id": + if "\0" in value: + pass + else: + self._last_event_id = value + elif fieldname == "retry": + try: + self._retry = int(value) + except (TypeError, ValueError): + pass + else: + pass # Field is ignored. + + return None + + +@runtime_checkable +class SSEBytesDecoder(Protocol): + def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + ... + + def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: + """Given an async iterator that yields raw binary data, iterate over it & yield every event encountered""" + ... + + +def is_stream_class_type(typ: type) -> TypeGuard[type[Stream[object]] | type[AsyncStream[object]]]: + """TypeGuard for determining whether or not the given type is a subclass of `Stream` / `AsyncStream`""" + origin = get_origin(typ) or typ + return inspect.isclass(origin) and issubclass(origin, (Stream, AsyncStream)) + + +def extract_stream_chunk_type( + stream_cls: type, + *, + failure_message: str | None = None, +) -> type: + """Given a type like `Stream[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyStream(Stream[bytes]): + ... + + extract_stream_chunk_type(MyStream) -> bytes + ``` + """ + from ._base_client import Stream, AsyncStream + + return extract_type_var_from_base( + stream_cls, + index=0, + generic_bases=cast("tuple[type, ...]", (Stream, AsyncStream)), + failure_message=failure_message, + ) diff --git a/src/brand/dev/_types.py b/src/brand/dev/_types.py new file mode 100644 index 0000000..7231eff --- /dev/null +++ b/src/brand/dev/_types.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from os import PathLike +from typing import ( + IO, + TYPE_CHECKING, + Any, + Dict, + List, + Type, + Tuple, + Union, + Mapping, + TypeVar, + Callable, + Optional, + Sequence, +) +from typing_extensions import Set, Literal, Protocol, TypeAlias, TypedDict, override, runtime_checkable + +import httpx +import pydantic +from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport + +if TYPE_CHECKING: + from ._models import BaseModel + from ._response import APIResponse, AsyncAPIResponse + +Transport = BaseTransport +AsyncTransport = AsyncBaseTransport +Query = Mapping[str, object] +Body = object +AnyMapping = Mapping[str, object] +ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) +_T = TypeVar("_T") + + +# Approximates httpx internal ProxiesTypes and RequestFiles types +# while adding support for `PathLike` instances +ProxiesDict = Dict["str | URL", Union[None, str, URL, Proxy]] +ProxiesTypes = Union[str, Proxy, ProxiesDict] +if TYPE_CHECKING: + Base64FileInput = Union[IO[bytes], PathLike[str]] + FileContent = Union[IO[bytes], bytes, PathLike[str]] +else: + Base64FileInput = Union[IO[bytes], PathLike] + FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. +FileTypes = Union[ + # file (or bytes) + FileContent, + # (filename, file (or bytes)) + Tuple[Optional[str], FileContent], + # (filename, file (or bytes), content_type) + Tuple[Optional[str], FileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], +] +RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] + +# duplicate of the above but without our custom file support +HttpxFileContent = Union[IO[bytes], bytes] +HttpxFileTypes = Union[ + # file (or bytes) + HttpxFileContent, + # (filename, file (or bytes)) + Tuple[Optional[str], HttpxFileContent], + # (filename, file (or bytes), content_type) + Tuple[Optional[str], HttpxFileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + Tuple[Optional[str], HttpxFileContent, Optional[str], Mapping[str, str]], +] +HttpxRequestFiles = Union[Mapping[str, HttpxFileTypes], Sequence[Tuple[str, HttpxFileTypes]]] + +# Workaround to support (cast_to: Type[ResponseT]) -> ResponseT +# where ResponseT includes `None`. In order to support directly +# passing `None`, overloads would have to be defined for every +# method that uses `ResponseT` which would lead to an unacceptable +# amount of code duplication and make it unreadable. See _base_client.py +# for example usage. +# +# This unfortunately means that you will either have +# to import this type and pass it explicitly: +# +# from brand.dev import NoneType +# client.get('/foo', cast_to=NoneType) +# +# or build it yourself: +# +# client.get('/foo', cast_to=type(None)) +if TYPE_CHECKING: + NoneType: Type[None] +else: + NoneType = type(None) + + +class RequestOptions(TypedDict, total=False): + headers: Headers + max_retries: int + timeout: float | Timeout | None + params: Query + extra_json: AnyMapping + idempotency_key: str + + +# Sentinel class used until PEP 0661 is accepted +class NotGiven: + """ + A sentinel singleton class used to distinguish omitted keyword arguments + from those passed in with the value None (which may have different behavior). + + For example: + + ```py + def get(timeout: Union[int, NotGiven, None] = NotGiven()) -> Response: ... + + + get(timeout=1) # 1s timeout + get(timeout=None) # No timeout + get() # Default timeout behavior, which may not be statically known at the method definition. + ``` + """ + + def __bool__(self) -> Literal[False]: + return False + + @override + def __repr__(self) -> str: + return "NOT_GIVEN" + + +NotGivenOr = Union[_T, NotGiven] +NOT_GIVEN = NotGiven() + + +class Omit: + """In certain situations you need to be able to represent a case where a default value has + to be explicitly removed and `None` is not an appropriate substitute, for example: + + ```py + # as the default `Content-Type` header is `application/json` that will be sent + client.post("/upload/files", files={"file": b"my raw file content"}) + + # you can't explicitly override the header as it has to be dynamically generated + # to look something like: 'multipart/form-data; boundary=0d8382fcf5f8c3be01ca2e11002d2983' + client.post(..., headers={"Content-Type": "multipart/form-data"}) + + # instead you can remove the default `application/json` header by passing Omit + client.post(..., headers={"Content-Type": Omit()}) + ``` + """ + + def __bool__(self) -> Literal[False]: + return False + + +@runtime_checkable +class ModelBuilderProtocol(Protocol): + @classmethod + def build( + cls: type[_T], + *, + response: Response, + data: object, + ) -> _T: ... + + +Headers = Mapping[str, Union[str, Omit]] + + +class HeadersLikeProtocol(Protocol): + def get(self, __key: str) -> str | None: ... + + +HeadersLike = Union[Headers, HeadersLikeProtocol] + +ResponseT = TypeVar( + "ResponseT", + bound=Union[ + object, + str, + None, + "BaseModel", + List[Any], + Dict[str, Any], + Response, + ModelBuilderProtocol, + "APIResponse[Any]", + "AsyncAPIResponse[Any]", + ], +) + +StrBytesIntFloat = Union[str, bytes, int, float] + +# Note: copied from Pydantic +# https://github.com/pydantic/pydantic/blob/6f31f8f68ef011f84357330186f603ff295312fd/pydantic/main.py#L79 +IncEx: TypeAlias = Union[Set[int], Set[str], Mapping[int, Union["IncEx", bool]], Mapping[str, Union["IncEx", bool]]] + +PostParser = Callable[[Any], Any] + + +@runtime_checkable +class InheritsGeneric(Protocol): + """Represents a type that has inherited from `Generic` + + The `__orig_bases__` property can be used to determine the resolved + type variable for a given base class. + """ + + __orig_bases__: tuple[_GenericAlias] + + +class _GenericAlias(Protocol): + __origin__: type[object] + + +class HttpxSendArgs(TypedDict, total=False): + auth: httpx.Auth diff --git a/src/brand/dev/_utils/__init__.py b/src/brand/dev/_utils/__init__.py new file mode 100644 index 0000000..d4fda26 --- /dev/null +++ b/src/brand/dev/_utils/__init__.py @@ -0,0 +1,57 @@ +from ._sync import asyncify as asyncify +from ._proxy import LazyProxy as LazyProxy +from ._utils import ( + flatten as flatten, + is_dict as is_dict, + is_list as is_list, + is_given as is_given, + is_tuple as is_tuple, + json_safe as json_safe, + lru_cache as lru_cache, + is_mapping as is_mapping, + is_tuple_t as is_tuple_t, + parse_date as parse_date, + is_iterable as is_iterable, + is_sequence as is_sequence, + coerce_float as coerce_float, + is_mapping_t as is_mapping_t, + removeprefix as removeprefix, + removesuffix as removesuffix, + extract_files as extract_files, + is_sequence_t as is_sequence_t, + required_args as required_args, + coerce_boolean as coerce_boolean, + coerce_integer as coerce_integer, + file_from_path as file_from_path, + parse_datetime as parse_datetime, + strip_not_given as strip_not_given, + deepcopy_minimal as deepcopy_minimal, + get_async_library as get_async_library, + maybe_coerce_float as maybe_coerce_float, + get_required_header as get_required_header, + maybe_coerce_boolean as maybe_coerce_boolean, + maybe_coerce_integer as maybe_coerce_integer, +) +from ._typing import ( + is_list_type as is_list_type, + is_union_type as is_union_type, + extract_type_arg as extract_type_arg, + is_iterable_type as is_iterable_type, + is_required_type as is_required_type, + is_annotated_type as is_annotated_type, + is_type_alias_type as is_type_alias_type, + strip_annotated_type as strip_annotated_type, + extract_type_var_from_base as extract_type_var_from_base, +) +from ._streams import consume_sync_iterator as consume_sync_iterator, consume_async_iterator as consume_async_iterator +from ._transform import ( + PropertyInfo as PropertyInfo, + transform as transform, + async_transform as async_transform, + maybe_transform as maybe_transform, + async_maybe_transform as async_maybe_transform, +) +from ._reflection import ( + function_has_argument as function_has_argument, + assert_signatures_in_sync as assert_signatures_in_sync, +) diff --git a/src/brand/dev/_utils/_logs.py b/src/brand/dev/_utils/_logs.py new file mode 100644 index 0000000..42bc07f --- /dev/null +++ b/src/brand/dev/_utils/_logs.py @@ -0,0 +1,25 @@ +import os +import logging + +logger: logging.Logger = logging.getLogger("brand.dev") +httpx_logger: logging.Logger = logging.getLogger("httpx") + + +def _basic_config() -> None: + # e.g. [2023-10-05 14:12:26 - brand.dev._base_client:818 - DEBUG] HTTP Request: POST http://127.0.0.1:4010/foo/bar "200 OK" + logging.basicConfig( + format="[%(asctime)s - %(name)s:%(lineno)d - %(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def setup_logging() -> None: + env = os.environ.get("BRAND_DEV_LOG") + if env == "debug": + _basic_config() + logger.setLevel(logging.DEBUG) + httpx_logger.setLevel(logging.DEBUG) + elif env == "info": + _basic_config() + logger.setLevel(logging.INFO) + httpx_logger.setLevel(logging.INFO) diff --git a/src/brand/dev/_utils/_proxy.py b/src/brand/dev/_utils/_proxy.py new file mode 100644 index 0000000..0f239a3 --- /dev/null +++ b/src/brand/dev/_utils/_proxy.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Generic, TypeVar, Iterable, cast +from typing_extensions import override + +T = TypeVar("T") + + +class LazyProxy(Generic[T], ABC): + """Implements data methods to pretend that an instance is another instance. + + This includes forwarding attribute access and other methods. + """ + + # Note: we have to special case proxies that themselves return proxies + # to support using a proxy as a catch-all for any random access, e.g. `proxy.foo.bar.baz` + + def __getattr__(self, attr: str) -> object: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied # pyright: ignore + return getattr(proxied, attr) + + @override + def __repr__(self) -> str: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied.__class__.__name__ + return repr(self.__get_proxied__()) + + @override + def __str__(self) -> str: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied.__class__.__name__ + return str(proxied) + + @override + def __dir__(self) -> Iterable[str]: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return [] + return proxied.__dir__() + + @property # type: ignore + @override + def __class__(self) -> type: # pyright: ignore + try: + proxied = self.__get_proxied__() + except Exception: + return type(self) + if issubclass(type(proxied), LazyProxy): + return type(proxied) + return proxied.__class__ + + def __get_proxied__(self) -> T: + return self.__load__() + + def __as_proxied__(self) -> T: + """Helper method that returns the current proxy, typed as the loaded object""" + return cast(T, self) + + @abstractmethod + def __load__(self) -> T: ... diff --git a/src/brand/dev/_utils/_reflection.py b/src/brand/dev/_utils/_reflection.py new file mode 100644 index 0000000..89aa712 --- /dev/null +++ b/src/brand/dev/_utils/_reflection.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import inspect +from typing import Any, Callable + + +def function_has_argument(func: Callable[..., Any], arg_name: str) -> bool: + """Returns whether or not the given function has a specific parameter""" + sig = inspect.signature(func) + return arg_name in sig.parameters + + +def assert_signatures_in_sync( + source_func: Callable[..., Any], + check_func: Callable[..., Any], + *, + exclude_params: set[str] = set(), +) -> None: + """Ensure that the signature of the second function matches the first.""" + + check_sig = inspect.signature(check_func) + source_sig = inspect.signature(source_func) + + errors: list[str] = [] + + for name, source_param in source_sig.parameters.items(): + if name in exclude_params: + continue + + custom_param = check_sig.parameters.get(name) + if not custom_param: + errors.append(f"the `{name}` param is missing") + continue + + if custom_param.annotation != source_param.annotation: + errors.append( + f"types for the `{name}` param are do not match; source={repr(source_param.annotation)} checking={repr(custom_param.annotation)}" + ) + continue + + if errors: + raise AssertionError(f"{len(errors)} errors encountered when comparing signatures:\n\n" + "\n\n".join(errors)) diff --git a/src/brand/dev/_utils/_resources_proxy.py b/src/brand/dev/_utils/_resources_proxy.py new file mode 100644 index 0000000..2f8942a --- /dev/null +++ b/src/brand/dev/_utils/_resources_proxy.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Any +from typing_extensions import override + +from ._proxy import LazyProxy + + +class ResourcesProxy(LazyProxy[Any]): + """A proxy for the `brand.dev.resources` module. + + This is used so that we can lazily import `brand.dev.resources` only when + needed *and* so that users can just import `brand.dev` and reference `brand.dev.resources` + """ + + @override + def __load__(self) -> Any: + import importlib + + mod = importlib.import_module("brand.dev.resources") + return mod + + +resources = ResourcesProxy().__as_proxied__() diff --git a/src/brand/dev/_utils/_streams.py b/src/brand/dev/_utils/_streams.py new file mode 100644 index 0000000..f4a0208 --- /dev/null +++ b/src/brand/dev/_utils/_streams.py @@ -0,0 +1,12 @@ +from typing import Any +from typing_extensions import Iterator, AsyncIterator + + +def consume_sync_iterator(iterator: Iterator[Any]) -> None: + for _ in iterator: + ... + + +async def consume_async_iterator(iterator: AsyncIterator[Any]) -> None: + async for _ in iterator: + ... diff --git a/src/brand/dev/_utils/_sync.py b/src/brand/dev/_utils/_sync.py new file mode 100644 index 0000000..ad7ec71 --- /dev/null +++ b/src/brand/dev/_utils/_sync.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import sys +import asyncio +import functools +import contextvars +from typing import Any, TypeVar, Callable, Awaitable +from typing_extensions import ParamSpec + +import anyio +import sniffio +import anyio.to_thread + +T_Retval = TypeVar("T_Retval") +T_ParamSpec = ParamSpec("T_ParamSpec") + + +if sys.version_info >= (3, 9): + _asyncio_to_thread = asyncio.to_thread +else: + # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread + # for Python 3.8 support + async def _asyncio_to_thread( + func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs + ) -> Any: + """Asynchronously run function *func* in a separate thread. + + Any *args and **kwargs supplied for this function are directly passed + to *func*. Also, the current :class:`contextvars.Context` is propagated, + allowing context variables from the main thread to be accessed in the + separate thread. + + Returns a coroutine that can be awaited to get the eventual result of *func*. + """ + loop = asyncio.events.get_running_loop() + ctx = contextvars.copy_context() + func_call = functools.partial(ctx.run, func, *args, **kwargs) + return await loop.run_in_executor(None, func_call) + + +async def to_thread( + func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs +) -> T_Retval: + if sniffio.current_async_library() == "asyncio": + return await _asyncio_to_thread(func, *args, **kwargs) + + return await anyio.to_thread.run_sync( + functools.partial(func, *args, **kwargs), + ) + + +# inspired by `asyncer`, https://github.com/tiangolo/asyncer +def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: + """ + Take a blocking function and create an async one that receives the same + positional and keyword arguments. For python version 3.9 and above, it uses + asyncio.to_thread to run the function in a separate thread. For python version + 3.8, it uses locally defined copy of the asyncio.to_thread function which was + introduced in python 3.9. + + Usage: + + ```python + def blocking_func(arg1, arg2, kwarg1=None): + # blocking code + return result + + + result = asyncify(blocking_function)(arg1, arg2, kwarg1=value1) + ``` + + ## Arguments + + `function`: a blocking regular callable (e.g. a function) + + ## Return + + An async function that takes the same positional and keyword arguments as the + original one, that when called runs the same original function in a thread worker + and returns the result. + """ + + async def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval: + return await to_thread(function, *args, **kwargs) + + return wrapper diff --git a/src/brand/dev/_utils/_transform.py b/src/brand/dev/_utils/_transform.py new file mode 100644 index 0000000..b0cc20a --- /dev/null +++ b/src/brand/dev/_utils/_transform.py @@ -0,0 +1,447 @@ +from __future__ import annotations + +import io +import base64 +import pathlib +from typing import Any, Mapping, TypeVar, cast +from datetime import date, datetime +from typing_extensions import Literal, get_args, override, get_type_hints as _get_type_hints + +import anyio +import pydantic + +from ._utils import ( + is_list, + is_given, + lru_cache, + is_mapping, + is_iterable, +) +from .._files import is_base64_file_input +from ._typing import ( + is_list_type, + is_union_type, + extract_type_arg, + is_iterable_type, + is_required_type, + is_annotated_type, + strip_annotated_type, +) +from .._compat import get_origin, model_dump, is_typeddict + +_T = TypeVar("_T") + + +# TODO: support for drilling globals() and locals() +# TODO: ensure works correctly with forward references in all cases + + +PropertyFormat = Literal["iso8601", "base64", "custom"] + + +class PropertyInfo: + """Metadata class to be used in Annotated types to provide information about a given type. + + For example: + + class MyParams(TypedDict): + account_holder_name: Annotated[str, PropertyInfo(alias='accountHolderName')] + + This means that {'account_holder_name': 'Robert'} will be transformed to {'accountHolderName': 'Robert'} before being sent to the API. + """ + + alias: str | None + format: PropertyFormat | None + format_template: str | None + discriminator: str | None + + def __init__( + self, + *, + alias: str | None = None, + format: PropertyFormat | None = None, + format_template: str | None = None, + discriminator: str | None = None, + ) -> None: + self.alias = alias + self.format = format + self.format_template = format_template + self.discriminator = discriminator + + @override + def __repr__(self) -> str: + return f"{self.__class__.__name__}(alias='{self.alias}', format={self.format}, format_template='{self.format_template}', discriminator='{self.discriminator}')" + + +def maybe_transform( + data: object, + expected_type: object, +) -> Any | None: + """Wrapper over `transform()` that allows `None` to be passed. + + See `transform()` for more details. + """ + if data is None: + return None + return transform(data, expected_type) + + +# Wrapper over _transform_recursive providing fake types +def transform( + data: _T, + expected_type: object, +) -> _T: + """Transform dictionaries based off of type information from the given type, for example: + + ```py + class Params(TypedDict, total=False): + card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]] + + + transformed = transform({"card_id": ""}, Params) + # {'cardID': ''} + ``` + + Any keys / data that does not have type information given will be included as is. + + It should be noted that the transformations that this function does are not represented in the type system. + """ + transformed = _transform_recursive(data, annotation=cast(type, expected_type)) + return cast(_T, transformed) + + +@lru_cache(maxsize=8096) +def _get_annotated_type(type_: type) -> type | None: + """If the given type is an `Annotated` type then it is returned, if not `None` is returned. + + This also unwraps the type when applicable, e.g. `Required[Annotated[T, ...]]` + """ + if is_required_type(type_): + # Unwrap `Required[Annotated[T, ...]]` to `Annotated[T, ...]` + type_ = get_args(type_)[0] + + if is_annotated_type(type_): + return type_ + + return None + + +def _maybe_transform_key(key: str, type_: type) -> str: + """Transform the given `data` based on the annotations provided in `type_`. + + Note: this function only looks at `Annotated` types that contain `PropertyInfo` metadata. + """ + annotated_type = _get_annotated_type(type_) + if annotated_type is None: + # no `Annotated` definition for this type, no transformation needed + return key + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.alias is not None: + return annotation.alias + + return key + + +def _no_transform_needed(annotation: type) -> bool: + return annotation == float or annotation == int + + +def _transform_recursive( + data: object, + *, + annotation: type, + inner_type: type | None = None, +) -> object: + """Transform the given data against the expected type. + + Args: + annotation: The direct type annotation given to the particular piece of data. + This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc + + inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type + is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in + the list can be transformed using the metadata from the container type. + + Defaults to the same value as the `annotation` argument. + """ + if inner_type is None: + inner_type = annotation + + stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type + if is_typeddict(stripped_type) and is_mapping(data): + return _transform_typeddict(data, stripped_type) + + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + + if ( + # List[T] + (is_list_type(stripped_type) and is_list(data)) + # Iterable[T] + or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + ): + # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually + # intended as an iterable, so we don't transform it. + if isinstance(data, dict): + return cast(object, data) + + inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + + return [_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] + + if is_union_type(stripped_type): + # For union types we run the transformation against all subtypes to ensure that everything is transformed. + # + # TODO: there may be edge cases where the same normalized field name will transform to two different names + # in different subtypes. + for subtype in get_args(stripped_type): + data = _transform_recursive(data, annotation=annotation, inner_type=subtype) + return data + + if isinstance(data, pydantic.BaseModel): + return model_dump(data, exclude_unset=True, mode="json") + + annotated_type = _get_annotated_type(annotation) + if annotated_type is None: + return data + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.format is not None: + return _format_data(data, annotation.format, annotation.format_template) + + return data + + +def _format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object: + if isinstance(data, (date, datetime)): + if format_ == "iso8601": + return data.isoformat() + + if format_ == "custom" and format_template is not None: + return data.strftime(format_template) + + if format_ == "base64" and is_base64_file_input(data): + binary: str | bytes | None = None + + if isinstance(data, pathlib.Path): + binary = data.read_bytes() + elif isinstance(data, io.IOBase): + binary = data.read() + + if isinstance(binary, str): # type: ignore[unreachable] + binary = binary.encode() + + if not isinstance(binary, bytes): + raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") + + return base64.b64encode(binary).decode("ascii") + + return data + + +def _transform_typeddict( + data: Mapping[str, object], + expected_type: type, +) -> Mapping[str, object]: + result: dict[str, object] = {} + annotations = get_type_hints(expected_type, include_extras=True) + for key, value in data.items(): + if not is_given(value): + # we don't need to include `NotGiven` values here as they'll + # be stripped out before the request is sent anyway + continue + + type_ = annotations.get(key) + if type_ is None: + # we do not have a type annotation for this field, leave it as is + result[key] = value + else: + result[_maybe_transform_key(key, type_)] = _transform_recursive(value, annotation=type_) + return result + + +async def async_maybe_transform( + data: object, + expected_type: object, +) -> Any | None: + """Wrapper over `async_transform()` that allows `None` to be passed. + + See `async_transform()` for more details. + """ + if data is None: + return None + return await async_transform(data, expected_type) + + +async def async_transform( + data: _T, + expected_type: object, +) -> _T: + """Transform dictionaries based off of type information from the given type, for example: + + ```py + class Params(TypedDict, total=False): + card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]] + + + transformed = transform({"card_id": ""}, Params) + # {'cardID': ''} + ``` + + Any keys / data that does not have type information given will be included as is. + + It should be noted that the transformations that this function does are not represented in the type system. + """ + transformed = await _async_transform_recursive(data, annotation=cast(type, expected_type)) + return cast(_T, transformed) + + +async def _async_transform_recursive( + data: object, + *, + annotation: type, + inner_type: type | None = None, +) -> object: + """Transform the given data against the expected type. + + Args: + annotation: The direct type annotation given to the particular piece of data. + This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc + + inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type + is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in + the list can be transformed using the metadata from the container type. + + Defaults to the same value as the `annotation` argument. + """ + if inner_type is None: + inner_type = annotation + + stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type + if is_typeddict(stripped_type) and is_mapping(data): + return await _async_transform_typeddict(data, stripped_type) + + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + + if ( + # List[T] + (is_list_type(stripped_type) and is_list(data)) + # Iterable[T] + or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + ): + # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually + # intended as an iterable, so we don't transform it. + if isinstance(data, dict): + return cast(object, data) + + inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + + return [await _async_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] + + if is_union_type(stripped_type): + # For union types we run the transformation against all subtypes to ensure that everything is transformed. + # + # TODO: there may be edge cases where the same normalized field name will transform to two different names + # in different subtypes. + for subtype in get_args(stripped_type): + data = await _async_transform_recursive(data, annotation=annotation, inner_type=subtype) + return data + + if isinstance(data, pydantic.BaseModel): + return model_dump(data, exclude_unset=True, mode="json") + + annotated_type = _get_annotated_type(annotation) + if annotated_type is None: + return data + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.format is not None: + return await _async_format_data(data, annotation.format, annotation.format_template) + + return data + + +async def _async_format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object: + if isinstance(data, (date, datetime)): + if format_ == "iso8601": + return data.isoformat() + + if format_ == "custom" and format_template is not None: + return data.strftime(format_template) + + if format_ == "base64" and is_base64_file_input(data): + binary: str | bytes | None = None + + if isinstance(data, pathlib.Path): + binary = await anyio.Path(data).read_bytes() + elif isinstance(data, io.IOBase): + binary = data.read() + + if isinstance(binary, str): # type: ignore[unreachable] + binary = binary.encode() + + if not isinstance(binary, bytes): + raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") + + return base64.b64encode(binary).decode("ascii") + + return data + + +async def _async_transform_typeddict( + data: Mapping[str, object], + expected_type: type, +) -> Mapping[str, object]: + result: dict[str, object] = {} + annotations = get_type_hints(expected_type, include_extras=True) + for key, value in data.items(): + if not is_given(value): + # we don't need to include `NotGiven` values here as they'll + # be stripped out before the request is sent anyway + continue + + type_ = annotations.get(key) + if type_ is None: + # we do not have a type annotation for this field, leave it as is + result[key] = value + else: + result[_maybe_transform_key(key, type_)] = await _async_transform_recursive(value, annotation=type_) + return result + + +@lru_cache(maxsize=8096) +def get_type_hints( + obj: Any, + globalns: dict[str, Any] | None = None, + localns: Mapping[str, Any] | None = None, + include_extras: bool = False, +) -> dict[str, Any]: + return _get_type_hints(obj, globalns=globalns, localns=localns, include_extras=include_extras) diff --git a/src/brand/dev/_utils/_typing.py b/src/brand/dev/_utils/_typing.py new file mode 100644 index 0000000..1bac954 --- /dev/null +++ b/src/brand/dev/_utils/_typing.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import sys +import typing +import typing_extensions +from typing import Any, TypeVar, Iterable, cast +from collections import abc as _c_abc +from typing_extensions import ( + TypeIs, + Required, + Annotated, + get_args, + get_origin, +) + +from ._utils import lru_cache +from .._types import InheritsGeneric +from .._compat import is_union as _is_union + + +def is_annotated_type(typ: type) -> bool: + return get_origin(typ) == Annotated + + +def is_list_type(typ: type) -> bool: + return (get_origin(typ) or typ) == list + + +def is_iterable_type(typ: type) -> bool: + """If the given type is `typing.Iterable[T]`""" + origin = get_origin(typ) or typ + return origin == Iterable or origin == _c_abc.Iterable + + +def is_union_type(typ: type) -> bool: + return _is_union(get_origin(typ)) + + +def is_required_type(typ: type) -> bool: + return get_origin(typ) == Required + + +def is_typevar(typ: type) -> bool: + # type ignore is required because type checkers + # think this expression will always return False + return type(typ) == TypeVar # type: ignore + + +_TYPE_ALIAS_TYPES: tuple[type[typing_extensions.TypeAliasType], ...] = (typing_extensions.TypeAliasType,) +if sys.version_info >= (3, 12): + _TYPE_ALIAS_TYPES = (*_TYPE_ALIAS_TYPES, typing.TypeAliasType) + + +def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]: + """Return whether the provided argument is an instance of `TypeAliasType`. + + ```python + type Int = int + is_type_alias_type(Int) + # > True + Str = TypeAliasType("Str", str) + is_type_alias_type(Str) + # > True + ``` + """ + return isinstance(tp, _TYPE_ALIAS_TYPES) + + +# Extracts T from Annotated[T, ...] or from Required[Annotated[T, ...]] +@lru_cache(maxsize=8096) +def strip_annotated_type(typ: type) -> type: + if is_required_type(typ) or is_annotated_type(typ): + return strip_annotated_type(cast(type, get_args(typ)[0])) + + return typ + + +def extract_type_arg(typ: type, index: int) -> type: + args = get_args(typ) + try: + return cast(type, args[index]) + except IndexError as err: + raise RuntimeError(f"Expected type {typ} to have a type argument at index {index} but it did not") from err + + +def extract_type_var_from_base( + typ: type, + *, + generic_bases: tuple[type, ...], + index: int, + failure_message: str | None = None, +) -> type: + """Given a type like `Foo[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyResponse(Foo[bytes]): + ... + + extract_type_var(MyResponse, bases=(Foo,), index=0) -> bytes + ``` + + And where a generic subclass is given: + ```py + _T = TypeVar('_T') + class MyResponse(Foo[_T]): + ... + + extract_type_var(MyResponse[bytes], bases=(Foo,), index=0) -> bytes + ``` + """ + cls = cast(object, get_origin(typ) or typ) + if cls in generic_bases: # pyright: ignore[reportUnnecessaryContains] + # we're given the class directly + return extract_type_arg(typ, index) + + # if a subclass is given + # --- + # this is needed as __orig_bases__ is not present in the typeshed stubs + # because it is intended to be for internal use only, however there does + # not seem to be a way to resolve generic TypeVars for inherited subclasses + # without using it. + if isinstance(cls, InheritsGeneric): + target_base_class: Any | None = None + for base in cls.__orig_bases__: + if base.__origin__ in generic_bases: + target_base_class = base + break + + if target_base_class is None: + raise RuntimeError( + "Could not find the generic base class;\n" + "This should never happen;\n" + f"Does {cls} inherit from one of {generic_bases} ?" + ) + + extracted = extract_type_arg(target_base_class, index) + if is_typevar(extracted): + # If the extracted type argument is itself a type variable + # then that means the subclass itself is generic, so we have + # to resolve the type argument from the class itself, not + # the base class. + # + # Note: if there is more than 1 type argument, the subclass could + # change the ordering of the type arguments, this is not currently + # supported. + return extract_type_arg(typ, index) + + return extracted + + raise RuntimeError(failure_message or f"Could not resolve inner type variable at index {index} for {typ}") diff --git a/src/brand/dev/_utils/_utils.py b/src/brand/dev/_utils/_utils.py new file mode 100644 index 0000000..ea3cf3f --- /dev/null +++ b/src/brand/dev/_utils/_utils.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +import os +import re +import inspect +import functools +from typing import ( + Any, + Tuple, + Mapping, + TypeVar, + Callable, + Iterable, + Sequence, + cast, + overload, +) +from pathlib import Path +from datetime import date, datetime +from typing_extensions import TypeGuard + +import sniffio + +from .._types import NotGiven, FileTypes, NotGivenOr, HeadersLike +from .._compat import parse_date as parse_date, parse_datetime as parse_datetime + +_T = TypeVar("_T") +_TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) +_MappingT = TypeVar("_MappingT", bound=Mapping[str, object]) +_SequenceT = TypeVar("_SequenceT", bound=Sequence[object]) +CallableT = TypeVar("CallableT", bound=Callable[..., Any]) + + +def flatten(t: Iterable[Iterable[_T]]) -> list[_T]: + return [item for sublist in t for item in sublist] + + +def extract_files( + # TODO: this needs to take Dict but variance issues..... + # create protocol type ? + query: Mapping[str, object], + *, + paths: Sequence[Sequence[str]], +) -> list[tuple[str, FileTypes]]: + """Recursively extract files from the given dictionary based on specified paths. + + A path may look like this ['foo', 'files', '', 'data']. + + Note: this mutates the given dictionary. + """ + files: list[tuple[str, FileTypes]] = [] + for path in paths: + files.extend(_extract_items(query, path, index=0, flattened_key=None)) + return files + + +def _extract_items( + obj: object, + path: Sequence[str], + *, + index: int, + flattened_key: str | None, +) -> list[tuple[str, FileTypes]]: + try: + key = path[index] + except IndexError: + if isinstance(obj, NotGiven): + # no value was provided - we can safely ignore + return [] + + # cyclical import + from .._files import assert_is_file_content + + # We have exhausted the path, return the entry we found. + assert flattened_key is not None + + if is_list(obj): + files: list[tuple[str, FileTypes]] = [] + for entry in obj: + assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "") + files.append((flattened_key + "[]", cast(FileTypes, entry))) + return files + + assert_is_file_content(obj, key=flattened_key) + return [(flattened_key, cast(FileTypes, obj))] + + index += 1 + if is_dict(obj): + try: + # We are at the last entry in the path so we must remove the field + if (len(path)) == index: + item = obj.pop(key) + else: + item = obj[key] + except KeyError: + # Key was not present in the dictionary, this is not indicative of an error + # as the given path may not point to a required field. We also do not want + # to enforce required fields as the API may differ from the spec in some cases. + return [] + if flattened_key is None: + flattened_key = key + else: + flattened_key += f"[{key}]" + return _extract_items( + item, + path, + index=index, + flattened_key=flattened_key, + ) + elif is_list(obj): + if key != "": + return [] + + return flatten( + [ + _extract_items( + item, + path, + index=index, + flattened_key=flattened_key + "[]" if flattened_key is not None else "[]", + ) + for item in obj + ] + ) + + # Something unexpected was passed, just ignore it. + return [] + + +def is_given(obj: NotGivenOr[_T]) -> TypeGuard[_T]: + return not isinstance(obj, NotGiven) + + +# Type safe methods for narrowing types with TypeVars. +# The default narrowing for isinstance(obj, dict) is dict[unknown, unknown], +# however this cause Pyright to rightfully report errors. As we know we don't +# care about the contained types we can safely use `object` in it's place. +# +# There are two separate functions defined, `is_*` and `is_*_t` for different use cases. +# `is_*` is for when you're dealing with an unknown input +# `is_*_t` is for when you're narrowing a known union type to a specific subset + + +def is_tuple(obj: object) -> TypeGuard[tuple[object, ...]]: + return isinstance(obj, tuple) + + +def is_tuple_t(obj: _TupleT | object) -> TypeGuard[_TupleT]: + return isinstance(obj, tuple) + + +def is_sequence(obj: object) -> TypeGuard[Sequence[object]]: + return isinstance(obj, Sequence) + + +def is_sequence_t(obj: _SequenceT | object) -> TypeGuard[_SequenceT]: + return isinstance(obj, Sequence) + + +def is_mapping(obj: object) -> TypeGuard[Mapping[str, object]]: + return isinstance(obj, Mapping) + + +def is_mapping_t(obj: _MappingT | object) -> TypeGuard[_MappingT]: + return isinstance(obj, Mapping) + + +def is_dict(obj: object) -> TypeGuard[dict[object, object]]: + return isinstance(obj, dict) + + +def is_list(obj: object) -> TypeGuard[list[object]]: + return isinstance(obj, list) + + +def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: + return isinstance(obj, Iterable) + + +def deepcopy_minimal(item: _T) -> _T: + """Minimal reimplementation of copy.deepcopy() that will only copy certain object types: + + - mappings, e.g. `dict` + - list + + This is done for performance reasons. + """ + if is_mapping(item): + return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) + if is_list(item): + return cast(_T, [deepcopy_minimal(entry) for entry in item]) + return item + + +# copied from https://github.com/Rapptz/RoboDanny +def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: + size = len(seq) + if size == 0: + return "" + + if size == 1: + return seq[0] + + if size == 2: + return f"{seq[0]} {final} {seq[1]}" + + return delim.join(seq[:-1]) + f" {final} {seq[-1]}" + + +def quote(string: str) -> str: + """Add single quotation marks around the given string. Does *not* do any escaping.""" + return f"'{string}'" + + +def required_args(*variants: Sequence[str]) -> Callable[[CallableT], CallableT]: + """Decorator to enforce a given set of arguments or variants of arguments are passed to the decorated function. + + Useful for enforcing runtime validation of overloaded functions. + + Example usage: + ```py + @overload + def foo(*, a: str) -> str: ... + + + @overload + def foo(*, b: bool) -> str: ... + + + # This enforces the same constraints that a static type checker would + # i.e. that either a or b must be passed to the function + @required_args(["a"], ["b"]) + def foo(*, a: str | None = None, b: bool | None = None) -> str: ... + ``` + """ + + def inner(func: CallableT) -> CallableT: + params = inspect.signature(func).parameters + positional = [ + name + for name, param in params.items() + if param.kind + in { + param.POSITIONAL_ONLY, + param.POSITIONAL_OR_KEYWORD, + } + ] + + @functools.wraps(func) + def wrapper(*args: object, **kwargs: object) -> object: + given_params: set[str] = set() + for i, _ in enumerate(args): + try: + given_params.add(positional[i]) + except IndexError: + raise TypeError( + f"{func.__name__}() takes {len(positional)} argument(s) but {len(args)} were given" + ) from None + + for key in kwargs.keys(): + given_params.add(key) + + for variant in variants: + matches = all((param in given_params for param in variant)) + if matches: + break + else: # no break + if len(variants) > 1: + variations = human_join( + ["(" + human_join([quote(arg) for arg in variant], final="and") + ")" for variant in variants] + ) + msg = f"Missing required arguments; Expected either {variations} arguments to be given" + else: + assert len(variants) > 0 + + # TODO: this error message is not deterministic + missing = list(set(variants[0]) - given_params) + if len(missing) > 1: + msg = f"Missing required arguments: {human_join([quote(arg) for arg in missing])}" + else: + msg = f"Missing required argument: {quote(missing[0])}" + raise TypeError(msg) + return func(*args, **kwargs) + + return wrapper # type: ignore + + return inner + + +_K = TypeVar("_K") +_V = TypeVar("_V") + + +@overload +def strip_not_given(obj: None) -> None: ... + + +@overload +def strip_not_given(obj: Mapping[_K, _V | NotGiven]) -> dict[_K, _V]: ... + + +@overload +def strip_not_given(obj: object) -> object: ... + + +def strip_not_given(obj: object | None) -> object: + """Remove all top-level keys where their values are instances of `NotGiven`""" + if obj is None: + return None + + if not is_mapping(obj): + return obj + + return {key: value for key, value in obj.items() if not isinstance(value, NotGiven)} + + +def coerce_integer(val: str) -> int: + return int(val, base=10) + + +def coerce_float(val: str) -> float: + return float(val) + + +def coerce_boolean(val: str) -> bool: + return val == "true" or val == "1" or val == "on" + + +def maybe_coerce_integer(val: str | None) -> int | None: + if val is None: + return None + return coerce_integer(val) + + +def maybe_coerce_float(val: str | None) -> float | None: + if val is None: + return None + return coerce_float(val) + + +def maybe_coerce_boolean(val: str | None) -> bool | None: + if val is None: + return None + return coerce_boolean(val) + + +def removeprefix(string: str, prefix: str) -> str: + """Remove a prefix from a string. + + Backport of `str.removeprefix` for Python < 3.9 + """ + if string.startswith(prefix): + return string[len(prefix) :] + return string + + +def removesuffix(string: str, suffix: str) -> str: + """Remove a suffix from a string. + + Backport of `str.removesuffix` for Python < 3.9 + """ + if string.endswith(suffix): + return string[: -len(suffix)] + return string + + +def file_from_path(path: str) -> FileTypes: + contents = Path(path).read_bytes() + file_name = os.path.basename(path) + return (file_name, contents) + + +def get_required_header(headers: HeadersLike, header: str) -> str: + lower_header = header.lower() + if is_mapping_t(headers): + # mypy doesn't understand the type narrowing here + for k, v in headers.items(): # type: ignore + if k.lower() == lower_header and isinstance(v, str): + return v + + # to deal with the case where the header looks like Stainless-Event-Id + intercaps_header = re.sub(r"([^\w])(\w)", lambda pat: pat.group(1) + pat.group(2).upper(), header.capitalize()) + + for normalized_header in [header, lower_header, header.upper(), intercaps_header]: + value = headers.get(normalized_header) + if value: + return value + + raise ValueError(f"Could not find {header} header") + + +def get_async_library() -> str: + try: + return sniffio.current_async_library() + except Exception: + return "false" + + +def lru_cache(*, maxsize: int | None = 128) -> Callable[[CallableT], CallableT]: + """A version of functools.lru_cache that retains the type signature + for the wrapped function arguments. + """ + wrapper = functools.lru_cache( # noqa: TID251 + maxsize=maxsize, + ) + return cast(Any, wrapper) # type: ignore[no-any-return] + + +def json_safe(data: object) -> object: + """Translates a mapping / sequence recursively in the same fashion + as `pydantic` v2's `model_dump(mode="json")`. + """ + if is_mapping(data): + return {json_safe(key): json_safe(value) for key, value in data.items()} + + if is_iterable(data) and not isinstance(data, (str, bytes, bytearray)): + return [json_safe(item) for item in data] + + if isinstance(data, (datetime, date)): + return data.isoformat() + + return data diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py new file mode 100644 index 0000000..aef490a --- /dev/null +++ b/src/brand/dev/_version.py @@ -0,0 +1,4 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +__title__ = "brand.dev" +__version__ = "0.0.1-alpha.0" diff --git a/src/brand/dev/lib/.keep b/src/brand/dev/lib/.keep new file mode 100644 index 0000000..5e2c99f --- /dev/null +++ b/src/brand/dev/lib/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store custom files to expand the SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/src/brand/dev/py.typed b/src/brand/dev/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/brand/dev/resources/__init__.py b/src/brand/dev/resources/__init__.py new file mode 100644 index 0000000..ffb8f92 --- /dev/null +++ b/src/brand/dev/resources/__init__.py @@ -0,0 +1,19 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .brand import ( + BrandResource, + AsyncBrandResource, + BrandResourceWithRawResponse, + AsyncBrandResourceWithRawResponse, + BrandResourceWithStreamingResponse, + AsyncBrandResourceWithStreamingResponse, +) + +__all__ = [ + "BrandResource", + "AsyncBrandResource", + "BrandResourceWithRawResponse", + "AsyncBrandResourceWithRawResponse", + "BrandResourceWithStreamingResponse", + "AsyncBrandResourceWithStreamingResponse", +] diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py new file mode 100644 index 0000000..a0ec7bd --- /dev/null +++ b/src/brand/dev/resources/brand.py @@ -0,0 +1,673 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal + +import httpx + +from ..types import ( + brand_search_params, + brand_retrieve_params, + brand_retrieve_naics_params, + brand_retrieve_by_ticker_params, + brand_identify_from_transaction_params, +) +from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.brand_search_response import BrandSearchResponse +from ..types.brand_retrieve_response import BrandRetrieveResponse +from ..types.brand_retrieve_naics_response import BrandRetrieveNaicsResponse +from ..types.brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse +from ..types.brand_identify_from_transaction_response import BrandIdentifyFromTransactionResponse + +__all__ = ["BrandResource", "AsyncBrandResource"] + + +class BrandResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> BrandResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/brand.dev-python#accessing-raw-response-data-eg-headers + """ + return BrandResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> BrandResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/brand.dev-python#with_streaming_response + """ + return BrandResourceWithStreamingResponse(self) + + def retrieve( + self, + *, + domain: str, + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandRetrieveResponse: + """ + Retrieve brand data by domain + + Args: + domain: Domain name to retrieve brand data for + + force_language: Optional parameter to force the language of the retrieved brand data + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/retrieve", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "domain": domain, + "force_language": force_language, + }, + brand_retrieve_params.BrandRetrieveParams, + ), + ), + cast_to=BrandRetrieveResponse, + ) + + def identify_from_transaction( + self, + *, + transaction_info: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandIdentifyFromTransactionResponse: + """ + Endpoint specially designed for platforms that want to identify transaction data + by the transaction title. + + Args: + transaction_info: Transaction information to identify the brand + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/transaction_identifier", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + {"transaction_info": transaction_info}, + brand_identify_from_transaction_params.BrandIdentifyFromTransactionParams, + ), + ), + cast_to=BrandIdentifyFromTransactionResponse, + ) + + def retrieve_by_ticker( + self, + *, + ticker: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandRetrieveByTickerResponse: + """Retrieve brand data by stock ticker (e.g. + + AAPL, TSLA, etc.) + + Args: + ticker: Stock ticker symbol to retrieve brand data for (e.g. AAPL, TSLA, etc.) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/retrieve-by-ticker", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"ticker": ticker}, brand_retrieve_by_ticker_params.BrandRetrieveByTickerParams), + ), + cast_to=BrandRetrieveByTickerResponse, + ) + + def retrieve_naics( + self, + *, + input: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandRetrieveNaicsResponse: + """ + Endpoint to classify any brand into a 2022 NAICS code. + + Args: + input: Brand domain or title to retrieve NAICS code for. If a valid domain is provided + in `input`, it will be used for classification, otherwise, we will search for + the brand using the provided title. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/naics", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"input": input}, brand_retrieve_naics_params.BrandRetrieveNaicsParams), + ), + cast_to=BrandRetrieveNaicsResponse, + ) + + def search( + self, + *, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandSearchResponse: + """ + Search brands by query + + Args: + query: Query string to search brands + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/search", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"query": query}, brand_search_params.BrandSearchParams), + ), + cast_to=BrandSearchResponse, + ) + + +class AsyncBrandResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncBrandResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/stainless-sdks/brand.dev-python#accessing-raw-response-data-eg-headers + """ + return AsyncBrandResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncBrandResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/stainless-sdks/brand.dev-python#with_streaming_response + """ + return AsyncBrandResourceWithStreamingResponse(self) + + async def retrieve( + self, + *, + domain: str, + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandRetrieveResponse: + """ + Retrieve brand data by domain + + Args: + domain: Domain name to retrieve brand data for + + force_language: Optional parameter to force the language of the retrieved brand data + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/brand/retrieve", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "domain": domain, + "force_language": force_language, + }, + brand_retrieve_params.BrandRetrieveParams, + ), + ), + cast_to=BrandRetrieveResponse, + ) + + async def identify_from_transaction( + self, + *, + transaction_info: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandIdentifyFromTransactionResponse: + """ + Endpoint specially designed for platforms that want to identify transaction data + by the transaction title. + + Args: + transaction_info: Transaction information to identify the brand + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/brand/transaction_identifier", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"transaction_info": transaction_info}, + brand_identify_from_transaction_params.BrandIdentifyFromTransactionParams, + ), + ), + cast_to=BrandIdentifyFromTransactionResponse, + ) + + async def retrieve_by_ticker( + self, + *, + ticker: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandRetrieveByTickerResponse: + """Retrieve brand data by stock ticker (e.g. + + AAPL, TSLA, etc.) + + Args: + ticker: Stock ticker symbol to retrieve brand data for (e.g. AAPL, TSLA, etc.) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/brand/retrieve-by-ticker", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"ticker": ticker}, brand_retrieve_by_ticker_params.BrandRetrieveByTickerParams + ), + ), + cast_to=BrandRetrieveByTickerResponse, + ) + + async def retrieve_naics( + self, + *, + input: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandRetrieveNaicsResponse: + """ + Endpoint to classify any brand into a 2022 NAICS code. + + Args: + input: Brand domain or title to retrieve NAICS code for. If a valid domain is provided + in `input`, it will be used for classification, otherwise, we will search for + the brand using the provided title. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/brand/naics", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"input": input}, brand_retrieve_naics_params.BrandRetrieveNaicsParams + ), + ), + cast_to=BrandRetrieveNaicsResponse, + ) + + async def search( + self, + *, + query: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandSearchResponse: + """ + Search brands by query + + Args: + query: Query string to search brands + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/brand/search", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"query": query}, brand_search_params.BrandSearchParams), + ), + cast_to=BrandSearchResponse, + ) + + +class BrandResourceWithRawResponse: + def __init__(self, brand: BrandResource) -> None: + self._brand = brand + + self.retrieve = to_raw_response_wrapper( + brand.retrieve, + ) + self.identify_from_transaction = to_raw_response_wrapper( + brand.identify_from_transaction, + ) + self.retrieve_by_ticker = to_raw_response_wrapper( + brand.retrieve_by_ticker, + ) + self.retrieve_naics = to_raw_response_wrapper( + brand.retrieve_naics, + ) + self.search = to_raw_response_wrapper( + brand.search, + ) + + +class AsyncBrandResourceWithRawResponse: + def __init__(self, brand: AsyncBrandResource) -> None: + self._brand = brand + + self.retrieve = async_to_raw_response_wrapper( + brand.retrieve, + ) + self.identify_from_transaction = async_to_raw_response_wrapper( + brand.identify_from_transaction, + ) + self.retrieve_by_ticker = async_to_raw_response_wrapper( + brand.retrieve_by_ticker, + ) + self.retrieve_naics = async_to_raw_response_wrapper( + brand.retrieve_naics, + ) + self.search = async_to_raw_response_wrapper( + brand.search, + ) + + +class BrandResourceWithStreamingResponse: + def __init__(self, brand: BrandResource) -> None: + self._brand = brand + + self.retrieve = to_streamed_response_wrapper( + brand.retrieve, + ) + self.identify_from_transaction = to_streamed_response_wrapper( + brand.identify_from_transaction, + ) + self.retrieve_by_ticker = to_streamed_response_wrapper( + brand.retrieve_by_ticker, + ) + self.retrieve_naics = to_streamed_response_wrapper( + brand.retrieve_naics, + ) + self.search = to_streamed_response_wrapper( + brand.search, + ) + + +class AsyncBrandResourceWithStreamingResponse: + def __init__(self, brand: AsyncBrandResource) -> None: + self._brand = brand + + self.retrieve = async_to_streamed_response_wrapper( + brand.retrieve, + ) + self.identify_from_transaction = async_to_streamed_response_wrapper( + brand.identify_from_transaction, + ) + self.retrieve_by_ticker = async_to_streamed_response_wrapper( + brand.retrieve_by_ticker, + ) + self.retrieve_naics = async_to_streamed_response_wrapper( + brand.retrieve_naics, + ) + self.search = async_to_streamed_response_wrapper( + brand.search, + ) diff --git a/src/brand/dev/types/__init__.py b/src/brand/dev/types/__init__.py new file mode 100644 index 0000000..55bc58f --- /dev/null +++ b/src/brand/dev/types/__init__.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .brand_search_params import BrandSearchParams as BrandSearchParams +from .brand_retrieve_params import BrandRetrieveParams as BrandRetrieveParams +from .brand_search_response import BrandSearchResponse as BrandSearchResponse +from .brand_retrieve_response import BrandRetrieveResponse as BrandRetrieveResponse +from .brand_retrieve_naics_params import BrandRetrieveNaicsParams as BrandRetrieveNaicsParams +from .brand_retrieve_naics_response import BrandRetrieveNaicsResponse as BrandRetrieveNaicsResponse +from .brand_retrieve_by_ticker_params import BrandRetrieveByTickerParams as BrandRetrieveByTickerParams +from .brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse as BrandRetrieveByTickerResponse +from .brand_identify_from_transaction_params import ( + BrandIdentifyFromTransactionParams as BrandIdentifyFromTransactionParams, +) +from .brand_identify_from_transaction_response import ( + BrandIdentifyFromTransactionResponse as BrandIdentifyFromTransactionResponse, +) diff --git a/src/brand/dev/types/brand_identify_from_transaction_params.py b/src/brand/dev/types/brand_identify_from_transaction_params.py new file mode 100644 index 0000000..0112bdf --- /dev/null +++ b/src/brand/dev/types/brand_identify_from_transaction_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["BrandIdentifyFromTransactionParams"] + + +class BrandIdentifyFromTransactionParams(TypedDict, total=False): + transaction_info: Required[str] + """Transaction information to identify the brand""" diff --git a/src/brand/dev/types/brand_identify_from_transaction_response.py b/src/brand/dev/types/brand_identify_from_transaction_response.py new file mode 100644 index 0000000..48411d6 --- /dev/null +++ b/src/brand/dev/types/brand_identify_from_transaction_response.py @@ -0,0 +1,185 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from .._models import BaseModel + +__all__ = [ + "BrandIdentifyFromTransactionResponse", + "Brand", + "BrandAddress", + "BrandBackdrop", + "BrandBackdropColor", + "BrandBackdropResolution", + "BrandColor", + "BrandFont", + "BrandLogo", + "BrandLogoColor", + "BrandLogoResolution", + "BrandSocial", + "BrandStock", +] + + +class BrandAddress(BaseModel): + city: Optional[str] = None + """City name""" + + country: Optional[str] = None + """Country name""" + + country_code: Optional[str] = None + """Country code""" + + postal_code: Optional[str] = None + """Postal or ZIP code""" + + state_code: Optional[str] = None + """State or province code""" + + state_province: Optional[str] = None + """State or province name""" + + street: Optional[str] = None + """Street address""" + + +class BrandBackdropColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandBackdropResolution(BaseModel): + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandBackdrop(BaseModel): + colors: Optional[List[BrandBackdropColor]] = None + """Array of colors in the backdrop image""" + + resolution: Optional[BrandBackdropResolution] = None + """Resolution of the backdrop image""" + + url: Optional[str] = None + """URL of the backdrop image""" + + +class BrandColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandFont(BaseModel): + name: Optional[str] = None + """Name of the font""" + + usage: Optional[str] = None + """Usage of the font, e.g., 'title', 'body', 'button'""" + + +class BrandLogoColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandLogoResolution(BaseModel): + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandLogo(BaseModel): + colors: Optional[List[BrandLogoColor]] = None + """Array of colors in the logo""" + + group: Optional[int] = None + """Group identifier for logos""" + + mode: Optional[str] = None + """Mode of the logo, e.g., 'dark', 'light'""" + + resolution: Optional[BrandLogoResolution] = None + """Resolution of the logo image""" + + url: Optional[str] = None + """URL of the logo image""" + + +class BrandSocial(BaseModel): + type: Optional[str] = None + """Type of social media, e.g., 'facebook', 'twitter'""" + + url: Optional[str] = None + """URL of the social media page""" + + +class BrandStock(BaseModel): + exchange: Optional[str] = None + """Stock exchange name""" + + ticker: Optional[str] = None + """Stock ticker symbol""" + + +class Brand(BaseModel): + address: Optional[BrandAddress] = None + """Physical address of the brand""" + + backdrops: Optional[List[BrandBackdrop]] = None + """An array of backdrop images for the brand""" + + colors: Optional[List[BrandColor]] = None + """An array of brand colors""" + + description: Optional[str] = None + """A brief description of the brand""" + + domain: Optional[str] = None + """The domain name of the brand""" + + fonts: Optional[List[BrandFont]] = None + """An array of fonts used by the brand's website""" + + logos: Optional[List[BrandLogo]] = None + """An array of logos associated with the brand""" + + slogan: Optional[str] = None + """The brand's slogan""" + + socials: Optional[List[BrandSocial]] = None + """An array of social media links for the brand""" + + stock: Optional[BrandStock] = None + """ + Stock market information for this brand (will be null if not a publicly traded + company) + """ + + title: Optional[str] = None + """The title or name of the brand""" + + +class BrandIdentifyFromTransactionResponse(BaseModel): + brand: Optional[Brand] = None + """Detailed brand information""" + + code: Optional[int] = None + """HTTP status code""" + + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" diff --git a/src/brand/dev/types/brand_retrieve_by_ticker_params.py b/src/brand/dev/types/brand_retrieve_by_ticker_params.py new file mode 100644 index 0000000..4c63ed2 --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_by_ticker_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["BrandRetrieveByTickerParams"] + + +class BrandRetrieveByTickerParams(TypedDict, total=False): + ticker: Required[str] + """Stock ticker symbol to retrieve brand data for (e.g. AAPL, TSLA, etc.)""" diff --git a/src/brand/dev/types/brand_retrieve_by_ticker_response.py b/src/brand/dev/types/brand_retrieve_by_ticker_response.py new file mode 100644 index 0000000..9da5d28 --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_by_ticker_response.py @@ -0,0 +1,185 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from .._models import BaseModel + +__all__ = [ + "BrandRetrieveByTickerResponse", + "Brand", + "BrandAddress", + "BrandBackdrop", + "BrandBackdropColor", + "BrandBackdropResolution", + "BrandColor", + "BrandFont", + "BrandLogo", + "BrandLogoColor", + "BrandLogoResolution", + "BrandSocial", + "BrandStock", +] + + +class BrandAddress(BaseModel): + city: Optional[str] = None + """City name""" + + country: Optional[str] = None + """Country name""" + + country_code: Optional[str] = None + """Country code""" + + postal_code: Optional[str] = None + """Postal or ZIP code""" + + state_code: Optional[str] = None + """State or province code""" + + state_province: Optional[str] = None + """State or province name""" + + street: Optional[str] = None + """Street address""" + + +class BrandBackdropColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandBackdropResolution(BaseModel): + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandBackdrop(BaseModel): + colors: Optional[List[BrandBackdropColor]] = None + """Array of colors in the backdrop image""" + + resolution: Optional[BrandBackdropResolution] = None + """Resolution of the backdrop image""" + + url: Optional[str] = None + """URL of the backdrop image""" + + +class BrandColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandFont(BaseModel): + name: Optional[str] = None + """Name of the font""" + + usage: Optional[str] = None + """Usage of the font, e.g., 'title', 'body', 'button'""" + + +class BrandLogoColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandLogoResolution(BaseModel): + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandLogo(BaseModel): + colors: Optional[List[BrandLogoColor]] = None + """Array of colors in the logo""" + + group: Optional[int] = None + """Group identifier for logos""" + + mode: Optional[str] = None + """Mode of the logo, e.g., 'dark', 'light'""" + + resolution: Optional[BrandLogoResolution] = None + """Resolution of the logo image""" + + url: Optional[str] = None + """URL of the logo image""" + + +class BrandSocial(BaseModel): + type: Optional[str] = None + """Type of social media, e.g., 'facebook', 'twitter'""" + + url: Optional[str] = None + """URL of the social media page""" + + +class BrandStock(BaseModel): + exchange: Optional[str] = None + """Stock exchange name""" + + ticker: Optional[str] = None + """Stock ticker symbol""" + + +class Brand(BaseModel): + address: Optional[BrandAddress] = None + """Physical address of the brand""" + + backdrops: Optional[List[BrandBackdrop]] = None + """An array of backdrop images for the brand""" + + colors: Optional[List[BrandColor]] = None + """An array of brand colors""" + + description: Optional[str] = None + """A brief description of the brand""" + + domain: Optional[str] = None + """The domain name of the brand""" + + fonts: Optional[List[BrandFont]] = None + """An array of fonts used by the brand's website""" + + logos: Optional[List[BrandLogo]] = None + """An array of logos associated with the brand""" + + slogan: Optional[str] = None + """The brand's slogan""" + + socials: Optional[List[BrandSocial]] = None + """An array of social media links for the brand""" + + stock: Optional[BrandStock] = None + """ + Stock market information for this brand (will be null if not a publicly traded + company) + """ + + title: Optional[str] = None + """The title or name of the brand""" + + +class BrandRetrieveByTickerResponse(BaseModel): + brand: Optional[Brand] = None + """Detailed brand information""" + + code: Optional[int] = None + """HTTP status code""" + + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" diff --git a/src/brand/dev/types/brand_retrieve_naics_params.py b/src/brand/dev/types/brand_retrieve_naics_params.py new file mode 100644 index 0000000..8d954fc --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_naics_params.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["BrandRetrieveNaicsParams"] + + +class BrandRetrieveNaicsParams(TypedDict, total=False): + input: Required[str] + """Brand domain or title to retrieve NAICS code for. + + If a valid domain is provided in `input`, it will be used for classification, + otherwise, we will search for the brand using the provided title. + """ diff --git a/src/brand/dev/types/brand_retrieve_naics_response.py b/src/brand/dev/types/brand_retrieve_naics_response.py new file mode 100644 index 0000000..5aa467d --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_naics_response.py @@ -0,0 +1,29 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from .._models import BaseModel + +__all__ = ["BrandRetrieveNaicsResponse", "Code"] + + +class Code(BaseModel): + code: Optional[str] = None + """NAICS code""" + + title: Optional[str] = None + """NAICS title""" + + +class BrandRetrieveNaicsResponse(BaseModel): + codes: Optional[List[Code]] = None + """Array of NAICS codes and titles.""" + + domain: Optional[str] = None + """Domain found for the brand""" + + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" + + type: Optional[str] = None + """Industry classification type, for naics api it will be `naics`""" diff --git a/src/brand/dev/types/brand_retrieve_params.py b/src/brand/dev/types/brand_retrieve_params.py new file mode 100644 index 0000000..e28d2db --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_params.py @@ -0,0 +1,68 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["BrandRetrieveParams"] + + +class BrandRetrieveParams(TypedDict, total=False): + domain: Required[str] + """Domain name to retrieve brand data for""" + + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + """Optional parameter to force the language of the retrieved brand data""" diff --git a/src/brand/dev/types/brand_retrieve_response.py b/src/brand/dev/types/brand_retrieve_response.py new file mode 100644 index 0000000..33ff13d --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_response.py @@ -0,0 +1,185 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from .._models import BaseModel + +__all__ = [ + "BrandRetrieveResponse", + "Brand", + "BrandAddress", + "BrandBackdrop", + "BrandBackdropColor", + "BrandBackdropResolution", + "BrandColor", + "BrandFont", + "BrandLogo", + "BrandLogoColor", + "BrandLogoResolution", + "BrandSocial", + "BrandStock", +] + + +class BrandAddress(BaseModel): + city: Optional[str] = None + """City name""" + + country: Optional[str] = None + """Country name""" + + country_code: Optional[str] = None + """Country code""" + + postal_code: Optional[str] = None + """Postal or ZIP code""" + + state_code: Optional[str] = None + """State or province code""" + + state_province: Optional[str] = None + """State or province name""" + + street: Optional[str] = None + """Street address""" + + +class BrandBackdropColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandBackdropResolution(BaseModel): + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandBackdrop(BaseModel): + colors: Optional[List[BrandBackdropColor]] = None + """Array of colors in the backdrop image""" + + resolution: Optional[BrandBackdropResolution] = None + """Resolution of the backdrop image""" + + url: Optional[str] = None + """URL of the backdrop image""" + + +class BrandColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandFont(BaseModel): + name: Optional[str] = None + """Name of the font""" + + usage: Optional[str] = None + """Usage of the font, e.g., 'title', 'body', 'button'""" + + +class BrandLogoColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandLogoResolution(BaseModel): + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandLogo(BaseModel): + colors: Optional[List[BrandLogoColor]] = None + """Array of colors in the logo""" + + group: Optional[int] = None + """Group identifier for logos""" + + mode: Optional[str] = None + """Mode of the logo, e.g., 'dark', 'light'""" + + resolution: Optional[BrandLogoResolution] = None + """Resolution of the logo image""" + + url: Optional[str] = None + """URL of the logo image""" + + +class BrandSocial(BaseModel): + type: Optional[str] = None + """Type of social media, e.g., 'facebook', 'twitter'""" + + url: Optional[str] = None + """URL of the social media page""" + + +class BrandStock(BaseModel): + exchange: Optional[str] = None + """Stock exchange name""" + + ticker: Optional[str] = None + """Stock ticker symbol""" + + +class Brand(BaseModel): + address: Optional[BrandAddress] = None + """Physical address of the brand""" + + backdrops: Optional[List[BrandBackdrop]] = None + """An array of backdrop images for the brand""" + + colors: Optional[List[BrandColor]] = None + """An array of brand colors""" + + description: Optional[str] = None + """A brief description of the brand""" + + domain: Optional[str] = None + """The domain name of the brand""" + + fonts: Optional[List[BrandFont]] = None + """An array of fonts used by the brand's website""" + + logos: Optional[List[BrandLogo]] = None + """An array of logos associated with the brand""" + + slogan: Optional[str] = None + """The brand's slogan""" + + socials: Optional[List[BrandSocial]] = None + """An array of social media links for the brand""" + + stock: Optional[BrandStock] = None + """ + Stock market information for this brand (will be null if not a publicly traded + company) + """ + + title: Optional[str] = None + """The title or name of the brand""" + + +class BrandRetrieveResponse(BaseModel): + brand: Optional[Brand] = None + """Detailed brand information""" + + code: Optional[int] = None + """HTTP status code""" + + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" diff --git a/src/brand/dev/types/brand_search_params.py b/src/brand/dev/types/brand_search_params.py new file mode 100644 index 0000000..5181e54 --- /dev/null +++ b/src/brand/dev/types/brand_search_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["BrandSearchParams"] + + +class BrandSearchParams(TypedDict, total=False): + query: Required[str] + """Query string to search brands""" diff --git a/src/brand/dev/types/brand_search_response.py b/src/brand/dev/types/brand_search_response.py new file mode 100644 index 0000000..c1c30b3 --- /dev/null +++ b/src/brand/dev/types/brand_search_response.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import TypeAlias + +from .._models import BaseModel + +__all__ = ["BrandSearchResponse", "BrandSearchResponseItem"] + + +class BrandSearchResponseItem(BaseModel): + domain: Optional[str] = None + """Domain name of the brand""" + + logo: Optional[str] = None + """URL of the brand's logo""" + + title: Optional[str] = None + """Title or name of the brand""" + + +BrandSearchResponse: TypeAlias = List[BrandSearchResponseItem] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/__init__.py b/tests/api_resources/__init__.py new file mode 100644 index 0000000..fd8019a --- /dev/null +++ b/tests/api_resources/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py new file mode 100644 index 0000000..19e5a5c --- /dev/null +++ b/tests/api_resources/test_brand.py @@ -0,0 +1,386 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from brand.dev import BrandDev, AsyncBrandDev +from tests.utils import assert_matches_type +from brand.dev.types import ( + BrandSearchResponse, + BrandRetrieveResponse, + BrandRetrieveNaicsResponse, + BrandRetrieveByTickerResponse, + BrandIdentifyFromTransactionResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestBrand: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip() + @parametrize + def test_method_retrieve(self, client: BrandDev) -> None: + brand = client.brand.retrieve( + domain="domain", + ) + assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_method_retrieve_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.retrieve( + domain="domain", + force_language="albanian", + ) + assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_raw_response_retrieve(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.retrieve( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_streaming_response_retrieve(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.retrieve( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip() + @parametrize + def test_method_identify_from_transaction(self, client: BrandDev) -> None: + brand = client.brand.identify_from_transaction( + transaction_info="transaction_info", + ) + assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_raw_response_identify_from_transaction(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.identify_from_transaction( + transaction_info="transaction_info", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_streaming_response_identify_from_transaction(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.identify_from_transaction( + transaction_info="transaction_info", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip() + @parametrize + def test_method_retrieve_by_ticker(self, client: BrandDev) -> None: + brand = client.brand.retrieve_by_ticker( + ticker="ticker", + ) + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_raw_response_retrieve_by_ticker(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.retrieve_by_ticker( + ticker="ticker", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_streaming_response_retrieve_by_ticker(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.retrieve_by_ticker( + ticker="ticker", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip() + @parametrize + def test_method_retrieve_naics(self, client: BrandDev) -> None: + brand = client.brand.retrieve_naics( + input="input", + ) + assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_raw_response_retrieve_naics(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.retrieve_naics( + input="input", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_streaming_response_retrieve_naics(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.retrieve_naics( + input="input", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip() + @parametrize + def test_method_search(self, client: BrandDev) -> None: + brand = client.brand.search( + query="query", + ) + assert_matches_type(BrandSearchResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_raw_response_search(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.search( + query="query", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandSearchResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_streaming_response_search(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.search( + query="query", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandSearchResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncBrand: + parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip() + @parametrize + async def test_method_retrieve(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve( + domain="domain", + ) + assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_method_retrieve_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve( + domain="domain", + force_language="albanian", + ) + assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.retrieve( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.retrieve( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip() + @parametrize + async def test_method_identify_from_transaction(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.identify_from_transaction( + transaction_info="transaction_info", + ) + assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_raw_response_identify_from_transaction(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.identify_from_transaction( + transaction_info="transaction_info", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_streaming_response_identify_from_transaction(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.identify_from_transaction( + transaction_info="transaction_info", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip() + @parametrize + async def test_method_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_by_ticker( + ticker="ticker", + ) + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_raw_response_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.retrieve_by_ticker( + ticker="ticker", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_streaming_response_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.retrieve_by_ticker( + ticker="ticker", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip() + @parametrize + async def test_method_retrieve_naics(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_naics( + input="input", + ) + assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_raw_response_retrieve_naics(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.retrieve_naics( + input="input", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_streaming_response_retrieve_naics(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.retrieve_naics( + input="input", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip() + @parametrize + async def test_method_search(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.search( + query="query", + ) + assert_matches_type(BrandSearchResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_raw_response_search(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.search( + query="query", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandSearchResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_streaming_response_search(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.search( + query="query", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandSearchResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5486879 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import os +import logging +from typing import TYPE_CHECKING, Iterator, AsyncIterator + +import pytest +from pytest_asyncio import is_async_test + +from brand.dev import BrandDev, AsyncBrandDev + +if TYPE_CHECKING: + from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] + +pytest.register_assert_rewrite("tests.utils") + +logging.getLogger("brand.dev").setLevel(logging.DEBUG) + + +# automatically add `pytest.mark.asyncio()` to all of our async tests +# so we don't have to add that boilerplate everywhere +def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: + pytest_asyncio_tests = (item for item in items if is_async_test(item)) + session_scope_marker = pytest.mark.asyncio(loop_scope="session") + for async_test in pytest_asyncio_tests: + async_test.add_marker(session_scope_marker, append=False) + + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + +api_key = "My API Key" + + +@pytest.fixture(scope="session") +def client(request: FixtureRequest) -> Iterator[BrandDev]: + strict = getattr(request, "param", True) + if not isinstance(strict, bool): + raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") + + with BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=strict) as client: + yield client + + +@pytest.fixture(scope="session") +async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncBrandDev]: + strict = getattr(request, "param", True) + if not isinstance(strict, bool): + raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") + + async with AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=strict) as client: + yield client diff --git a/tests/sample_file.txt b/tests/sample_file.txt new file mode 100644 index 0000000..af5626b --- /dev/null +++ b/tests/sample_file.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..6cc808b --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,1637 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import gc +import os +import sys +import json +import time +import asyncio +import inspect +import subprocess +import tracemalloc +from typing import Any, Union, cast +from textwrap import dedent +from unittest import mock +from typing_extensions import Literal + +import httpx +import pytest +from respx import MockRouter +from pydantic import ValidationError + +from brand.dev import BrandDev, AsyncBrandDev, APIResponseValidationError +from brand.dev._types import Omit +from brand.dev._models import BaseModel, FinalRequestOptions +from brand.dev._constants import RAW_RESPONSE_HEADER +from brand.dev._exceptions import BrandDevError, APIStatusError, APITimeoutError, APIResponseValidationError +from brand.dev._base_client import ( + DEFAULT_TIMEOUT, + HTTPX_DEFAULT_TIMEOUT, + BaseClient, + make_request_options, +) + +from .utils import update_env + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +api_key = "My API Key" + + +def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + return dict(url.params) + + +def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: + return 0.1 + + +def _get_open_connections(client: BrandDev | AsyncBrandDev) -> int: + transport = client._client._transport + assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) + + pool = transport._pool + return len(pool._requests) + + +class TestBrandDev: + client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + @pytest.mark.respx(base_url=base_url) + def test_raw_response(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + @pytest.mark.respx(base_url=base_url) + def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock( + return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') + ) + + response = self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + def test_copy(self) -> None: + copied = self.client.copy() + assert id(copied) != id(self.client) + + copied = self.client.copy(api_key="another My API Key") + assert copied.api_key == "another My API Key" + assert self.client.api_key == "My API Key" + + def test_copy_default_options(self) -> None: + # options that have a default are overridden correctly + copied = self.client.copy(max_retries=7) + assert copied.max_retries == 7 + assert self.client.max_retries == 2 + + copied2 = copied.copy(max_retries=6) + assert copied2.max_retries == 6 + assert copied.max_retries == 7 + + # timeout + assert isinstance(self.client.timeout, httpx.Timeout) + copied = self.client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(self.client.timeout, httpx.Timeout) + + def test_copy_default_headers(self) -> None: + client = BrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} + ) + assert client.default_headers["X-Foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert copied.default_headers["X-Foo"] == "bar" + + # merges already given headers + copied = client.copy(default_headers={"X-Bar": "stainless"}) + assert copied.default_headers["X-Foo"] == "bar" + assert copied.default_headers["X-Bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_headers={"X-Foo": "stainless"}) + assert copied.default_headers["X-Foo"] == "stainless" + + # set_default_headers + + # completely overrides already set values + copied = client.copy(set_default_headers={}) + assert copied.default_headers.get("X-Foo") is None + + copied = client.copy(set_default_headers={"X-Bar": "Robert"}) + assert copied.default_headers["X-Bar"] == "Robert" + + with pytest.raises( + ValueError, + match="`default_headers` and `set_default_headers` arguments are mutually exclusive", + ): + client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + + def test_copy_default_query(self) -> None: + client = BrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"} + ) + assert _get_params(client)["foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert _get_params(copied)["foo"] == "bar" + + # merges already given params + copied = client.copy(default_query={"bar": "stainless"}) + params = _get_params(copied) + assert params["foo"] == "bar" + assert params["bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_query={"foo": "stainless"}) + assert _get_params(copied)["foo"] == "stainless" + + # set_default_query + + # completely overrides already set values + copied = client.copy(set_default_query={}) + assert _get_params(copied) == {} + + copied = client.copy(set_default_query={"bar": "Robert"}) + assert _get_params(copied)["bar"] == "Robert" + + with pytest.raises( + ValueError, + # TODO: update + match="`default_query` and `set_default_query` arguments are mutually exclusive", + ): + client.copy(set_default_query={}, default_query={"foo": "Bar"}) + + def test_copy_signature(self) -> None: + # ensure the same parameters that can be passed to the client are defined in the `.copy()` method + init_signature = inspect.signature( + # mypy doesn't like that we access the `__init__` property. + self.client.__init__, # type: ignore[misc] + ) + copy_signature = inspect.signature(self.client.copy) + exclude_params = {"transport", "proxies", "_strict_response_validation"} + + for name in init_signature.parameters.keys(): + if name in exclude_params: + continue + + copy_param = copy_signature.parameters.get(name) + assert copy_param is not None, f"copy() signature is missing the {name} param" + + def test_copy_build_request(self) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client = self.client.copy() + client._build_request(options) + + # ensure that the machinery is warmed up before tracing starts. + build_request(options) + gc.collect() + + tracemalloc.start(1000) + + snapshot_before = tracemalloc.take_snapshot() + + ITERATIONS = 10 + for _ in range(ITERATIONS): + build_request(options) + + gc.collect() + snapshot_after = tracemalloc.take_snapshot() + + tracemalloc.stop() + + def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: + if diff.count == 0: + # Avoid false positives by considering only leaks (i.e. allocations that persist). + return + + if diff.count % ITERATIONS != 0: + # Avoid false positives by considering only leaks that appear per iteration. + return + + for frame in diff.traceback: + if any( + frame.filename.endswith(fragment) + for fragment in [ + # to_raw_response_wrapper leaks through the @functools.wraps() decorator. + # + # removing the decorator fixes the leak for reasons we don't understand. + "brand/dev/_legacy_response.py", + "brand/dev/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "brand/dev/_compat.py", + # Standard library leaks we don't care about. + "/logging/__init__.py", + ] + ): + return + + leaks.append(diff) + + leaks: list[tracemalloc.StatisticDiff] = [] + for diff in snapshot_after.compare_to(snapshot_before, "traceback"): + add_leak(leaks, diff) + if leaks: + for leak in leaks: + print("MEMORY LEAK:", leak) + for frame in leak.traceback: + print(frame) + raise AssertionError() + + def test_request_timeout(self) -> None: + request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + request = self.client._build_request( + FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) + ) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(100.0) + + def test_client_timeout_option(self) -> None: + client = BrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0) + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(0) + + def test_http_client_timeout_option(self) -> None: + # custom timeout given to the httpx client should be used + with httpx.Client(timeout=None) as http_client: + client = BrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(None) + + # no timeout given to the httpx client should not use the httpx default + with httpx.Client() as http_client: + client = BrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + # explicitly passing the default timeout currently results in it being ignored + with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: + client = BrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT # our default + + async def test_invalid_http_client(self) -> None: + with pytest.raises(TypeError, match="Invalid `http_client` arg"): + async with httpx.AsyncClient() as http_client: + BrandDev( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=cast(Any, http_client), + ) + + def test_default_headers_option(self) -> None: + client = BrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "bar" + assert request.headers.get("x-stainless-lang") == "python" + + client2 = BrandDev( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + default_headers={ + "X-Foo": "stainless", + "X-Stainless-Lang": "my-overriding-header", + }, + ) + request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "stainless" + assert request.headers.get("x-stainless-lang") == "my-overriding-header" + + def test_validate_headers(self) -> None: + client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("Authorization") == f"Bearer {api_key}" + + with pytest.raises(BrandDevError): + with update_env(**{"BRAND_DEV_API_KEY": Omit()}): + client2 = BrandDev(base_url=base_url, api_key=None, _strict_response_validation=True) + _ = client2 + + def test_default_query_option(self) -> None: + client = BrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"} + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + assert dict(url.params) == {"query_param": "bar"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo", + params={"foo": "baz", "query_param": "overridden"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} + + def test_request_extra_json(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": False} + + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"baz": False} + + # `extra_json` takes priority over `json_data` when keys clash + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar", "baz": True}, + extra_json={"baz": None}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": None} + + def test_request_extra_headers(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options(extra_headers={"X-Foo": "Foo"}), + ), + ) + assert request.headers.get("X-Foo") == "Foo" + + # `extra_headers` takes priority over `default_headers` when keys clash + request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_headers={"X-Bar": "false"}, + ), + ), + ) + assert request.headers.get("X-Bar") == "false" + + def test_request_extra_query(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_query={"my_query_param": "Foo"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"my_query_param": "Foo"} + + # if both `query` and `extra_query` are given, they are merged + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"bar": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"bar": "1", "foo": "2"} + + # `extra_query` takes priority over `query` when keys clash + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"foo": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"foo": "2"} + + def test_multipart_repeating_array(self, client: BrandDev) -> None: + request = client._build_request( + FinalRequestOptions.construct( + method="get", + url="/foo", + headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, + json_data={"array": ["foo", "bar"]}, + files=[("foo.txt", b"hello world")], + ) + ) + + assert request.read().split(b"\r\n") == [ + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"foo", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"bar", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="foo.txt"; filename="upload"', + b"Content-Type: application/octet-stream", + b"", + b"hello world", + b"--6b7ba517decee4a450543ea6ae821c82--", + b"", + ] + + @pytest.mark.respx(base_url=base_url) + def test_basic_union_response(self, respx_mock: MockRouter) -> None: + class Model1(BaseModel): + name: str + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + @pytest.mark.respx(base_url=base_url) + def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + """Union of objects with the same field name using a different type""" + + class Model1(BaseModel): + foo: int + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) + + response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model1) + assert response.foo == 1 + + @pytest.mark.respx(base_url=base_url) + def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + """ + Response that sets Content-Type to something other than application/json but returns json data + """ + + class Model(BaseModel): + foo: int + + respx_mock.get("/foo").mock( + return_value=httpx.Response( + 200, + content=json.dumps({"foo": 2}), + headers={"Content-Type": "application/text"}, + ) + ) + + response = self.client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 + + def test_base_url_setter(self) -> None: + client = BrandDev(base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True) + assert client.base_url == "https://example.com/from_init/" + + client.base_url = "https://example.com/from_setter" # type: ignore[assignment] + + assert client.base_url == "https://example.com/from_setter/" + + def test_base_url_env(self) -> None: + with update_env(BRAND_DEV_BASE_URL="http://localhost:5000/from/env"): + client = BrandDev(api_key=api_key, _strict_response_validation=True) + assert client.base_url == "http://localhost:5000/from/env/" + + @pytest.mark.parametrize( + "client", + [ + BrandDev(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + BrandDev( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_trailing_slash(self, client: BrandDev) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + @pytest.mark.parametrize( + "client", + [ + BrandDev(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + BrandDev( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_no_trailing_slash(self, client: BrandDev) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + @pytest.mark.parametrize( + "client", + [ + BrandDev(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + BrandDev( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_absolute_request_url(self, client: BrandDev) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + + def test_copied_client_does_not_close_http(self) -> None: + client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not client.is_closed() + + copied = client.copy() + assert copied is not client + + del copied + + assert not client.is_closed() + + def test_client_context_manager(self) -> None: + client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + with client as c2: + assert c2 is client + assert not c2.is_closed() + assert not client.is_closed() + assert client.is_closed() + + @pytest.mark.respx(base_url=base_url) + def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + self.client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + + def test_client_max_retries_validation(self) -> None: + with pytest.raises(TypeError, match=r"max_retries cannot be None"): + BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None)) + + @pytest.mark.respx(base_url=base_url) + def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) + + strict_client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + with pytest.raises(APIResponseValidationError): + strict_client.get("/foo", cast_to=Model) + + client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=False) + + response = client.get("/foo", cast_to=Model) + assert isinstance(response, str) # type: ignore[unreachable] + + @pytest.mark.parametrize( + "remaining_retries,retry_after,timeout", + [ + [3, "20", 20], + [3, "0", 0.5], + [3, "-10", 0.5], + [3, "60", 60], + [3, "61", 0.5], + [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], + [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], + [3, "99999999999999999999999999999999999", 0.5], + [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "", 0.5], + [2, "", 0.5 * 2.0], + [1, "", 0.5 * 4.0], + [-1100, "", 8], # test large number potentially overflowing + ], + ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) + def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: + client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + headers = httpx.Headers({"retry-after": retry_after}) + options = FinalRequestOptions(method="get", url="/foo", max_retries=3) + calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + + @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + respx_mock.get("/brand/retrieve").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + self.client.get( + "/brand/retrieve", cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}} + ) + + assert _get_open_connections(self.client) == 0 + + @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + respx_mock.get("/brand/retrieve").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + self.client.get( + "/brand/retrieve", cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}} + ) + + assert _get_open_connections(self.client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("failure_mode", ["status", "exception"]) + def test_retries_taken( + self, + client: BrandDev, + failures_before_success: int, + failure_mode: Literal["status", "exception"], + respx_mock: MockRouter, + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + if failure_mode == "exception": + raise RuntimeError("oops") + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/brand/retrieve").mock(side_effect=retry_handler) + + response = client.brand.with_raw_response.retrieve(domain="domain") + + assert response.retries_taken == failures_before_success + assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_omit_retry_count_header( + self, client: BrandDev, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/brand/retrieve").mock(side_effect=retry_handler) + + response = client.brand.with_raw_response.retrieve( + domain="domain", extra_headers={"x-stainless-retry-count": Omit()} + ) + + assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_overwrite_retry_count_header( + self, client: BrandDev, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/brand/retrieve").mock(side_effect=retry_handler) + + response = client.brand.with_raw_response.retrieve( + domain="domain", extra_headers={"x-stainless-retry-count": "42"} + ) + + assert response.http_request.headers.get("x-stainless-retry-count") == "42" + + +class TestAsyncBrandDev: + client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_raw_response(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + respx_mock.post("/foo").mock( + return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') + ) + + response = await self.client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + def test_copy(self) -> None: + copied = self.client.copy() + assert id(copied) != id(self.client) + + copied = self.client.copy(api_key="another My API Key") + assert copied.api_key == "another My API Key" + assert self.client.api_key == "My API Key" + + def test_copy_default_options(self) -> None: + # options that have a default are overridden correctly + copied = self.client.copy(max_retries=7) + assert copied.max_retries == 7 + assert self.client.max_retries == 2 + + copied2 = copied.copy(max_retries=6) + assert copied2.max_retries == 6 + assert copied.max_retries == 7 + + # timeout + assert isinstance(self.client.timeout, httpx.Timeout) + copied = self.client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(self.client.timeout, httpx.Timeout) + + def test_copy_default_headers(self) -> None: + client = AsyncBrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} + ) + assert client.default_headers["X-Foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert copied.default_headers["X-Foo"] == "bar" + + # merges already given headers + copied = client.copy(default_headers={"X-Bar": "stainless"}) + assert copied.default_headers["X-Foo"] == "bar" + assert copied.default_headers["X-Bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_headers={"X-Foo": "stainless"}) + assert copied.default_headers["X-Foo"] == "stainless" + + # set_default_headers + + # completely overrides already set values + copied = client.copy(set_default_headers={}) + assert copied.default_headers.get("X-Foo") is None + + copied = client.copy(set_default_headers={"X-Bar": "Robert"}) + assert copied.default_headers["X-Bar"] == "Robert" + + with pytest.raises( + ValueError, + match="`default_headers` and `set_default_headers` arguments are mutually exclusive", + ): + client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + + def test_copy_default_query(self) -> None: + client = AsyncBrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"} + ) + assert _get_params(client)["foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert _get_params(copied)["foo"] == "bar" + + # merges already given params + copied = client.copy(default_query={"bar": "stainless"}) + params = _get_params(copied) + assert params["foo"] == "bar" + assert params["bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_query={"foo": "stainless"}) + assert _get_params(copied)["foo"] == "stainless" + + # set_default_query + + # completely overrides already set values + copied = client.copy(set_default_query={}) + assert _get_params(copied) == {} + + copied = client.copy(set_default_query={"bar": "Robert"}) + assert _get_params(copied)["bar"] == "Robert" + + with pytest.raises( + ValueError, + # TODO: update + match="`default_query` and `set_default_query` arguments are mutually exclusive", + ): + client.copy(set_default_query={}, default_query={"foo": "Bar"}) + + def test_copy_signature(self) -> None: + # ensure the same parameters that can be passed to the client are defined in the `.copy()` method + init_signature = inspect.signature( + # mypy doesn't like that we access the `__init__` property. + self.client.__init__, # type: ignore[misc] + ) + copy_signature = inspect.signature(self.client.copy) + exclude_params = {"transport", "proxies", "_strict_response_validation"} + + for name in init_signature.parameters.keys(): + if name in exclude_params: + continue + + copy_param = copy_signature.parameters.get(name) + assert copy_param is not None, f"copy() signature is missing the {name} param" + + def test_copy_build_request(self) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client = self.client.copy() + client._build_request(options) + + # ensure that the machinery is warmed up before tracing starts. + build_request(options) + gc.collect() + + tracemalloc.start(1000) + + snapshot_before = tracemalloc.take_snapshot() + + ITERATIONS = 10 + for _ in range(ITERATIONS): + build_request(options) + + gc.collect() + snapshot_after = tracemalloc.take_snapshot() + + tracemalloc.stop() + + def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: + if diff.count == 0: + # Avoid false positives by considering only leaks (i.e. allocations that persist). + return + + if diff.count % ITERATIONS != 0: + # Avoid false positives by considering only leaks that appear per iteration. + return + + for frame in diff.traceback: + if any( + frame.filename.endswith(fragment) + for fragment in [ + # to_raw_response_wrapper leaks through the @functools.wraps() decorator. + # + # removing the decorator fixes the leak for reasons we don't understand. + "brand/dev/_legacy_response.py", + "brand/dev/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "brand/dev/_compat.py", + # Standard library leaks we don't care about. + "/logging/__init__.py", + ] + ): + return + + leaks.append(diff) + + leaks: list[tracemalloc.StatisticDiff] = [] + for diff in snapshot_after.compare_to(snapshot_before, "traceback"): + add_leak(leaks, diff) + if leaks: + for leak in leaks: + print("MEMORY LEAK:", leak) + for frame in leak.traceback: + print(frame) + raise AssertionError() + + async def test_request_timeout(self) -> None: + request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + request = self.client._build_request( + FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) + ) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(100.0) + + async def test_client_timeout_option(self) -> None: + client = AsyncBrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0) + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(0) + + async def test_http_client_timeout_option(self) -> None: + # custom timeout given to the httpx client should be used + async with httpx.AsyncClient(timeout=None) as http_client: + client = AsyncBrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(None) + + # no timeout given to the httpx client should not use the httpx default + async with httpx.AsyncClient() as http_client: + client = AsyncBrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + # explicitly passing the default timeout currently results in it being ignored + async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: + client = AsyncBrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT # our default + + def test_invalid_http_client(self) -> None: + with pytest.raises(TypeError, match="Invalid `http_client` arg"): + with httpx.Client() as http_client: + AsyncBrandDev( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=cast(Any, http_client), + ) + + def test_default_headers_option(self) -> None: + client = AsyncBrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "bar" + assert request.headers.get("x-stainless-lang") == "python" + + client2 = AsyncBrandDev( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + default_headers={ + "X-Foo": "stainless", + "X-Stainless-Lang": "my-overriding-header", + }, + ) + request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "stainless" + assert request.headers.get("x-stainless-lang") == "my-overriding-header" + + def test_validate_headers(self) -> None: + client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("Authorization") == f"Bearer {api_key}" + + with pytest.raises(BrandDevError): + with update_env(**{"BRAND_DEV_API_KEY": Omit()}): + client2 = AsyncBrandDev(base_url=base_url, api_key=None, _strict_response_validation=True) + _ = client2 + + def test_default_query_option(self) -> None: + client = AsyncBrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"} + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + assert dict(url.params) == {"query_param": "bar"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo", + params={"foo": "baz", "query_param": "overridden"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} + + def test_request_extra_json(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": False} + + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"baz": False} + + # `extra_json` takes priority over `json_data` when keys clash + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar", "baz": True}, + extra_json={"baz": None}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": None} + + def test_request_extra_headers(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options(extra_headers={"X-Foo": "Foo"}), + ), + ) + assert request.headers.get("X-Foo") == "Foo" + + # `extra_headers` takes priority over `default_headers` when keys clash + request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_headers={"X-Bar": "false"}, + ), + ), + ) + assert request.headers.get("X-Bar") == "false" + + def test_request_extra_query(self) -> None: + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_query={"my_query_param": "Foo"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"my_query_param": "Foo"} + + # if both `query` and `extra_query` are given, they are merged + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"bar": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"bar": "1", "foo": "2"} + + # `extra_query` takes priority over `query` when keys clash + request = self.client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"foo": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"foo": "2"} + + def test_multipart_repeating_array(self, async_client: AsyncBrandDev) -> None: + request = async_client._build_request( + FinalRequestOptions.construct( + method="get", + url="/foo", + headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, + json_data={"array": ["foo", "bar"]}, + files=[("foo.txt", b"hello world")], + ) + ) + + assert request.read().split(b"\r\n") == [ + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"foo", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"bar", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="foo.txt"; filename="upload"', + b"Content-Type: application/octet-stream", + b"", + b"hello world", + b"--6b7ba517decee4a450543ea6ae821c82--", + b"", + ] + + @pytest.mark.respx(base_url=base_url) + async def test_basic_union_response(self, respx_mock: MockRouter) -> None: + class Model1(BaseModel): + name: str + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + @pytest.mark.respx(base_url=base_url) + async def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + """Union of objects with the same field name using a different type""" + + class Model1(BaseModel): + foo: int + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) + + response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model1) + assert response.foo == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + """ + Response that sets Content-Type to something other than application/json but returns json data + """ + + class Model(BaseModel): + foo: int + + respx_mock.get("/foo").mock( + return_value=httpx.Response( + 200, + content=json.dumps({"foo": 2}), + headers={"Content-Type": "application/text"}, + ) + ) + + response = await self.client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 + + def test_base_url_setter(self) -> None: + client = AsyncBrandDev( + base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True + ) + assert client.base_url == "https://example.com/from_init/" + + client.base_url = "https://example.com/from_setter" # type: ignore[assignment] + + assert client.base_url == "https://example.com/from_setter/" + + def test_base_url_env(self) -> None: + with update_env(BRAND_DEV_BASE_URL="http://localhost:5000/from/env"): + client = AsyncBrandDev(api_key=api_key, _strict_response_validation=True) + assert client.base_url == "http://localhost:5000/from/env/" + + @pytest.mark.parametrize( + "client", + [ + AsyncBrandDev( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncBrandDev( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_trailing_slash(self, client: AsyncBrandDev) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + @pytest.mark.parametrize( + "client", + [ + AsyncBrandDev( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncBrandDev( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_no_trailing_slash(self, client: AsyncBrandDev) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + @pytest.mark.parametrize( + "client", + [ + AsyncBrandDev( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncBrandDev( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_absolute_request_url(self, client: AsyncBrandDev) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + + async def test_copied_client_does_not_close_http(self) -> None: + client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not client.is_closed() + + copied = client.copy() + assert copied is not client + + del copied + + await asyncio.sleep(0.2) + assert not client.is_closed() + + async def test_client_context_manager(self) -> None: + client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + async with client as c2: + assert c2 is client + assert not c2.is_closed() + assert not client.is_closed() + assert client.is_closed() + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + await self.client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + + async def test_client_max_retries_validation(self) -> None: + with pytest.raises(TypeError, match=r"max_retries cannot be None"): + AsyncBrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None) + ) + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) + + strict_client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + with pytest.raises(APIResponseValidationError): + await strict_client.get("/foo", cast_to=Model) + + client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=False) + + response = await client.get("/foo", cast_to=Model) + assert isinstance(response, str) # type: ignore[unreachable] + + @pytest.mark.parametrize( + "remaining_retries,retry_after,timeout", + [ + [3, "20", 20], + [3, "0", 0.5], + [3, "-10", 0.5], + [3, "60", 60], + [3, "61", 0.5], + [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], + [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], + [3, "99999999999999999999999999999999999", 0.5], + [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "", 0.5], + [2, "", 0.5 * 2.0], + [1, "", 0.5 * 4.0], + [-1100, "", 8], # test large number potentially overflowing + ], + ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) + @pytest.mark.asyncio + async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: + client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + headers = httpx.Headers({"retry-after": retry_after}) + options = FinalRequestOptions(method="get", url="/foo", max_retries=3) + calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + + @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + respx_mock.get("/brand/retrieve").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + await self.client.get( + "/brand/retrieve", cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}} + ) + + assert _get_open_connections(self.client) == 0 + + @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + respx_mock.get("/brand/retrieve").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + await self.client.get( + "/brand/retrieve", cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}} + ) + + assert _get_open_connections(self.client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + @pytest.mark.parametrize("failure_mode", ["status", "exception"]) + async def test_retries_taken( + self, + async_client: AsyncBrandDev, + failures_before_success: int, + failure_mode: Literal["status", "exception"], + respx_mock: MockRouter, + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + if failure_mode == "exception": + raise RuntimeError("oops") + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/brand/retrieve").mock(side_effect=retry_handler) + + response = await client.brand.with_raw_response.retrieve(domain="domain") + + assert response.retries_taken == failures_before_success + assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_omit_retry_count_header( + self, async_client: AsyncBrandDev, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/brand/retrieve").mock(side_effect=retry_handler) + + response = await client.brand.with_raw_response.retrieve( + domain="domain", extra_headers={"x-stainless-retry-count": Omit()} + ) + + assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_overwrite_retry_count_header( + self, async_client: AsyncBrandDev, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/brand/retrieve").mock(side_effect=retry_handler) + + response = await client.brand.with_raw_response.retrieve( + domain="domain", extra_headers={"x-stainless-retry-count": "42"} + ) + + assert response.http_request.headers.get("x-stainless-retry-count") == "42" + + def test_get_platform(self) -> None: + # A previous implementation of asyncify could leave threads unterminated when + # used with nest_asyncio. + # + # Since nest_asyncio.apply() is global and cannot be un-applied, this + # test is run in a separate process to avoid affecting other tests. + test_code = dedent(""" + import asyncio + import nest_asyncio + import threading + + from brand.dev._utils import asyncify + from brand.dev._base_client import get_platform + + async def test_main() -> None: + result = await asyncify(get_platform)() + print(result) + for thread in threading.enumerate(): + print(thread.name) + + nest_asyncio.apply() + asyncio.run(test_main()) + """) + with subprocess.Popen( + [sys.executable, "-c", test_code], + text=True, + ) as process: + timeout = 10 # seconds + + start_time = time.monotonic() + while True: + return_code = process.poll() + if return_code is not None: + if return_code != 0: + raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") + + # success + break + + if time.monotonic() - start_time > timeout: + process.kill() + raise AssertionError("calling get_platform using asyncify resulted in a hung process") + + time.sleep(0.1) diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py new file mode 100644 index 0000000..937bf72 --- /dev/null +++ b/tests/test_deepcopy.py @@ -0,0 +1,58 @@ +from brand.dev._utils import deepcopy_minimal + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert id(obj1) != id(obj2) + + +def test_simple_dict() -> None: + obj1 = {"foo": "bar"} + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + + +def test_nested_dict() -> None: + obj1 = {"foo": {"bar": True}} + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + assert_different_identities(obj1["foo"], obj2["foo"]) + + +def test_complex_nested_dict() -> None: + obj1 = {"foo": {"bar": [{"hello": "world"}]}} + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + assert_different_identities(obj1["foo"], obj2["foo"]) + assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) + assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) + + +def test_simple_list() -> None: + obj1 = ["a", "b", "c"] + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + + +def test_nested_list() -> None: + obj1 = ["a", [1, 2, 3]] + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + assert_different_identities(obj1[1], obj2[1]) + + +class MyObject: ... + + +def test_ignores_other_types() -> None: + # custom classes + my_obj = MyObject() + obj1 = {"foo": my_obj} + obj2 = deepcopy_minimal(obj1) + assert_different_identities(obj1, obj2) + assert obj1["foo"] is my_obj + + # tuples + obj3 = ("a", "b") + obj4 = deepcopy_minimal(obj3) + assert obj3 is obj4 diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py new file mode 100644 index 0000000..72d0a8b --- /dev/null +++ b/tests/test_extract_files.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Sequence + +import pytest + +from brand.dev._types import FileTypes +from brand.dev._utils import extract_files + + +def test_removes_files_from_input() -> None: + query = {"foo": "bar"} + assert extract_files(query, paths=[]) == [] + assert query == {"foo": "bar"} + + query2 = {"foo": b"Bar", "hello": "world"} + assert extract_files(query2, paths=[["foo"]]) == [("foo", b"Bar")] + assert query2 == {"hello": "world"} + + query3 = {"foo": {"foo": {"bar": b"Bar"}}, "hello": "world"} + assert extract_files(query3, paths=[["foo", "foo", "bar"]]) == [("foo[foo][bar]", b"Bar")] + assert query3 == {"foo": {"foo": {}}, "hello": "world"} + + query4 = {"foo": {"bar": b"Bar", "baz": "foo"}, "hello": "world"} + assert extract_files(query4, paths=[["foo", "bar"]]) == [("foo[bar]", b"Bar")] + assert query4 == {"hello": "world", "foo": {"baz": "foo"}} + + +def test_multiple_files() -> None: + query = {"documents": [{"file": b"My first file"}, {"file": b"My second file"}]} + assert extract_files(query, paths=[["documents", "", "file"]]) == [ + ("documents[][file]", b"My first file"), + ("documents[][file]", b"My second file"), + ] + assert query == {"documents": [{}, {}]} + + +@pytest.mark.parametrize( + "query,paths,expected", + [ + [ + {"foo": {"bar": "baz"}}, + [["foo", "", "bar"]], + [], + ], + [ + {"foo": ["bar", "baz"]}, + [["foo", "bar"]], + [], + ], + [ + {"foo": {"bar": "baz"}}, + [["foo", "foo"]], + [], + ], + ], + ids=["dict expecting array", "array expecting dict", "unknown keys"], +) +def test_ignores_incorrect_paths( + query: dict[str, object], + paths: Sequence[Sequence[str]], + expected: list[tuple[str, FileTypes]], +) -> None: + assert extract_files(query, paths=paths) == expected diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..9af98b8 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,51 @@ +from pathlib import Path + +import anyio +import pytest +from dirty_equals import IsDict, IsList, IsBytes, IsTuple + +from brand.dev._files import to_httpx_files, async_to_httpx_files + +readme_path = Path(__file__).parent.parent.joinpath("README.md") + + +def test_pathlib_includes_file_name() -> None: + result = to_httpx_files({"file": readme_path}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +def test_tuple_input() -> None: + result = to_httpx_files([("file", readme_path)]) + print(result) + assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) + + +@pytest.mark.asyncio +async def test_async_pathlib_includes_file_name() -> None: + result = await async_to_httpx_files({"file": readme_path}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +@pytest.mark.asyncio +async def test_async_supports_anyio_path() -> None: + result = await async_to_httpx_files({"file": anyio.Path(readme_path)}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +@pytest.mark.asyncio +async def test_async_tuple_input() -> None: + result = await async_to_httpx_files([("file", readme_path)]) + print(result) + assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) + + +def test_string_not_allowed() -> None: + with pytest.raises(TypeError, match="Expected file types input to be a FileContent type or to be a tuple"): + to_httpx_files( + { + "file": "foo", # type: ignore + } + ) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..3e4688d --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,891 @@ +import json +from typing import Any, Dict, List, Union, Optional, cast +from datetime import datetime, timezone +from typing_extensions import Literal, Annotated, TypeAliasType + +import pytest +import pydantic +from pydantic import Field + +from brand.dev._utils import PropertyInfo +from brand.dev._compat import PYDANTIC_V2, parse_obj, model_dump, model_json +from brand.dev._models import BaseModel, construct_type + + +class BasicModel(BaseModel): + foo: str + + +@pytest.mark.parametrize("value", ["hello", 1], ids=["correct type", "mismatched"]) +def test_basic(value: object) -> None: + m = BasicModel.construct(foo=value) + assert m.foo == value + + +def test_directly_nested_model() -> None: + class NestedModel(BaseModel): + nested: BasicModel + + m = NestedModel.construct(nested={"foo": "Foo!"}) + assert m.nested.foo == "Foo!" + + # mismatched types + m = NestedModel.construct(nested="hello!") + assert cast(Any, m.nested) == "hello!" + + +def test_optional_nested_model() -> None: + class NestedModel(BaseModel): + nested: Optional[BasicModel] + + m1 = NestedModel.construct(nested=None) + assert m1.nested is None + + m2 = NestedModel.construct(nested={"foo": "bar"}) + assert m2.nested is not None + assert m2.nested.foo == "bar" + + # mismatched types + m3 = NestedModel.construct(nested={"foo"}) + assert isinstance(cast(Any, m3.nested), set) + assert cast(Any, m3.nested) == {"foo"} + + +def test_list_nested_model() -> None: + class NestedModel(BaseModel): + nested: List[BasicModel] + + m = NestedModel.construct(nested=[{"foo": "bar"}, {"foo": "2"}]) + assert m.nested is not None + assert isinstance(m.nested, list) + assert len(m.nested) == 2 + assert m.nested[0].foo == "bar" + assert m.nested[1].foo == "2" + + # mismatched types + m = NestedModel.construct(nested=True) + assert cast(Any, m.nested) is True + + m = NestedModel.construct(nested=[False]) + assert cast(Any, m.nested) == [False] + + +def test_optional_list_nested_model() -> None: + class NestedModel(BaseModel): + nested: Optional[List[BasicModel]] + + m1 = NestedModel.construct(nested=[{"foo": "bar"}, {"foo": "2"}]) + assert m1.nested is not None + assert isinstance(m1.nested, list) + assert len(m1.nested) == 2 + assert m1.nested[0].foo == "bar" + assert m1.nested[1].foo == "2" + + m2 = NestedModel.construct(nested=None) + assert m2.nested is None + + # mismatched types + m3 = NestedModel.construct(nested={1}) + assert cast(Any, m3.nested) == {1} + + m4 = NestedModel.construct(nested=[False]) + assert cast(Any, m4.nested) == [False] + + +def test_list_optional_items_nested_model() -> None: + class NestedModel(BaseModel): + nested: List[Optional[BasicModel]] + + m = NestedModel.construct(nested=[None, {"foo": "bar"}]) + assert m.nested is not None + assert isinstance(m.nested, list) + assert len(m.nested) == 2 + assert m.nested[0] is None + assert m.nested[1] is not None + assert m.nested[1].foo == "bar" + + # mismatched types + m3 = NestedModel.construct(nested="foo") + assert cast(Any, m3.nested) == "foo" + + m4 = NestedModel.construct(nested=[False]) + assert cast(Any, m4.nested) == [False] + + +def test_list_mismatched_type() -> None: + class NestedModel(BaseModel): + nested: List[str] + + m = NestedModel.construct(nested=False) + assert cast(Any, m.nested) is False + + +def test_raw_dictionary() -> None: + class NestedModel(BaseModel): + nested: Dict[str, str] + + m = NestedModel.construct(nested={"hello": "world"}) + assert m.nested == {"hello": "world"} + + # mismatched types + m = NestedModel.construct(nested=False) + assert cast(Any, m.nested) is False + + +def test_nested_dictionary_model() -> None: + class NestedModel(BaseModel): + nested: Dict[str, BasicModel] + + m = NestedModel.construct(nested={"hello": {"foo": "bar"}}) + assert isinstance(m.nested, dict) + assert m.nested["hello"].foo == "bar" + + # mismatched types + m = NestedModel.construct(nested={"hello": False}) + assert cast(Any, m.nested["hello"]) is False + + +def test_unknown_fields() -> None: + m1 = BasicModel.construct(foo="foo", unknown=1) + assert m1.foo == "foo" + assert cast(Any, m1).unknown == 1 + + m2 = BasicModel.construct(foo="foo", unknown={"foo_bar": True}) + assert m2.foo == "foo" + assert cast(Any, m2).unknown == {"foo_bar": True} + + assert model_dump(m2) == {"foo": "foo", "unknown": {"foo_bar": True}} + + +def test_strict_validation_unknown_fields() -> None: + class Model(BaseModel): + foo: str + + model = parse_obj(Model, dict(foo="hello!", user="Robert")) + assert model.foo == "hello!" + assert cast(Any, model).user == "Robert" + + assert model_dump(model) == {"foo": "hello!", "user": "Robert"} + + +def test_aliases() -> None: + class Model(BaseModel): + my_field: int = Field(alias="myField") + + m = Model.construct(myField=1) + assert m.my_field == 1 + + # mismatched types + m = Model.construct(myField={"hello": False}) + assert cast(Any, m.my_field) == {"hello": False} + + +def test_repr() -> None: + model = BasicModel(foo="bar") + assert str(model) == "BasicModel(foo='bar')" + assert repr(model) == "BasicModel(foo='bar')" + + +def test_repr_nested_model() -> None: + class Child(BaseModel): + name: str + age: int + + class Parent(BaseModel): + name: str + child: Child + + model = Parent(name="Robert", child=Child(name="Foo", age=5)) + assert str(model) == "Parent(name='Robert', child=Child(name='Foo', age=5))" + assert repr(model) == "Parent(name='Robert', child=Child(name='Foo', age=5))" + + +def test_optional_list() -> None: + class Submodel(BaseModel): + name: str + + class Model(BaseModel): + items: Optional[List[Submodel]] + + m = Model.construct(items=None) + assert m.items is None + + m = Model.construct(items=[]) + assert m.items == [] + + m = Model.construct(items=[{"name": "Robert"}]) + assert m.items is not None + assert len(m.items) == 1 + assert m.items[0].name == "Robert" + + +def test_nested_union_of_models() -> None: + class Submodel1(BaseModel): + bar: bool + + class Submodel2(BaseModel): + thing: str + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2] + + m = Model.construct(foo={"thing": "hello"}) + assert isinstance(m.foo, Submodel2) + assert m.foo.thing == "hello" + + +def test_nested_union_of_mixed_types() -> None: + class Submodel1(BaseModel): + bar: bool + + class Model(BaseModel): + foo: Union[Submodel1, Literal[True], Literal["CARD_HOLDER"]] + + m = Model.construct(foo=True) + assert m.foo is True + + m = Model.construct(foo="CARD_HOLDER") + assert m.foo == "CARD_HOLDER" + + m = Model.construct(foo={"bar": False}) + assert isinstance(m.foo, Submodel1) + assert m.foo.bar is False + + +def test_nested_union_multiple_variants() -> None: + class Submodel1(BaseModel): + bar: bool + + class Submodel2(BaseModel): + thing: str + + class Submodel3(BaseModel): + foo: int + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2, None, Submodel3] + + m = Model.construct(foo={"thing": "hello"}) + assert isinstance(m.foo, Submodel2) + assert m.foo.thing == "hello" + + m = Model.construct(foo=None) + assert m.foo is None + + m = Model.construct() + assert m.foo is None + + m = Model.construct(foo={"foo": "1"}) + assert isinstance(m.foo, Submodel3) + assert m.foo.foo == 1 + + +def test_nested_union_invalid_data() -> None: + class Submodel1(BaseModel): + level: int + + class Submodel2(BaseModel): + name: str + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2] + + m = Model.construct(foo=True) + assert cast(bool, m.foo) is True + + m = Model.construct(foo={"name": 3}) + if PYDANTIC_V2: + assert isinstance(m.foo, Submodel1) + assert m.foo.name == 3 # type: ignore + else: + assert isinstance(m.foo, Submodel2) + assert m.foo.name == "3" + + +def test_list_of_unions() -> None: + class Submodel1(BaseModel): + level: int + + class Submodel2(BaseModel): + name: str + + class Model(BaseModel): + items: List[Union[Submodel1, Submodel2]] + + m = Model.construct(items=[{"level": 1}, {"name": "Robert"}]) + assert len(m.items) == 2 + assert isinstance(m.items[0], Submodel1) + assert m.items[0].level == 1 + assert isinstance(m.items[1], Submodel2) + assert m.items[1].name == "Robert" + + m = Model.construct(items=[{"level": -1}, 156]) + assert len(m.items) == 2 + assert isinstance(m.items[0], Submodel1) + assert m.items[0].level == -1 + assert cast(Any, m.items[1]) == 156 + + +def test_union_of_lists() -> None: + class SubModel1(BaseModel): + level: int + + class SubModel2(BaseModel): + name: str + + class Model(BaseModel): + items: Union[List[SubModel1], List[SubModel2]] + + # with one valid entry + m = Model.construct(items=[{"name": "Robert"}]) + assert len(m.items) == 1 + assert isinstance(m.items[0], SubModel2) + assert m.items[0].name == "Robert" + + # with two entries pointing to different types + m = Model.construct(items=[{"level": 1}, {"name": "Robert"}]) + assert len(m.items) == 2 + assert isinstance(m.items[0], SubModel1) + assert m.items[0].level == 1 + assert isinstance(m.items[1], SubModel1) + assert cast(Any, m.items[1]).name == "Robert" + + # with two entries pointing to *completely* different types + m = Model.construct(items=[{"level": -1}, 156]) + assert len(m.items) == 2 + assert isinstance(m.items[0], SubModel1) + assert m.items[0].level == -1 + assert cast(Any, m.items[1]) == 156 + + +def test_dict_of_union() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + foo: str + + class Model(BaseModel): + data: Dict[str, Union[SubModel1, SubModel2]] + + m = Model.construct(data={"hello": {"name": "there"}, "foo": {"foo": "bar"}}) + assert len(list(m.data.keys())) == 2 + assert isinstance(m.data["hello"], SubModel1) + assert m.data["hello"].name == "there" + assert isinstance(m.data["foo"], SubModel2) + assert m.data["foo"].foo == "bar" + + # TODO: test mismatched type + + +def test_double_nested_union() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + bar: str + + class Model(BaseModel): + data: Dict[str, List[Union[SubModel1, SubModel2]]] + + m = Model.construct(data={"foo": [{"bar": "baz"}, {"name": "Robert"}]}) + assert len(m.data["foo"]) == 2 + + entry1 = m.data["foo"][0] + assert isinstance(entry1, SubModel2) + assert entry1.bar == "baz" + + entry2 = m.data["foo"][1] + assert isinstance(entry2, SubModel1) + assert entry2.name == "Robert" + + # TODO: test mismatched type + + +def test_union_of_dict() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + foo: str + + class Model(BaseModel): + data: Union[Dict[str, SubModel1], Dict[str, SubModel2]] + + m = Model.construct(data={"hello": {"name": "there"}, "foo": {"foo": "bar"}}) + assert len(list(m.data.keys())) == 2 + assert isinstance(m.data["hello"], SubModel1) + assert m.data["hello"].name == "there" + assert isinstance(m.data["foo"], SubModel1) + assert cast(Any, m.data["foo"]).foo == "bar" + + +def test_iso8601_datetime() -> None: + class Model(BaseModel): + created_at: datetime + + expected = datetime(2019, 12, 27, 18, 11, 19, 117000, tzinfo=timezone.utc) + + if PYDANTIC_V2: + expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' + else: + expected_json = '{"created_at": "2019-12-27T18:11:19.117000+00:00"}' + + model = Model.construct(created_at="2019-12-27T18:11:19.117Z") + assert model.created_at == expected + assert model_json(model) == expected_json + + model = parse_obj(Model, dict(created_at="2019-12-27T18:11:19.117Z")) + assert model.created_at == expected + assert model_json(model) == expected_json + + +def test_does_not_coerce_int() -> None: + class Model(BaseModel): + bar: int + + assert Model.construct(bar=1).bar == 1 + assert Model.construct(bar=10.9).bar == 10.9 + assert Model.construct(bar="19").bar == "19" # type: ignore[comparison-overlap] + assert Model.construct(bar=False).bar is False + + +def test_int_to_float_safe_conversion() -> None: + class Model(BaseModel): + float_field: float + + m = Model.construct(float_field=10) + assert m.float_field == 10.0 + assert isinstance(m.float_field, float) + + m = Model.construct(float_field=10.12) + assert m.float_field == 10.12 + assert isinstance(m.float_field, float) + + # number too big + m = Model.construct(float_field=2**53 + 1) + assert m.float_field == 2**53 + 1 + assert isinstance(m.float_field, int) + + +def test_deprecated_alias() -> None: + class Model(BaseModel): + resource_id: str = Field(alias="model_id") + + @property + def model_id(self) -> str: + return self.resource_id + + m = Model.construct(model_id="id") + assert m.model_id == "id" + assert m.resource_id == "id" + assert m.resource_id is m.model_id + + m = parse_obj(Model, {"model_id": "id"}) + assert m.model_id == "id" + assert m.resource_id == "id" + assert m.resource_id is m.model_id + + +def test_omitted_fields() -> None: + class Model(BaseModel): + resource_id: Optional[str] = None + + m = Model.construct() + assert m.resource_id is None + assert "resource_id" not in m.model_fields_set + + m = Model.construct(resource_id=None) + assert m.resource_id is None + assert "resource_id" in m.model_fields_set + + m = Model.construct(resource_id="foo") + assert m.resource_id == "foo" + assert "resource_id" in m.model_fields_set + + +def test_to_dict() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert m.to_dict() == {"FOO": "hello"} + assert m.to_dict(use_api_names=False) == {"foo": "hello"} + + m2 = Model() + assert m2.to_dict() == {} + assert m2.to_dict(exclude_unset=False) == {"FOO": None} + assert m2.to_dict(exclude_unset=False, exclude_none=True) == {} + assert m2.to_dict(exclude_unset=False, exclude_defaults=True) == {} + + m3 = Model(FOO=None) + assert m3.to_dict() == {"FOO": None} + assert m3.to_dict(exclude_none=True) == {} + assert m3.to_dict(exclude_defaults=True) == {} + + class Model2(BaseModel): + created_at: datetime + + time_str = "2024-03-21T11:39:01.275859" + m4 = Model2.construct(created_at=time_str) + assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)} + assert m4.to_dict(mode="json") == {"created_at": time_str} + + if not PYDANTIC_V2: + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.to_dict(warnings=False) + + +def test_forwards_compat_model_dump_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert m.model_dump() == {"foo": "hello"} + assert m.model_dump(include={"bar"}) == {} + assert m.model_dump(exclude={"foo"}) == {} + assert m.model_dump(by_alias=True) == {"FOO": "hello"} + + m2 = Model() + assert m2.model_dump() == {"foo": None} + assert m2.model_dump(exclude_unset=True) == {} + assert m2.model_dump(exclude_none=True) == {} + assert m2.model_dump(exclude_defaults=True) == {} + + m3 = Model(FOO=None) + assert m3.model_dump() == {"foo": None} + assert m3.model_dump(exclude_none=True) == {} + + if not PYDANTIC_V2: + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump(warnings=False) + + +def test_compat_method_no_error_for_warnings() -> None: + class Model(BaseModel): + foo: Optional[str] + + m = Model(foo="hello") + assert isinstance(model_dump(m, warnings=False), dict) + + +def test_to_json() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert json.loads(m.to_json()) == {"FOO": "hello"} + assert json.loads(m.to_json(use_api_names=False)) == {"foo": "hello"} + + if PYDANTIC_V2: + assert m.to_json(indent=None) == '{"FOO":"hello"}' + else: + assert m.to_json(indent=None) == '{"FOO": "hello"}' + + m2 = Model() + assert json.loads(m2.to_json()) == {} + assert json.loads(m2.to_json(exclude_unset=False)) == {"FOO": None} + assert json.loads(m2.to_json(exclude_unset=False, exclude_none=True)) == {} + assert json.loads(m2.to_json(exclude_unset=False, exclude_defaults=True)) == {} + + m3 = Model(FOO=None) + assert json.loads(m3.to_json()) == {"FOO": None} + assert json.loads(m3.to_json(exclude_none=True)) == {} + + if not PYDANTIC_V2: + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.to_json(warnings=False) + + +def test_forwards_compat_model_dump_json_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert json.loads(m.model_dump_json()) == {"foo": "hello"} + assert json.loads(m.model_dump_json(include={"bar"})) == {} + assert json.loads(m.model_dump_json(include={"foo"})) == {"foo": "hello"} + assert json.loads(m.model_dump_json(by_alias=True)) == {"FOO": "hello"} + + assert m.model_dump_json(indent=2) == '{\n "foo": "hello"\n}' + + m2 = Model() + assert json.loads(m2.model_dump_json()) == {"foo": None} + assert json.loads(m2.model_dump_json(exclude_unset=True)) == {} + assert json.loads(m2.model_dump_json(exclude_none=True)) == {} + assert json.loads(m2.model_dump_json(exclude_defaults=True)) == {} + + m3 = Model(FOO=None) + assert json.loads(m3.model_dump_json()) == {"foo": None} + assert json.loads(m3.model_dump_json(exclude_none=True)) == {} + + if not PYDANTIC_V2: + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump_json(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump_json(warnings=False) + + +def test_type_compat() -> None: + # our model type can be assigned to Pydantic's model type + + def takes_pydantic(model: pydantic.BaseModel) -> None: # noqa: ARG001 + ... + + class OurModel(BaseModel): + foo: Optional[str] = None + + takes_pydantic(OurModel()) + + +def test_annotated_types() -> None: + class Model(BaseModel): + value: str + + m = construct_type( + value={"value": "foo"}, + type_=cast(Any, Annotated[Model, "random metadata"]), + ) + assert isinstance(m, Model) + assert m.value == "foo" + + +def test_discriminated_unions_invalid_data() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "a", "data": 100}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, A) + assert m.type == "a" + if PYDANTIC_V2: + assert m.data == 100 # type: ignore[comparison-overlap] + else: + # pydantic v1 automatically converts inputs to strings + # if the expected type is a str + assert m.data == "100" + + +def test_discriminated_unions_unknown_variant() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + m = construct_type( + value={"type": "c", "data": None, "new_thing": "bar"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + + # just chooses the first variant + assert isinstance(m, A) + assert m.type == "c" # type: ignore[comparison-overlap] + assert m.data == None # type: ignore[unreachable] + assert m.new_thing == "bar" + + +def test_discriminated_unions_invalid_data_nested_unions() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + class C(BaseModel): + type: Literal["c"] + + data: bool + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[Union[A, B], C], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "c", "data": "foo"}, + type_=cast(Any, Annotated[Union[Union[A, B], C], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, C) + assert m.type == "c" + assert m.data == "foo" # type: ignore[comparison-overlap] + + +def test_discriminated_unions_with_aliases_invalid_data() -> None: + class A(BaseModel): + foo_type: Literal["a"] = Field(alias="type") + + data: str + + class B(BaseModel): + foo_type: Literal["b"] = Field(alias="type") + + data: int + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="foo_type")]), + ) + assert isinstance(m, B) + assert m.foo_type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "a", "data": 100}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="foo_type")]), + ) + assert isinstance(m, A) + assert m.foo_type == "a" + if PYDANTIC_V2: + assert m.data == 100 # type: ignore[comparison-overlap] + else: + # pydantic v1 automatically converts inputs to strings + # if the expected type is a str + assert m.data == "100" + + +def test_discriminated_unions_overlapping_discriminators_invalid_data() -> None: + class A(BaseModel): + type: Literal["a"] + + data: bool + + class B(BaseModel): + type: Literal["a"] + + data: int + + m = construct_type( + value={"type": "a", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "a" + assert m.data == "foo" # type: ignore[comparison-overlap] + + +def test_discriminated_unions_invalid_data_uses_cache() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + UnionType = cast(Any, Union[A, B]) + + assert not hasattr(UnionType, "__discriminator__") + + m = construct_type( + value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + discriminator = UnionType.__discriminator__ + assert discriminator is not None + + m = construct_type( + value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + # if the discriminator details object stays the same between invocations then + # we hit the cache + assert UnionType.__discriminator__ is discriminator + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +def test_type_alias_type() -> None: + Alias = TypeAliasType("Alias", str) # pyright: ignore + + class Model(BaseModel): + alias: Alias + union: Union[int, Alias] + + m = construct_type(value={"alias": "foo", "union": "bar"}, type_=Model) + assert isinstance(m, Model) + assert isinstance(m.alias, str) + assert m.alias == "foo" + assert isinstance(m.union, str) + assert m.union == "bar" + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +def test_field_named_cls() -> None: + class Model(BaseModel): + cls: str + + m = construct_type(value={"cls": "foo"}, type_=Model) + assert isinstance(m, Model) + assert isinstance(m.cls, str) + + +def test_discriminated_union_case() -> None: + class A(BaseModel): + type: Literal["a"] + + data: bool + + class B(BaseModel): + type: Literal["b"] + + data: List[Union[A, object]] + + class ModelA(BaseModel): + type: Literal["modelA"] + + data: int + + class ModelB(BaseModel): + type: Literal["modelB"] + + required: str + + data: Union[A, B] + + # when constructing ModelA | ModelB, value data doesn't match ModelB exactly - missing `required` + m = construct_type( + value={"type": "modelB", "data": {"type": "a", "data": True}}, + type_=cast(Any, Annotated[Union[ModelA, ModelB], PropertyInfo(discriminator="type")]), + ) + + assert isinstance(m, ModelB) diff --git a/tests/test_qs.py b/tests/test_qs.py new file mode 100644 index 0000000..da6e38f --- /dev/null +++ b/tests/test_qs.py @@ -0,0 +1,78 @@ +from typing import Any, cast +from functools import partial +from urllib.parse import unquote + +import pytest + +from brand.dev._qs import Querystring, stringify + + +def test_empty() -> None: + assert stringify({}) == "" + assert stringify({"a": {}}) == "" + assert stringify({"a": {"b": {"c": {}}}}) == "" + + +def test_basic() -> None: + assert stringify({"a": 1}) == "a=1" + assert stringify({"a": "b"}) == "a=b" + assert stringify({"a": True}) == "a=true" + assert stringify({"a": False}) == "a=false" + assert stringify({"a": 1.23456}) == "a=1.23456" + assert stringify({"a": None}) == "" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_nested_dotted(method: str) -> None: + if method == "class": + serialise = Querystring(nested_format="dots").stringify + else: + serialise = partial(stringify, nested_format="dots") + + assert unquote(serialise({"a": {"b": "c"}})) == "a.b=c" + assert unquote(serialise({"a": {"b": "c", "d": "e", "f": "g"}})) == "a.b=c&a.d=e&a.f=g" + assert unquote(serialise({"a": {"b": {"c": {"d": "e"}}}})) == "a.b.c.d=e" + assert unquote(serialise({"a": {"b": True}})) == "a.b=true" + + +def test_nested_brackets() -> None: + assert unquote(stringify({"a": {"b": "c"}})) == "a[b]=c" + assert unquote(stringify({"a": {"b": "c", "d": "e", "f": "g"}})) == "a[b]=c&a[d]=e&a[f]=g" + assert unquote(stringify({"a": {"b": {"c": {"d": "e"}}}})) == "a[b][c][d]=e" + assert unquote(stringify({"a": {"b": True}})) == "a[b]=true" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_array_comma(method: str) -> None: + if method == "class": + serialise = Querystring(array_format="comma").stringify + else: + serialise = partial(stringify, array_format="comma") + + assert unquote(serialise({"in": ["foo", "bar"]})) == "in=foo,bar" + assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b]=true,false" + assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b]=true,false,true" + + +def test_array_repeat() -> None: + assert unquote(stringify({"in": ["foo", "bar"]})) == "in=foo&in=bar" + assert unquote(stringify({"a": {"b": [True, False]}})) == "a[b]=true&a[b]=false" + assert unquote(stringify({"a": {"b": [True, False, None, True]}})) == "a[b]=true&a[b]=false&a[b]=true" + assert unquote(stringify({"in": ["foo", {"b": {"c": ["d", "e"]}}]})) == "in=foo&in[b][c]=d&in[b][c]=e" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_array_brackets(method: str) -> None: + if method == "class": + serialise = Querystring(array_format="brackets").stringify + else: + serialise = partial(stringify, array_format="brackets") + + assert unquote(serialise({"in": ["foo", "bar"]})) == "in[]=foo&in[]=bar" + assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b][]=true&a[b][]=false" + assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b][]=true&a[b][]=false&a[b][]=true" + + +def test_unknown_array_format() -> None: + with pytest.raises(NotImplementedError, match="Unknown array_format value: foo, choose from comma, repeat"): + stringify({"a": ["foo", "bar"]}, array_format=cast(Any, "foo")) diff --git a/tests/test_required_args.py b/tests/test_required_args.py new file mode 100644 index 0000000..30bb0be --- /dev/null +++ b/tests/test_required_args.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import pytest + +from brand.dev._utils import required_args + + +def test_too_many_positional_params() -> None: + @required_args(["a"]) + def foo(a: str | None = None) -> str | None: + return a + + with pytest.raises(TypeError, match=r"foo\(\) takes 1 argument\(s\) but 2 were given"): + foo("a", "b") # type: ignore + + +def test_positional_param() -> None: + @required_args(["a"]) + def foo(a: str | None = None) -> str | None: + return a + + assert foo("a") == "a" + assert foo(None) is None + assert foo(a="b") == "b" + + with pytest.raises(TypeError, match="Missing required argument: 'a'"): + foo() + + +def test_keyword_only_param() -> None: + @required_args(["a"]) + def foo(*, a: str | None = None) -> str | None: + return a + + assert foo(a="a") == "a" + assert foo(a=None) is None + assert foo(a="b") == "b" + + with pytest.raises(TypeError, match="Missing required argument: 'a'"): + foo() + + +def test_multiple_params() -> None: + @required_args(["a", "b", "c"]) + def foo(a: str = "", *, b: str = "", c: str = "") -> str | None: + return f"{a} {b} {c}" + + assert foo(a="a", b="b", c="c") == "a b c" + + error_message = r"Missing required arguments.*" + + with pytest.raises(TypeError, match=error_message): + foo() + + with pytest.raises(TypeError, match=error_message): + foo(a="a") + + with pytest.raises(TypeError, match=error_message): + foo(b="b") + + with pytest.raises(TypeError, match=error_message): + foo(c="c") + + with pytest.raises(TypeError, match=r"Missing required argument: 'a'"): + foo(b="a", c="c") + + with pytest.raises(TypeError, match=r"Missing required argument: 'b'"): + foo("a", c="c") + + +def test_multiple_variants() -> None: + @required_args(["a"], ["b"]) + def foo(*, a: str | None = None, b: str | None = None) -> str | None: + return a if a is not None else b + + assert foo(a="foo") == "foo" + assert foo(b="bar") == "bar" + assert foo(a=None) is None + assert foo(b=None) is None + + # TODO: this error message could probably be improved + with pytest.raises( + TypeError, + match=r"Missing required arguments; Expected either \('a'\) or \('b'\) arguments to be given", + ): + foo() + + +def test_multiple_params_multiple_variants() -> None: + @required_args(["a", "b"], ["c"]) + def foo(*, a: str | None = None, b: str | None = None, c: str | None = None) -> str | None: + if a is not None: + return a + if b is not None: + return b + return c + + error_message = r"Missing required arguments; Expected either \('a' and 'b'\) or \('c'\) arguments to be given" + + with pytest.raises(TypeError, match=error_message): + foo(a="foo") + + with pytest.raises(TypeError, match=error_message): + foo(b="bar") + + with pytest.raises(TypeError, match=error_message): + foo() + + assert foo(a=None, b="bar") == "bar" + assert foo(c=None) is None + assert foo(c="foo") == "foo" diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 0000000..a2fb191 --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,277 @@ +import json +from typing import Any, List, Union, cast +from typing_extensions import Annotated + +import httpx +import pytest +import pydantic + +from brand.dev import BrandDev, BaseModel, AsyncBrandDev +from brand.dev._response import ( + APIResponse, + BaseAPIResponse, + AsyncAPIResponse, + BinaryAPIResponse, + AsyncBinaryAPIResponse, + extract_response_type, +) +from brand.dev._streaming import Stream +from brand.dev._base_client import FinalRequestOptions + + +class ConcreteBaseAPIResponse(APIResponse[bytes]): ... + + +class ConcreteAPIResponse(APIResponse[List[str]]): ... + + +class ConcreteAsyncAPIResponse(APIResponse[httpx.Response]): ... + + +def test_extract_response_type_direct_classes() -> None: + assert extract_response_type(BaseAPIResponse[str]) == str + assert extract_response_type(APIResponse[str]) == str + assert extract_response_type(AsyncAPIResponse[str]) == str + + +def test_extract_response_type_direct_class_missing_type_arg() -> None: + with pytest.raises( + RuntimeError, + match="Expected type to have a type argument at index 0 but it did not", + ): + extract_response_type(AsyncAPIResponse) + + +def test_extract_response_type_concrete_subclasses() -> None: + assert extract_response_type(ConcreteBaseAPIResponse) == bytes + assert extract_response_type(ConcreteAPIResponse) == List[str] + assert extract_response_type(ConcreteAsyncAPIResponse) == httpx.Response + + +def test_extract_response_type_binary_response() -> None: + assert extract_response_type(BinaryAPIResponse) == bytes + assert extract_response_type(AsyncBinaryAPIResponse) == bytes + + +class PydanticModel(pydantic.BaseModel): ... + + +def test_response_parse_mismatched_basemodel(client: BrandDev) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo"), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + with pytest.raises( + TypeError, + match="Pydantic models must subclass our base model type, e.g. `from brand.dev import BaseModel`", + ): + response.parse(to=PydanticModel) + + +@pytest.mark.asyncio +async def test_async_response_parse_mismatched_basemodel(async_client: AsyncBrandDev) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo"), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + with pytest.raises( + TypeError, + match="Pydantic models must subclass our base model type, e.g. `from brand.dev import BaseModel`", + ): + await response.parse(to=PydanticModel) + + +def test_response_parse_custom_stream(client: BrandDev) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo"), + client=client, + stream=True, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + stream = response.parse(to=Stream[int]) + assert stream._cast_to == int + + +@pytest.mark.asyncio +async def test_async_response_parse_custom_stream(async_client: AsyncBrandDev) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo"), + client=async_client, + stream=True, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + stream = await response.parse(to=Stream[int]) + assert stream._cast_to == int + + +class CustomModel(BaseModel): + foo: str + bar: int + + +def test_response_parse_custom_model(client: BrandDev) -> None: + response = APIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse(to=CustomModel) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +@pytest.mark.asyncio +async def test_async_response_parse_custom_model(async_client: AsyncBrandDev) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse(to=CustomModel) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +def test_response_parse_annotated_type(client: BrandDev) -> None: + response = APIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse( + to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), + ) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +async def test_async_response_parse_annotated_type(async_client: AsyncBrandDev) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse( + to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), + ) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +@pytest.mark.parametrize( + "content, expected", + [ + ("false", False), + ("true", True), + ("False", False), + ("True", True), + ("TrUe", True), + ("FalSe", False), + ], +) +def test_response_parse_bool(client: BrandDev, content: str, expected: bool) -> None: + response = APIResponse( + raw=httpx.Response(200, content=content), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + result = response.parse(to=bool) + assert result is expected + + +@pytest.mark.parametrize( + "content, expected", + [ + ("false", False), + ("true", True), + ("False", False), + ("True", True), + ("TrUe", True), + ("FalSe", False), + ], +) +async def test_async_response_parse_bool(client: AsyncBrandDev, content: str, expected: bool) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=content), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + result = await response.parse(to=bool) + assert result is expected + + +class OtherModel(BaseModel): + a: str + + +@pytest.mark.parametrize("client", [False], indirect=True) # loose validation +def test_response_parse_expect_model_union_non_json_content(client: BrandDev) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse(to=cast(Any, Union[CustomModel, OtherModel])) + assert isinstance(obj, str) + assert obj == "foo" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("async_client", [False], indirect=True) # loose validation +async def test_async_response_parse_expect_model_union_non_json_content(async_client: AsyncBrandDev) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse(to=cast(Any, Union[CustomModel, OtherModel])) + assert isinstance(obj, str) + assert obj == "foo" diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..4748496 --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from typing import Iterator, AsyncIterator + +import httpx +import pytest + +from brand.dev import BrandDev, AsyncBrandDev +from brand.dev._streaming import Stream, AsyncStream, ServerSentEvent + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_basic(sync: bool, client: BrandDev, async_client: AsyncBrandDev) -> None: + def body() -> Iterator[bytes]: + yield b"event: completion\n" + yield b'data: {"foo":true}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_data_missing_event(sync: bool, client: BrandDev, async_client: AsyncBrandDev) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"foo":true}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_event_missing_data(sync: bool, client: BrandDev, async_client: AsyncBrandDev) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.data == "" + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_events(sync: bool, client: BrandDev, async_client: AsyncBrandDev) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"\n" + yield b"event: completion\n" + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.data == "" + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.data == "" + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_events_with_data(sync: bool, client: BrandDev, async_client: AsyncBrandDev) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b'data: {"foo":true}\n' + yield b"\n" + yield b"event: completion\n" + yield b'data: {"bar":false}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.json() == {"bar": False} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_data_lines_with_empty_line(sync: bool, client: BrandDev, async_client: AsyncBrandDev) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"data: {\n" + yield b'data: "foo":\n' + yield b"data: \n" + yield b"data:\n" + yield b"data: true}\n" + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + assert sse.data == '{\n"foo":\n\n\ntrue}' + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_data_json_escaped_double_new_line(sync: bool, client: BrandDev, async_client: AsyncBrandDev) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b'data: {"foo": "my long\\n\\ncontent"}' + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": "my long\n\ncontent"} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_data_lines(sync: bool, client: BrandDev, async_client: AsyncBrandDev) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"data: {\n" + yield b'data: "foo":\n' + yield b"data: true}\n" + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_special_new_line_character( + sync: bool, + client: BrandDev, + async_client: AsyncBrandDev, +) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"content":" culpa"}\n' + yield b"\n" + yield b'data: {"content":" \xe2\x80\xa8"}\n' + yield b"\n" + yield b'data: {"content":"foo"}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": " culpa"} + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": " 
"} + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": "foo"} + + await assert_empty_iter(iterator) + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multi_byte_character_multiple_chunks( + sync: bool, + client: BrandDev, + async_client: AsyncBrandDev, +) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"content":"' + # bytes taken from the string 'известни' and arbitrarily split + # so that some multi-byte characters span multiple chunks + yield b"\xd0" + yield b"\xb8\xd0\xb7\xd0" + yield b"\xb2\xd0\xb5\xd1\x81\xd1\x82\xd0\xbd\xd0\xb8" + yield b'"}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": "известни"} + + +async def to_aiter(iter: Iterator[bytes]) -> AsyncIterator[bytes]: + for chunk in iter: + yield chunk + + +async def iter_next(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> ServerSentEvent: + if isinstance(iter, AsyncIterator): + return await iter.__anext__() + + return next(iter) + + +async def assert_empty_iter(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> None: + with pytest.raises((StopAsyncIteration, RuntimeError)): + await iter_next(iter) + + +def make_event_iterator( + content: Iterator[bytes], + *, + sync: bool, + client: BrandDev, + async_client: AsyncBrandDev, +) -> Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]: + if sync: + return Stream(cast_to=object, client=client, response=httpx.Response(200, content=content))._iter_events() + + return AsyncStream( + cast_to=object, client=async_client, response=httpx.Response(200, content=to_aiter(content)) + )._iter_events() diff --git a/tests/test_transform.py b/tests/test_transform.py new file mode 100644 index 0000000..ae2573f --- /dev/null +++ b/tests/test_transform.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import io +import pathlib +from typing import Any, Dict, List, Union, TypeVar, Iterable, Optional, cast +from datetime import date, datetime +from typing_extensions import Required, Annotated, TypedDict + +import pytest + +from brand.dev._types import NOT_GIVEN, Base64FileInput +from brand.dev._utils import ( + PropertyInfo, + transform as _transform, + parse_datetime, + async_transform as _async_transform, +) +from brand.dev._compat import PYDANTIC_V2 +from brand.dev._models import BaseModel + +_T = TypeVar("_T") + +SAMPLE_FILE_PATH = pathlib.Path(__file__).parent.joinpath("sample_file.txt") + + +async def transform( + data: _T, + expected_type: object, + use_async: bool, +) -> _T: + if use_async: + return await _async_transform(data, expected_type=expected_type) + + return _transform(data, expected_type=expected_type) + + +parametrize = pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) + + +class Foo1(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +@parametrize +@pytest.mark.asyncio +async def test_top_level_alias(use_async: bool) -> None: + assert await transform({"foo_bar": "hello"}, expected_type=Foo1, use_async=use_async) == {"fooBar": "hello"} + + +class Foo2(TypedDict): + bar: Bar2 + + +class Bar2(TypedDict): + this_thing: Annotated[int, PropertyInfo(alias="this__thing")] + baz: Annotated[Baz2, PropertyInfo(alias="Baz")] + + +class Baz2(TypedDict): + my_baz: Annotated[str, PropertyInfo(alias="myBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_recursive_typeddict(use_async: bool) -> None: + assert await transform({"bar": {"this_thing": 1}}, Foo2, use_async) == {"bar": {"this__thing": 1}} + assert await transform({"bar": {"baz": {"my_baz": "foo"}}}, Foo2, use_async) == {"bar": {"Baz": {"myBaz": "foo"}}} + + +class Foo3(TypedDict): + things: List[Bar3] + + +class Bar3(TypedDict): + my_field: Annotated[str, PropertyInfo(alias="myField")] + + +@parametrize +@pytest.mark.asyncio +async def test_list_of_typeddict(use_async: bool) -> None: + result = await transform({"things": [{"my_field": "foo"}, {"my_field": "foo2"}]}, Foo3, use_async) + assert result == {"things": [{"myField": "foo"}, {"myField": "foo2"}]} + + +class Foo4(TypedDict): + foo: Union[Bar4, Baz4] + + +class Bar4(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz4(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_union_of_typeddict(use_async: bool) -> None: + assert await transform({"foo": {"foo_bar": "bar"}}, Foo4, use_async) == {"foo": {"fooBar": "bar"}} + assert await transform({"foo": {"foo_baz": "baz"}}, Foo4, use_async) == {"foo": {"fooBaz": "baz"}} + assert await transform({"foo": {"foo_baz": "baz", "foo_bar": "bar"}}, Foo4, use_async) == { + "foo": {"fooBaz": "baz", "fooBar": "bar"} + } + + +class Foo5(TypedDict): + foo: Annotated[Union[Bar4, List[Baz4]], PropertyInfo(alias="FOO")] + + +class Bar5(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz5(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_union_of_list(use_async: bool) -> None: + assert await transform({"foo": {"foo_bar": "bar"}}, Foo5, use_async) == {"FOO": {"fooBar": "bar"}} + assert await transform( + { + "foo": [ + {"foo_baz": "baz"}, + {"foo_baz": "baz"}, + ] + }, + Foo5, + use_async, + ) == {"FOO": [{"fooBaz": "baz"}, {"fooBaz": "baz"}]} + + +class Foo6(TypedDict): + bar: Annotated[str, PropertyInfo(alias="Bar")] + + +@parametrize +@pytest.mark.asyncio +async def test_includes_unknown_keys(use_async: bool) -> None: + assert await transform({"bar": "bar", "baz_": {"FOO": 1}}, Foo6, use_async) == { + "Bar": "bar", + "baz_": {"FOO": 1}, + } + + +class Foo7(TypedDict): + bar: Annotated[List[Bar7], PropertyInfo(alias="bAr")] + foo: Bar7 + + +class Bar7(TypedDict): + foo: str + + +@parametrize +@pytest.mark.asyncio +async def test_ignores_invalid_input(use_async: bool) -> None: + assert await transform({"bar": ""}, Foo7, use_async) == {"bAr": ""} + assert await transform({"foo": ""}, Foo7, use_async) == {"foo": ""} + + +class DatetimeDict(TypedDict, total=False): + foo: Annotated[datetime, PropertyInfo(format="iso8601")] + + bar: Annotated[Optional[datetime], PropertyInfo(format="iso8601")] + + required: Required[Annotated[Optional[datetime], PropertyInfo(format="iso8601")]] + + list_: Required[Annotated[Optional[List[datetime]], PropertyInfo(format="iso8601")]] + + union: Annotated[Union[int, datetime], PropertyInfo(format="iso8601")] + + +class DateDict(TypedDict, total=False): + foo: Annotated[date, PropertyInfo(format="iso8601")] + + +class DatetimeModel(BaseModel): + foo: datetime + + +class DateModel(BaseModel): + foo: Optional[date] + + +@parametrize +@pytest.mark.asyncio +async def test_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + tz = "Z" if PYDANTIC_V2 else "+00:00" + assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692" + tz} # type: ignore[comparison-overlap] + + dt = dt.replace(tzinfo=None) + assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] + assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] + + assert await transform({"foo": None}, DateDict, use_async) == {"foo": None} # type: ignore[comparison-overlap] + assert await transform(DateModel(foo=None), Any, use_async) == {"foo": None} # type: ignore + assert await transform({"foo": date.fromisoformat("2023-02-23")}, DateDict, use_async) == {"foo": "2023-02-23"} # type: ignore[comparison-overlap] + assert await transform(DateModel(foo=date.fromisoformat("2023-02-23")), DateDict, use_async) == { + "foo": "2023-02-23" + } # type: ignore[comparison-overlap] + + +@parametrize +@pytest.mark.asyncio +async def test_optional_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"bar": dt}, DatetimeDict, use_async) == {"bar": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + + assert await transform({"bar": None}, DatetimeDict, use_async) == {"bar": None} + + +@parametrize +@pytest.mark.asyncio +async def test_required_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"required": dt}, DatetimeDict, use_async) == { + "required": "2023-02-23T14:16:36.337692+00:00" + } # type: ignore[comparison-overlap] + + assert await transform({"required": None}, DatetimeDict, use_async) == {"required": None} + + +@parametrize +@pytest.mark.asyncio +async def test_union_datetime(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"union": dt}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] + "union": "2023-02-23T14:16:36.337692+00:00" + } + + assert await transform({"union": "foo"}, DatetimeDict, use_async) == {"union": "foo"} + + +@parametrize +@pytest.mark.asyncio +async def test_nested_list_iso6801_format(use_async: bool) -> None: + dt1 = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + dt2 = parse_datetime("2022-01-15T06:34:23Z") + assert await transform({"list_": [dt1, dt2]}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] + "list_": ["2023-02-23T14:16:36.337692+00:00", "2022-01-15T06:34:23+00:00"] + } + + +@parametrize +@pytest.mark.asyncio +async def test_datetime_custom_format(use_async: bool) -> None: + dt = parse_datetime("2022-01-15T06:34:23Z") + + result = await transform(dt, Annotated[datetime, PropertyInfo(format="custom", format_template="%H")], use_async) + assert result == "06" # type: ignore[comparison-overlap] + + +class DateDictWithRequiredAlias(TypedDict, total=False): + required_prop: Required[Annotated[date, PropertyInfo(format="iso8601", alias="prop")]] + + +@parametrize +@pytest.mark.asyncio +async def test_datetime_with_alias(use_async: bool) -> None: + assert await transform({"required_prop": None}, DateDictWithRequiredAlias, use_async) == {"prop": None} # type: ignore[comparison-overlap] + assert await transform( + {"required_prop": date.fromisoformat("2023-02-23")}, DateDictWithRequiredAlias, use_async + ) == {"prop": "2023-02-23"} # type: ignore[comparison-overlap] + + +class MyModel(BaseModel): + foo: str + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_model_to_dictionary(use_async: bool) -> None: + assert cast(Any, await transform(MyModel(foo="hi!"), Any, use_async)) == {"foo": "hi!"} + assert cast(Any, await transform(MyModel.construct(foo="hi!"), Any, use_async)) == {"foo": "hi!"} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_empty_model(use_async: bool) -> None: + assert cast(Any, await transform(MyModel.construct(), Any, use_async)) == {} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_unknown_field(use_async: bool) -> None: + assert cast(Any, await transform(MyModel.construct(my_untyped_field=True), Any, use_async)) == { + "my_untyped_field": True + } + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_mismatched_types(use_async: bool) -> None: + model = MyModel.construct(foo=True) + if PYDANTIC_V2: + with pytest.warns(UserWarning): + params = await transform(model, Any, use_async) + else: + params = await transform(model, Any, use_async) + assert cast(Any, params) == {"foo": True} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_mismatched_object_type(use_async: bool) -> None: + model = MyModel.construct(foo=MyModel.construct(hello="world")) + if PYDANTIC_V2: + with pytest.warns(UserWarning): + params = await transform(model, Any, use_async) + else: + params = await transform(model, Any, use_async) + assert cast(Any, params) == {"foo": {"hello": "world"}} + + +class ModelNestedObjects(BaseModel): + nested: MyModel + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_nested_objects(use_async: bool) -> None: + model = ModelNestedObjects.construct(nested={"foo": "stainless"}) + assert isinstance(model.nested, MyModel) + assert cast(Any, await transform(model, Any, use_async)) == {"nested": {"foo": "stainless"}} + + +class ModelWithDefaultField(BaseModel): + foo: str + with_none_default: Union[str, None] = None + with_str_default: str = "foo" + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_default_field(use_async: bool) -> None: + # should be excluded when defaults are used + model = ModelWithDefaultField.construct() + assert model.with_none_default is None + assert model.with_str_default == "foo" + assert cast(Any, await transform(model, Any, use_async)) == {} + + # should be included when the default value is explicitly given + model = ModelWithDefaultField.construct(with_none_default=None, with_str_default="foo") + assert model.with_none_default is None + assert model.with_str_default == "foo" + assert cast(Any, await transform(model, Any, use_async)) == {"with_none_default": None, "with_str_default": "foo"} + + # should be included when a non-default value is explicitly given + model = ModelWithDefaultField.construct(with_none_default="bar", with_str_default="baz") + assert model.with_none_default == "bar" + assert model.with_str_default == "baz" + assert cast(Any, await transform(model, Any, use_async)) == {"with_none_default": "bar", "with_str_default": "baz"} + + +class TypedDictIterableUnion(TypedDict): + foo: Annotated[Union[Bar8, Iterable[Baz8]], PropertyInfo(alias="FOO")] + + +class Bar8(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz8(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_iterable_of_dictionaries(use_async: bool) -> None: + assert await transform({"foo": [{"foo_baz": "bar"}]}, TypedDictIterableUnion, use_async) == { + "FOO": [{"fooBaz": "bar"}] + } + assert cast(Any, await transform({"foo": ({"foo_baz": "bar"},)}, TypedDictIterableUnion, use_async)) == { + "FOO": [{"fooBaz": "bar"}] + } + + def my_iter() -> Iterable[Baz8]: + yield {"foo_baz": "hello"} + yield {"foo_baz": "world"} + + assert await transform({"foo": my_iter()}, TypedDictIterableUnion, use_async) == { + "FOO": [{"fooBaz": "hello"}, {"fooBaz": "world"}] + } + + +@parametrize +@pytest.mark.asyncio +async def test_dictionary_items(use_async: bool) -> None: + class DictItems(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + assert await transform({"foo": {"foo_baz": "bar"}}, Dict[str, DictItems], use_async) == {"foo": {"fooBaz": "bar"}} + + +class TypedDictIterableUnionStr(TypedDict): + foo: Annotated[Union[str, Iterable[Baz8]], PropertyInfo(alias="FOO")] + + +@parametrize +@pytest.mark.asyncio +async def test_iterable_union_str(use_async: bool) -> None: + assert await transform({"foo": "bar"}, TypedDictIterableUnionStr, use_async) == {"FOO": "bar"} + assert cast(Any, await transform(iter([{"foo_baz": "bar"}]), Union[str, Iterable[Baz8]], use_async)) == [ + {"fooBaz": "bar"} + ] + + +class TypedDictBase64Input(TypedDict): + foo: Annotated[Union[str, Base64FileInput], PropertyInfo(format="base64")] + + +@parametrize +@pytest.mark.asyncio +async def test_base64_file_input(use_async: bool) -> None: + # strings are left as-is + assert await transform({"foo": "bar"}, TypedDictBase64Input, use_async) == {"foo": "bar"} + + # pathlib.Path is automatically converted to base64 + assert await transform({"foo": SAMPLE_FILE_PATH}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQo=" + } # type: ignore[comparison-overlap] + + # io instances are automatically converted to base64 + assert await transform({"foo": io.StringIO("Hello, world!")}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQ==" + } # type: ignore[comparison-overlap] + assert await transform({"foo": io.BytesIO(b"Hello, world!")}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQ==" + } # type: ignore[comparison-overlap] + + +@parametrize +@pytest.mark.asyncio +async def test_transform_skipping(use_async: bool) -> None: + # lists of ints are left as-is + data = [1, 2, 3] + assert await transform(data, List[int], use_async) is data + + # iterables of ints are converted to a list + data = iter([1, 2, 3]) + assert await transform(data, Iterable[int], use_async) == [1, 2, 3] + + +@parametrize +@pytest.mark.asyncio +async def test_strips_notgiven(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": NOT_GIVEN}, Foo1, use_async) == {} diff --git a/tests/test_utils/test_proxy.py b/tests/test_utils/test_proxy.py new file mode 100644 index 0000000..d3adf4f --- /dev/null +++ b/tests/test_utils/test_proxy.py @@ -0,0 +1,34 @@ +import operator +from typing import Any +from typing_extensions import override + +from brand.dev._utils import LazyProxy + + +class RecursiveLazyProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + return self + + def __call__(self, *_args: Any, **_kwds: Any) -> Any: + raise RuntimeError("This should never be called!") + + +def test_recursive_proxy() -> None: + proxy = RecursiveLazyProxy() + assert repr(proxy) == "RecursiveLazyProxy" + assert str(proxy) == "RecursiveLazyProxy" + assert dir(proxy) == [] + assert type(proxy).__name__ == "RecursiveLazyProxy" + assert type(operator.attrgetter("name.foo.bar.baz")(proxy)).__name__ == "RecursiveLazyProxy" + + +def test_isinstance_does_not_error() -> None: + class AlwaysErrorProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + raise RuntimeError("Mocking missing dependency") + + proxy = AlwaysErrorProxy() + assert not isinstance(proxy, dict) + assert isinstance(proxy, LazyProxy) diff --git a/tests/test_utils/test_typing.py b/tests/test_utils/test_typing.py new file mode 100644 index 0000000..62483ec --- /dev/null +++ b/tests/test_utils/test_typing.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Generic, TypeVar, cast + +from brand.dev._utils import extract_type_var_from_base + +_T = TypeVar("_T") +_T2 = TypeVar("_T2") +_T3 = TypeVar("_T3") + + +class BaseGeneric(Generic[_T]): ... + + +class SubclassGeneric(BaseGeneric[_T]): ... + + +class BaseGenericMultipleTypeArgs(Generic[_T, _T2, _T3]): ... + + +class SubclassGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T, _T2, _T3]): ... + + +class SubclassDifferentOrderGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T2, _T, _T3]): ... + + +def test_extract_type_var() -> None: + assert ( + extract_type_var_from_base( + BaseGeneric[int], + index=0, + generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), + ) + == int + ) + + +def test_extract_type_var_generic_subclass() -> None: + assert ( + extract_type_var_from_base( + SubclassGeneric[int], + index=0, + generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), + ) + == int + ) + + +def test_extract_type_var_multiple() -> None: + typ = BaseGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) + + +def test_extract_type_var_generic_subclass_multiple() -> None: + typ = SubclassGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) + + +def test_extract_type_var_generic_subclass_different_ordering_multiple() -> None: + typ = SubclassDifferentOrderGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..560db72 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import os +import inspect +import traceback +import contextlib +from typing import Any, TypeVar, Iterator, cast +from datetime import date, datetime +from typing_extensions import Literal, get_args, get_origin, assert_type + +from brand.dev._types import Omit, NoneType +from brand.dev._utils import ( + is_dict, + is_list, + is_list_type, + is_union_type, + extract_type_arg, + is_annotated_type, + is_type_alias_type, +) +from brand.dev._compat import PYDANTIC_V2, field_outer_type, get_model_fields +from brand.dev._models import BaseModel + +BaseModelT = TypeVar("BaseModelT", bound=BaseModel) + + +def assert_matches_model(model: type[BaseModelT], value: BaseModelT, *, path: list[str]) -> bool: + for name, field in get_model_fields(model).items(): + field_value = getattr(value, name) + if PYDANTIC_V2: + allow_none = False + else: + # in v1 nullability was structured differently + # https://docs.pydantic.dev/2.0/migration/#required-optional-and-nullable-fields + allow_none = getattr(field, "allow_none", False) + + assert_matches_type( + field_outer_type(field), + field_value, + path=[*path, name], + allow_none=allow_none, + ) + + return True + + +# Note: the `path` argument is only used to improve error messages when `--showlocals` is used +def assert_matches_type( + type_: Any, + value: object, + *, + path: list[str], + allow_none: bool = False, +) -> None: + if is_type_alias_type(type_): + type_ = type_.__value__ + + # unwrap `Annotated[T, ...]` -> `T` + if is_annotated_type(type_): + type_ = extract_type_arg(type_, 0) + + if allow_none and value is None: + return + + if type_ is None or type_ is NoneType: + assert value is None + return + + origin = get_origin(type_) or type_ + + if is_list_type(type_): + return _assert_list_type(type_, value) + + if origin == str: + assert isinstance(value, str) + elif origin == int: + assert isinstance(value, int) + elif origin == bool: + assert isinstance(value, bool) + elif origin == float: + assert isinstance(value, float) + elif origin == bytes: + assert isinstance(value, bytes) + elif origin == datetime: + assert isinstance(value, datetime) + elif origin == date: + assert isinstance(value, date) + elif origin == object: + # nothing to do here, the expected type is unknown + pass + elif origin == Literal: + assert value in get_args(type_) + elif origin == dict: + assert is_dict(value) + + args = get_args(type_) + key_type = args[0] + items_type = args[1] + + for key, item in value.items(): + assert_matches_type(key_type, key, path=[*path, ""]) + assert_matches_type(items_type, item, path=[*path, ""]) + elif is_union_type(type_): + variants = get_args(type_) + + try: + none_index = variants.index(type(None)) + except ValueError: + pass + else: + # special case Optional[T] for better error messages + if len(variants) == 2: + if value is None: + # valid + return + + return assert_matches_type(type_=variants[not none_index], value=value, path=path) + + for i, variant in enumerate(variants): + try: + assert_matches_type(variant, value, path=[*path, f"variant {i}"]) + return + except AssertionError: + traceback.print_exc() + continue + + raise AssertionError("Did not match any variants") + elif issubclass(origin, BaseModel): + assert isinstance(value, type_) + assert assert_matches_model(type_, cast(Any, value), path=path) + elif inspect.isclass(origin) and origin.__name__ == "HttpxBinaryResponseContent": + assert value.__class__.__name__ == "HttpxBinaryResponseContent" + else: + assert None, f"Unhandled field type: {type_}" + + +def _assert_list_type(type_: type[object], value: object) -> None: + assert is_list(value) + + inner_type = get_args(type_)[0] + for entry in value: + assert_type(inner_type, entry) # type: ignore + + +@contextlib.contextmanager +def update_env(**new_env: str | Omit) -> Iterator[None]: + old = os.environ.copy() + + try: + for name, value in new_env.items(): + if isinstance(value, Omit): + os.environ.pop(name, None) + else: + os.environ[name] = value + + yield None + finally: + os.environ.clear() + os.environ.update(old) From e0298982c8d33cd5bbee0f0f7228deaec857f33d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 21:09:41 +0000 Subject: [PATCH 002/176] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 5bd2afb..84da68e 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-bf411a5dc003606804e8544b1ae67679048e1e709ee932d0ee84d005507a3095.yml openapi_spec_hash: b910fd6625c8b2f3451a03df123cd420 -config_hash: a98599ff88bf44ea1ecb26366b802452 +config_hash: 2a42b29df74a2526f1d8d81b3448b7e0 From afc2fa5396e3471fbf4aa2708495d6690ea3db79 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 21:13:50 +0000 Subject: [PATCH 003/176] chore: update SDK settings --- .github/workflows/publish-pypi.yml | 31 +++++++++++++ .github/workflows/release-doctor.yml | 21 +++++++++ .release-please-manifest.json | 3 ++ .stats.yml | 2 +- CONTRIBUTING.md | 4 +- README.md | 10 ++--- bin/check-release-environment | 21 +++++++++ pyproject.toml | 6 +-- release-please-config.json | 66 ++++++++++++++++++++++++++++ src/brand/dev/_version.py | 2 +- src/brand/dev/resources/brand.py | 8 ++-- 11 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/publish-pypi.yml create mode 100644 .github/workflows/release-doctor.yml create mode 100644 .release-please-manifest.json create mode 100644 bin/check-release-environment create mode 100644 release-please-config.json diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..e77a334 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,31 @@ +# This workflow is triggered when a GitHub release is created. +# It can also be run manually to re-publish to PyPI in case it failed for some reason. +# You can run this workflow by navigating to https://www.github.com/brand-dot-dev/python-sdk/actions/workflows/publish-pypi.yml +name: Publish PyPI +on: + workflow_dispatch: + + release: + types: [published] + +jobs: + publish: + name: publish + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Publish to PyPI + run: | + bash ./bin/publish-pypi + env: + PYPI_TOKEN: ${{ secrets.BRAND_DEV_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml new file mode 100644 index 0000000..6d1c39b --- /dev/null +++ b/.github/workflows/release-doctor.yml @@ -0,0 +1,21 @@ +name: Release Doctor +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + release_doctor: + name: release doctor + runs-on: ubuntu-latest + if: github.repository == 'brand-dot-dev/python-sdk' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') + + steps: + - uses: actions/checkout@v4 + + - name: Check release environment + run: | + bash ./bin/check-release-environment + env: + PYPI_TOKEN: ${{ secrets.BRAND_DEV_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..c476280 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.1-alpha.0" +} \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 84da68e..0909661 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-bf411a5dc003606804e8544b1ae67679048e1e709ee932d0ee84d005507a3095.yml openapi_spec_hash: b910fd6625c8b2f3451a03df123cd420 -config_hash: 2a42b29df74a2526f1d8d81b3448b7e0 +config_hash: 0eda55be1876add297ef57bed4f62b18 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 112a797..6164c91 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,7 @@ If you’d like to use the repository from source, you can either install from g To install via git: ```sh -$ pip install git+ssh://git@github.com/stainless-sdks/brand.dev-python.git +$ pip install git+ssh://git@github.com/brand-dot-dev/python-sdk.git ``` Alternatively, you can build from source and install the wheel file: @@ -121,7 +121,7 @@ the changes aren't made through the automated pipeline, you may want to make rel ### Publish with a GitHub workflow -You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/stainless-sdks/brand.dev-python/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. +You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/brand-dot-dev/python-sdk/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. ### Publish manually diff --git a/README.md b/README.md index 9fd47dc..5df7b7c 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ The full API of this library can be found in [api.md](api.md). ## Installation ```sh -# install from this staging repo -pip install git+ssh://git@github.com/stainless-sdks/brand.dev-python.git +# install from the production repo +pip install git+ssh://git@github.com/brand-dot-dev/python-sdk.git ``` > [!NOTE] @@ -218,9 +218,9 @@ brand = response.parse() # get the object that `brand.retrieve()` would have re print(brand.brand) ``` -These methods return an [`APIResponse`](https://github.com/stainless-sdks/brand.dev-python/tree/main/src/brand/dev/_response.py) object. +These methods return an [`APIResponse`](https://github.com/brand-dot-dev/python-sdk/tree/main/src/brand/dev/_response.py) object. -The async client returns an [`AsyncAPIResponse`](https://github.com/stainless-sdks/brand.dev-python/tree/main/src/brand/dev/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. +The async client returns an [`AsyncAPIResponse`](https://github.com/brand-dot-dev/python-sdk/tree/main/src/brand/dev/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. #### `.with_streaming_response` @@ -326,7 +326,7 @@ This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) con We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. -We are keen for your feedback; please open an [issue](https://www.github.com/stainless-sdks/brand.dev-python/issues) with questions, bugs, or suggestions. +We are keen for your feedback; please open an [issue](https://www.github.com/brand-dot-dev/python-sdk/issues) with questions, bugs, or suggestions. ### Determining the installed version diff --git a/bin/check-release-environment b/bin/check-release-environment new file mode 100644 index 0000000..def918a --- /dev/null +++ b/bin/check-release-environment @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +errors=() + +if [ -z "${PYPI_TOKEN}" ]; then + errors+=("The BRAND_DEV_PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") +fi + +lenErrors=${#errors[@]} + +if [[ lenErrors -gt 0 ]]; then + echo -e "Found the following errors in the release environment:\n" + + for error in "${errors[@]}"; do + echo -e "- $error\n" + done + + exit 1 +fi + +echo "The environment is ready to push releases!" diff --git a/pyproject.toml b/pyproject.toml index 342003b..6ff4846 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,8 +34,8 @@ classifiers = [ ] [project.urls] -Homepage = "https://github.com/stainless-sdks/brand.dev-python" -Repository = "https://github.com/stainless-sdks/brand.dev-python" +Homepage = "https://github.com/brand-dot-dev/python-sdk" +Repository = "https://github.com/brand-dot-dev/python-sdk" [tool.rye] @@ -121,7 +121,7 @@ path = "README.md" [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] # replace relative links with absolute links pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' -replacement = '[\1](https://github.com/stainless-sdks/brand.dev-python/tree/main/\g<2>)' +replacement = '[\1](https://github.com/brand-dot-dev/python-sdk/tree/main/\g<2>)' [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..5cdc8d5 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,66 @@ +{ + "packages": { + ".": {} + }, + "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", + "include-v-in-tag": true, + "include-component-in-tag": false, + "versioning": "prerelease", + "prerelease": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "pull-request-header": "Automated Release PR", + "pull-request-title-pattern": "release: ${version}", + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "revert", + "section": "Reverts" + }, + { + "type": "chore", + "section": "Chores" + }, + { + "type": "docs", + "section": "Documentation" + }, + { + "type": "style", + "section": "Styles" + }, + { + "type": "refactor", + "section": "Refactors" + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "build", + "section": "Build System" + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + } + ], + "release-type": "python", + "extra-files": [ + "src/brand/dev/_version.py" + ] +} \ No newline at end of file diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index aef490a..b44b807 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "0.0.1-alpha.0" +__version__ = "0.0.1-alpha.0" # x-release-please-version diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index a0ec7bd..0d2fd8a 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -40,7 +40,7 @@ def with_raw_response(self) -> BrandResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/brand.dev-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/brand-dot-dev/python-sdk#accessing-raw-response-data-eg-headers """ return BrandResourceWithRawResponse(self) @@ -49,7 +49,7 @@ def with_streaming_response(self) -> BrandResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/brand.dev-python#with_streaming_response + For more information, see https://www.github.com/brand-dot-dev/python-sdk#with_streaming_response """ return BrandResourceWithStreamingResponse(self) @@ -316,7 +316,7 @@ def with_raw_response(self) -> AsyncBrandResourceWithRawResponse: This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. - For more information, see https://www.github.com/stainless-sdks/brand.dev-python#accessing-raw-response-data-eg-headers + For more information, see https://www.github.com/brand-dot-dev/python-sdk#accessing-raw-response-data-eg-headers """ return AsyncBrandResourceWithRawResponse(self) @@ -325,7 +325,7 @@ def with_streaming_response(self) -> AsyncBrandResourceWithStreamingResponse: """ An alternative to `.with_raw_response` that doesn't eagerly read the response body. - For more information, see https://www.github.com/stainless-sdks/brand.dev-python#with_streaming_response + For more information, see https://www.github.com/brand-dot-dev/python-sdk#with_streaming_response """ return AsyncBrandResourceWithStreamingResponse(self) From c008e10ed6e02dcb738f902a5891aa5c111650f7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 21:14:27 +0000 Subject: [PATCH 004/176] chore: update SDK settings --- .stats.yml | 2 +- README.md | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.stats.yml b/.stats.yml index 0909661..5cb0b49 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-bf411a5dc003606804e8544b1ae67679048e1e709ee932d0ee84d005507a3095.yml openapi_spec_hash: b910fd6625c8b2f3451a03df123cd420 -config_hash: 0eda55be1876add297ef57bed4f62b18 +config_hash: 86dcace891937635f9e56dcdea517aa3 diff --git a/README.md b/README.md index 5df7b7c..6629f15 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,10 @@ The full API of this library can be found in [api.md](api.md). ## Installation ```sh -# install from the production repo -pip install git+ssh://git@github.com/brand-dot-dev/python-sdk.git +# install from PyPI +pip install --pre brand.dev ``` -> [!NOTE] -> Once this package is [published to PyPI](https://app.stainless.com/docs/guides/publish), this will become: `pip install --pre brand.dev` - ## Usage The full API of this library can be found in [api.md](api.md). From 8147c8945ba9d31343523d62fc2a840ae1204ded Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 21:26:37 +0000 Subject: [PATCH 005/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c476280..55f722d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.0.1-alpha.0" + ".": "0.0.1-alpha.1" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6ff4846..5c1cddf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "0.0.1-alpha.0" +version = "0.0.1-alpha.1" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index b44b807..9ff5597 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "0.0.1-alpha.0" # x-release-please-version +__version__ = "0.0.1-alpha.1" # x-release-please-version From 1060152c16052b258c76ab2a54876006d2cc9a3b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 21:43:30 +0000 Subject: [PATCH 006/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 5cb0b49..7bdae97 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-bf411a5dc003606804e8544b1ae67679048e1e709ee932d0ee84d005507a3095.yml -openapi_spec_hash: b910fd6625c8b2f3451a03df123cd420 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-4ae7054043638dcc01d21118e45decf37b9b4c45901f56f754242c53488e9e1b.yml +openapi_spec_hash: 36398495174caed44730dadeab373cba config_hash: 86dcace891937635f9e56dcdea517aa3 From d3edcc198a2e5a2e5440252bafad139d829e28b1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 21:52:46 +0000 Subject: [PATCH 007/176] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 7bdae97..80ea7a0 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-4ae7054043638dcc01d21118e45decf37b9b4c45901f56f754242c53488e9e1b.yml openapi_spec_hash: 36398495174caed44730dadeab373cba -config_hash: 86dcace891937635f9e56dcdea517aa3 +config_hash: 8fae1c2fda5d342e67cdb6d0a5dfd9c1 From 0ae5ca71366b2a6b34a7fcd6e6cac4fbdbe14a6e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 21:59:25 +0000 Subject: [PATCH 008/176] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 80ea7a0..863e9db 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-4ae7054043638dcc01d21118e45decf37b9b4c45901f56f754242c53488e9e1b.yml openapi_spec_hash: 36398495174caed44730dadeab373cba -config_hash: 8fae1c2fda5d342e67cdb6d0a5dfd9c1 +config_hash: 0b5e11a8e6655e6314726969e45af91b From dd10921c3e1cb3143157ab0800c7ed3a0e589189 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 22:42:25 +0000 Subject: [PATCH 009/176] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 863e9db..644bbf3 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-4ae7054043638dcc01d21118e45decf37b9b4c45901f56f754242c53488e9e1b.yml openapi_spec_hash: 36398495174caed44730dadeab373cba -config_hash: 0b5e11a8e6655e6314726969e45af91b +config_hash: 29e06b6f848056056c40f0d89eacc2d6 From 7c468862561ae370f075e8e69a2f335946790ec9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 17:42:24 +0000 Subject: [PATCH 010/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 644bbf3..7816e6f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-4ae7054043638dcc01d21118e45decf37b9b4c45901f56f754242c53488e9e1b.yml -openapi_spec_hash: 36398495174caed44730dadeab373cba +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-97c4abcfeb185afb2001b244b4a4ab6d0cbb40f967cf7a8c27f1a0e2e8a0f6db.yml +openapi_spec_hash: cf66c23d6840b98c22d6caa303db8b93 config_hash: 29e06b6f848056056c40f0d89eacc2d6 From a303dbd1398895c9a13d71365e4225a2a513ef59 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 17:44:47 +0000 Subject: [PATCH 011/176] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 7816e6f..27372e7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-97c4abcfeb185afb2001b244b4a4ab6d0cbb40f967cf7a8c27f1a0e2e8a0f6db.yml openapi_spec_hash: cf66c23d6840b98c22d6caa303db8b93 -config_hash: 29e06b6f848056056c40f0d89eacc2d6 +config_hash: 29b105a642e6b6687b258cd4e63d6aab From 3c7f2ce8d590b168c30eef6f3b69748b7c12c0c9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 18:01:25 +0000 Subject: [PATCH 012/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 27372e7..ac8369e 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-97c4abcfeb185afb2001b244b4a4ab6d0cbb40f967cf7a8c27f1a0e2e8a0f6db.yml -openapi_spec_hash: cf66c23d6840b98c22d6caa303db8b93 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-4ae7054043638dcc01d21118e45decf37b9b4c45901f56f754242c53488e9e1b.yml +openapi_spec_hash: 36398495174caed44730dadeab373cba config_hash: 29b105a642e6b6687b258cd4e63d6aab From ccc55af118aab70a67c56e3c8eaea626f0d24bb0 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 19:12:32 +0000 Subject: [PATCH 013/176] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index ac8369e..59fd58b 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-4ae7054043638dcc01d21118e45decf37b9b4c45901f56f754242c53488e9e1b.yml openapi_spec_hash: 36398495174caed44730dadeab373cba -config_hash: 29b105a642e6b6687b258cd4e63d6aab +config_hash: 8301e508e4673f9b7544b474ff4ff231 From c13b41f82e60a8ee97baba556a5c1d873c23f871 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 19:13:59 +0000 Subject: [PATCH 014/176] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 59fd58b..3a935a8 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-4ae7054043638dcc01d21118e45decf37b9b4c45901f56f754242c53488e9e1b.yml openapi_spec_hash: 36398495174caed44730dadeab373cba -config_hash: 8301e508e4673f9b7544b474ff4ff231 +config_hash: d46b53bb2641150aed659de7eeec3a68 From 16cabf68f7e233320311a2cc641be642069ae3f6 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 2 Jun 2025 19:47:56 +0000 Subject: [PATCH 015/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 3a935a8..67ba43c 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 5 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-4ae7054043638dcc01d21118e45decf37b9b4c45901f56f754242c53488e9e1b.yml -openapi_spec_hash: 36398495174caed44730dadeab373cba +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-d5cf52f21333b8216b73e9659b4a1e8e0675404f0ae3d15bdd7ef368ccfa94cf.yml +openapi_spec_hash: c70cbc2e38e7aeaf2173574a13e9ca55 config_hash: d46b53bb2641150aed659de7eeec3a68 From 9a0c867b030434beb986f0e46dec2de3bd34bc76 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 2 Jun 2025 19:51:52 +0000 Subject: [PATCH 016/176] feat(api): manual updates --- .stats.yml | 4 +- api.md | 2 + src/brand/dev/resources/brand.py | 115 +++++++++++++ src/brand/dev/types/__init__.py | 2 + src/brand/dev/types/brand_ai_query_params.py | 33 ++++ .../dev/types/brand_ai_query_response.py | 26 +++ tests/api_resources/test_brand.py | 151 ++++++++++++++++++ 7 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 src/brand/dev/types/brand_ai_query_params.py create mode 100644 src/brand/dev/types/brand_ai_query_response.py diff --git a/.stats.yml b/.stats.yml index 67ba43c..6a34fdb 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 5 +configured_endpoints: 6 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-d5cf52f21333b8216b73e9659b4a1e8e0675404f0ae3d15bdd7ef368ccfa94cf.yml openapi_spec_hash: c70cbc2e38e7aeaf2173574a13e9ca55 -config_hash: d46b53bb2641150aed659de7eeec3a68 +config_hash: 372b187172495fc2f76f05ba016b4a45 diff --git a/api.md b/api.md index c69b8c3..da9469f 100644 --- a/api.md +++ b/api.md @@ -5,6 +5,7 @@ Types: ```python from brand.dev.types import ( BrandRetrieveResponse, + BrandAIQueryResponse, BrandIdentifyFromTransactionResponse, BrandRetrieveByTickerResponse, BrandRetrieveNaicsResponse, @@ -15,6 +16,7 @@ from brand.dev.types import ( Methods: - client.brand.retrieve(\*\*params) -> BrandRetrieveResponse +- client.brand.ai_query(\*\*params) -> BrandAIQueryResponse - client.brand.identify_from_transaction(\*\*params) -> BrandIdentifyFromTransactionResponse - client.brand.retrieve_by_ticker(\*\*params) -> BrandRetrieveByTickerResponse - client.brand.retrieve_naics(\*\*params) -> BrandRetrieveNaicsResponse diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 0d2fd8a..4542415 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -2,12 +2,14 @@ from __future__ import annotations +from typing import List, Iterable from typing_extensions import Literal import httpx from ..types import ( brand_search_params, + brand_ai_query_params, brand_retrieve_params, brand_retrieve_naics_params, brand_retrieve_by_ticker_params, @@ -25,6 +27,7 @@ ) from .._base_client import make_request_options from ..types.brand_search_response import BrandSearchResponse +from ..types.brand_ai_query_response import BrandAIQueryResponse from ..types.brand_retrieve_response import BrandRetrieveResponse from ..types.brand_retrieve_naics_response import BrandRetrieveNaicsResponse from ..types.brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse @@ -153,6 +156,56 @@ def retrieve( cast_to=BrandRetrieveResponse, ) + def ai_query( + self, + *, + data_to_extract: Iterable[brand_ai_query_params.DataToExtract], + domain: str, + specific_pages: List[str] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandAIQueryResponse: + """Beta feature: Use AI to extract specific data points from a brand's website. + + The + AI will crawl the website and extract the requested information based on the + provided data points. + + Args: + data_to_extract: Array of data points to extract from the website + + domain: The domain name to analyze + + specific_pages: Optional array of specific pages to analyze + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/brand/ai/query", + body=maybe_transform( + { + "data_to_extract": data_to_extract, + "domain": domain, + "specific_pages": specific_pages, + }, + brand_ai_query_params.BrandAIQueryParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrandAIQueryResponse, + ) + def identify_from_transaction( self, *, @@ -429,6 +482,56 @@ async def retrieve( cast_to=BrandRetrieveResponse, ) + async def ai_query( + self, + *, + data_to_extract: Iterable[brand_ai_query_params.DataToExtract], + domain: str, + specific_pages: List[str] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandAIQueryResponse: + """Beta feature: Use AI to extract specific data points from a brand's website. + + The + AI will crawl the website and extract the requested information based on the + provided data points. + + Args: + data_to_extract: Array of data points to extract from the website + + domain: The domain name to analyze + + specific_pages: Optional array of specific pages to analyze + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/brand/ai/query", + body=await async_maybe_transform( + { + "data_to_extract": data_to_extract, + "domain": domain, + "specific_pages": specific_pages, + }, + brand_ai_query_params.BrandAIQueryParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrandAIQueryResponse, + ) + async def identify_from_transaction( self, *, @@ -596,6 +699,9 @@ def __init__(self, brand: BrandResource) -> None: self.retrieve = to_raw_response_wrapper( brand.retrieve, ) + self.ai_query = to_raw_response_wrapper( + brand.ai_query, + ) self.identify_from_transaction = to_raw_response_wrapper( brand.identify_from_transaction, ) @@ -617,6 +723,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.retrieve = async_to_raw_response_wrapper( brand.retrieve, ) + self.ai_query = async_to_raw_response_wrapper( + brand.ai_query, + ) self.identify_from_transaction = async_to_raw_response_wrapper( brand.identify_from_transaction, ) @@ -638,6 +747,9 @@ def __init__(self, brand: BrandResource) -> None: self.retrieve = to_streamed_response_wrapper( brand.retrieve, ) + self.ai_query = to_streamed_response_wrapper( + brand.ai_query, + ) self.identify_from_transaction = to_streamed_response_wrapper( brand.identify_from_transaction, ) @@ -659,6 +771,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.retrieve = async_to_streamed_response_wrapper( brand.retrieve, ) + self.ai_query = async_to_streamed_response_wrapper( + brand.ai_query, + ) self.identify_from_transaction = async_to_streamed_response_wrapper( brand.identify_from_transaction, ) diff --git a/src/brand/dev/types/__init__.py b/src/brand/dev/types/__init__.py index 55bc58f..00e8bf7 100644 --- a/src/brand/dev/types/__init__.py +++ b/src/brand/dev/types/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations from .brand_search_params import BrandSearchParams as BrandSearchParams +from .brand_ai_query_params import BrandAIQueryParams as BrandAIQueryParams from .brand_retrieve_params import BrandRetrieveParams as BrandRetrieveParams from .brand_search_response import BrandSearchResponse as BrandSearchResponse +from .brand_ai_query_response import BrandAIQueryResponse as BrandAIQueryResponse from .brand_retrieve_response import BrandRetrieveResponse as BrandRetrieveResponse from .brand_retrieve_naics_params import BrandRetrieveNaicsParams as BrandRetrieveNaicsParams from .brand_retrieve_naics_response import BrandRetrieveNaicsResponse as BrandRetrieveNaicsResponse diff --git a/src/brand/dev/types/brand_ai_query_params.py b/src/brand/dev/types/brand_ai_query_params.py new file mode 100644 index 0000000..f65e569 --- /dev/null +++ b/src/brand/dev/types/brand_ai_query_params.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Iterable +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["BrandAIQueryParams", "DataToExtract"] + + +class BrandAIQueryParams(TypedDict, total=False): + data_to_extract: Required[Iterable[DataToExtract]] + """Array of data points to extract from the website""" + + domain: Required[str] + """The domain name to analyze""" + + specific_pages: List[str] + """Optional array of specific pages to analyze""" + + +class DataToExtract(TypedDict, total=False): + datapoint_description: Required[str] + """Description of what to extract""" + + datapoint_example: Required[str] + """Example of the expected value""" + + datapoint_name: Required[str] + """Name of the data point to extract""" + + datapoint_type: Required[Literal["text", "number", "date", "boolean", "list", "url"]] + """Type of the data point""" diff --git a/src/brand/dev/types/brand_ai_query_response.py b/src/brand/dev/types/brand_ai_query_response.py new file mode 100644 index 0000000..0e9909b --- /dev/null +++ b/src/brand/dev/types/brand_ai_query_response.py @@ -0,0 +1,26 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional + +from .._models import BaseModel + +__all__ = ["BrandAIQueryResponse", "DataExtracted"] + + +class DataExtracted(BaseModel): + datapoint_name: Optional[str] = None + """Name of the extracted data point""" + + datapoint_value: Union[str, float, bool, List[str], List[float], None] = None + """Value of the extracted data point""" + + +class BrandAIQueryResponse(BaseModel): + data_extracted: Optional[List[DataExtracted]] = None + """Array of extracted data points""" + + domain: Optional[str] = None + """The domain that was analyzed""" + + urls_analyzed: Optional[List[str]] = None + """List of URLs that were analyzed""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 19e5a5c..3ff2fdd 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -11,6 +11,7 @@ from tests.utils import assert_matches_type from brand.dev.types import ( BrandSearchResponse, + BrandAIQueryResponse, BrandRetrieveResponse, BrandRetrieveNaicsResponse, BrandRetrieveByTickerResponse, @@ -66,6 +67,81 @@ def test_streaming_response_retrieve(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True + @pytest.mark.skip() + @parametrize + def test_method_ai_query(self, client: BrandDev) -> None: + brand = client.brand.ai_query( + data_to_extract=[ + { + "datapoint_description": "datapoint_description", + "datapoint_example": "datapoint_example", + "datapoint_name": "datapoint_name", + "datapoint_type": "text", + } + ], + domain="domain", + ) + assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_method_ai_query_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.ai_query( + data_to_extract=[ + { + "datapoint_description": "datapoint_description", + "datapoint_example": "datapoint_example", + "datapoint_name": "datapoint_name", + "datapoint_type": "text", + } + ], + domain="domain", + specific_pages=["string"], + ) + assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_raw_response_ai_query(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.ai_query( + data_to_extract=[ + { + "datapoint_description": "datapoint_description", + "datapoint_example": "datapoint_example", + "datapoint_name": "datapoint_name", + "datapoint_type": "text", + } + ], + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_streaming_response_ai_query(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.ai_query( + data_to_extract=[ + { + "datapoint_description": "datapoint_description", + "datapoint_example": "datapoint_example", + "datapoint_name": "datapoint_name", + "datapoint_type": "text", + } + ], + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip() @parametrize def test_method_identify_from_transaction(self, client: BrandDev) -> None: @@ -249,6 +325,81 @@ async def test_streaming_response_retrieve(self, async_client: AsyncBrandDev) -> assert cast(Any, response.is_closed) is True + @pytest.mark.skip() + @parametrize + async def test_method_ai_query(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.ai_query( + data_to_extract=[ + { + "datapoint_description": "datapoint_description", + "datapoint_example": "datapoint_example", + "datapoint_name": "datapoint_name", + "datapoint_type": "text", + } + ], + domain="domain", + ) + assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_method_ai_query_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.ai_query( + data_to_extract=[ + { + "datapoint_description": "datapoint_description", + "datapoint_example": "datapoint_example", + "datapoint_name": "datapoint_name", + "datapoint_type": "text", + } + ], + domain="domain", + specific_pages=["string"], + ) + assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_raw_response_ai_query(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.ai_query( + data_to_extract=[ + { + "datapoint_description": "datapoint_description", + "datapoint_example": "datapoint_example", + "datapoint_name": "datapoint_name", + "datapoint_type": "text", + } + ], + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_streaming_response_ai_query(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.ai_query( + data_to_extract=[ + { + "datapoint_description": "datapoint_description", + "datapoint_example": "datapoint_example", + "datapoint_name": "datapoint_name", + "datapoint_type": "text", + } + ], + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip() @parametrize async def test_method_identify_from_transaction(self, async_client: AsyncBrandDev) -> None: From 6e7cf826782fb709988992c771df38aa2f674b0f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 2 Jun 2025 19:57:42 +0000 Subject: [PATCH 017/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- README.md | 2 +- pyproject.toml | 2 +- scripts/utils/upload-artifact.sh | 2 +- src/brand/dev/_version.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 55f722d..fea3454 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.0.1-alpha.1" + ".": "1.0.0" } \ No newline at end of file diff --git a/README.md b/README.md index 6629f15..4f528a5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The full API of this library can be found in [api.md](api.md). ```sh # install from PyPI -pip install --pre brand.dev +pip install brand.dev ``` ## Usage diff --git a/pyproject.toml b/pyproject.toml index 5c1cddf..4755f1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "0.0.1-alpha.1" +version = "1.0.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh index 0f72d31..ce09341 100755 --- a/scripts/utils/upload-artifact.sh +++ b/scripts/utils/upload-artifact.sh @@ -18,7 +18,7 @@ UPLOAD_RESPONSE=$(tar -cz . | curl -v -X PUT \ if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: pip install --pre 'https://pkg.stainless.com/s/brand.dev-python/$SHA'\033[0m" + echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/brand.dev-python/$SHA'\033[0m" else echo -e "\033[31mFailed to upload artifact.\033[0m" exit 1 diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 9ff5597..0b3fad1 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "0.0.1-alpha.1" # x-release-please-version +__version__ = "1.0.0" # x-release-please-version From 3ee7da0188dee571d3b89d23d9c30ad9c96c627f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 2 Jun 2025 20:03:12 +0000 Subject: [PATCH 018/176] feat(api): manual updates --- .stats.yml | 4 +-- README.md | 33 +++++++++++++++++++ src/brand/dev/resources/brand.py | 10 +++--- src/brand/dev/types/brand_ai_query_params.py | 34 +++++++++++++++++--- tests/api_resources/test_brand.py | 22 +++++++++++-- 5 files changed, 90 insertions(+), 13 deletions(-) diff --git a/.stats.yml b/.stats.yml index 6a34fdb..0221da5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 6 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-d5cf52f21333b8216b73e9659b4a1e8e0675404f0ae3d15bdd7ef368ccfa94cf.yml -openapi_spec_hash: c70cbc2e38e7aeaf2173574a13e9ca55 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-c921d60adf854da13dbb83d547cbd8a32fd86d625fb12a325b7d305da7f3a93a.yml +openapi_spec_hash: c02b88f26faaf9fd04177b77d34fd5c3 config_hash: 372b187172495fc2f76f05ba016b4a45 diff --git a/README.md b/README.md index 4f528a5..aeefff9 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,39 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. +## Nested params + +Nested parameters are dictionaries, typed using `TypedDict`, for example: + +```python +from brand.dev import BrandDev + +client = BrandDev() + +response = client.brand.ai_query( + data_to_extract=[ + { + "datapoint_description": "datapoint_description", + "datapoint_example": "datapoint_example", + "datapoint_name": "datapoint_name", + "datapoint_type": "text", + } + ], + domain="domain", + specific_pages={ + "about_us": True, + "blog": True, + "careers": True, + "contact_us": True, + "faq": True, + "home_page": True, + "privacy_policy": True, + "terms_and_conditions": True, + }, +) +print(response.specific_pages) +``` + ## Handling errors When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `brand.dev.APIConnectionError` is raised. diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 4542415..95ecfa0 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Iterable +from typing import Iterable from typing_extensions import Literal import httpx @@ -161,7 +161,7 @@ def ai_query( *, data_to_extract: Iterable[brand_ai_query_params.DataToExtract], domain: str, - specific_pages: List[str] | NotGiven = NOT_GIVEN, + specific_pages: brand_ai_query_params.SpecificPages | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -180,7 +180,7 @@ def ai_query( domain: The domain name to analyze - specific_pages: Optional array of specific pages to analyze + specific_pages: Optional object specifying which pages to analyze extra_headers: Send extra headers @@ -487,7 +487,7 @@ async def ai_query( *, data_to_extract: Iterable[brand_ai_query_params.DataToExtract], domain: str, - specific_pages: List[str] | NotGiven = NOT_GIVEN, + specific_pages: brand_ai_query_params.SpecificPages | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -506,7 +506,7 @@ async def ai_query( domain: The domain name to analyze - specific_pages: Optional array of specific pages to analyze + specific_pages: Optional object specifying which pages to analyze extra_headers: Send extra headers diff --git a/src/brand/dev/types/brand_ai_query_params.py b/src/brand/dev/types/brand_ai_query_params.py index f65e569..d199778 100644 --- a/src/brand/dev/types/brand_ai_query_params.py +++ b/src/brand/dev/types/brand_ai_query_params.py @@ -2,10 +2,10 @@ from __future__ import annotations -from typing import List, Iterable +from typing import Iterable from typing_extensions import Literal, Required, TypedDict -__all__ = ["BrandAIQueryParams", "DataToExtract"] +__all__ = ["BrandAIQueryParams", "DataToExtract", "SpecificPages"] class BrandAIQueryParams(TypedDict, total=False): @@ -15,8 +15,8 @@ class BrandAIQueryParams(TypedDict, total=False): domain: Required[str] """The domain name to analyze""" - specific_pages: List[str] - """Optional array of specific pages to analyze""" + specific_pages: SpecificPages + """Optional object specifying which pages to analyze""" class DataToExtract(TypedDict, total=False): @@ -31,3 +31,29 @@ class DataToExtract(TypedDict, total=False): datapoint_type: Required[Literal["text", "number", "date", "boolean", "list", "url"]] """Type of the data point""" + + +class SpecificPages(TypedDict, total=False): + about_us: bool + """Whether to analyze the about us page""" + + blog: bool + """Whether to analyze the blog""" + + careers: bool + """Whether to analyze the careers page""" + + contact_us: bool + """Whether to analyze the contact us page""" + + faq: bool + """Whether to analyze the FAQ page""" + + home_page: bool + """Whether to analyze the home page""" + + privacy_policy: bool + """Whether to analyze the privacy policy page""" + + terms_and_conditions: bool + """Whether to analyze the terms and conditions page""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 3ff2fdd..3f1629e 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -96,7 +96,16 @@ def test_method_ai_query_with_all_params(self, client: BrandDev) -> None: } ], domain="domain", - specific_pages=["string"], + specific_pages={ + "about_us": True, + "blog": True, + "careers": True, + "contact_us": True, + "faq": True, + "home_page": True, + "privacy_policy": True, + "terms_and_conditions": True, + }, ) assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) @@ -354,7 +363,16 @@ async def test_method_ai_query_with_all_params(self, async_client: AsyncBrandDev } ], domain="domain", - specific_pages=["string"], + specific_pages={ + "about_us": True, + "blog": True, + "careers": True, + "contact_us": True, + "faq": True, + "home_page": True, + "privacy_policy": True, + "terms_and_conditions": True, + }, ) assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) From a943c1a5310eebf4fceae8b18c07793ecb30e5d7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 02:11:36 +0000 Subject: [PATCH 019/176] chore(docs): remove reference to rye shell --- CONTRIBUTING.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6164c91..8126529 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,7 @@ $ rye sync --all-features You can then run scripts using `rye run python script.py` or by activating the virtual environment: ```sh -$ rye shell -# or manually activate - https://docs.python.org/3/library/venv.html#how-venvs-work +# Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work $ source .venv/bin/activate # now you can omit the `rye run` prefix From 12e6a495f6feab8798c296f73a5451485c582be3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 02:23:17 +0000 Subject: [PATCH 020/176] chore(docs): remove unnecessary param examples --- README.md | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/README.md b/README.md index aeefff9..81f4734 100644 --- a/README.md +++ b/README.md @@ -96,16 +96,7 @@ response = client.brand.ai_query( } ], domain="domain", - specific_pages={ - "about_us": True, - "blog": True, - "careers": True, - "contact_us": True, - "faq": True, - "home_page": True, - "privacy_policy": True, - "terms_and_conditions": True, - }, + specific_pages={}, ) print(response.specific_pages) ``` From c684272c48a01e3ecb49e127885bcdbc2489dac1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 03:25:09 +0000 Subject: [PATCH 021/176] feat(client): add follow_redirects request option --- src/brand/dev/_base_client.py | 6 ++++ src/brand/dev/_models.py | 2 ++ src/brand/dev/_types.py | 2 ++ tests/test_client.py | 54 +++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/src/brand/dev/_base_client.py b/src/brand/dev/_base_client.py index 67fcdf8..35eb16c 100644 --- a/src/brand/dev/_base_client.py +++ b/src/brand/dev/_base_client.py @@ -960,6 +960,9 @@ def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None @@ -1460,6 +1463,9 @@ async def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None diff --git a/src/brand/dev/_models.py b/src/brand/dev/_models.py index 798956f..4f21498 100644 --- a/src/brand/dev/_models.py +++ b/src/brand/dev/_models.py @@ -737,6 +737,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): idempotency_key: str json_data: Body extra_json: AnyMapping + follow_redirects: bool @final @@ -750,6 +751,7 @@ class FinalRequestOptions(pydantic.BaseModel): files: Union[HttpxRequestFiles, None] = None idempotency_key: Union[str, None] = None post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() + follow_redirects: Union[bool, None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. diff --git a/src/brand/dev/_types.py b/src/brand/dev/_types.py index 7231eff..382635b 100644 --- a/src/brand/dev/_types.py +++ b/src/brand/dev/_types.py @@ -100,6 +100,7 @@ class RequestOptions(TypedDict, total=False): params: Query extra_json: AnyMapping idempotency_key: str + follow_redirects: bool # Sentinel class used until PEP 0661 is accepted @@ -215,3 +216,4 @@ class _GenericAlias(Protocol): class HttpxSendArgs(TypedDict, total=False): auth: httpx.Auth + follow_redirects: bool diff --git a/tests/test_client.py b/tests/test_client.py index 6cc808b..391b339 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -814,6 +814,33 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" + class TestAsyncBrandDev: client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -1635,3 +1662,30 @@ async def test_main() -> None: raise AssertionError("calling get_platform using asyncify resulted in a hung process") time.sleep(0.1) + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + await self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" From b472dc58553870b08618662406bfb31834948dba Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 03:52:50 +0000 Subject: [PATCH 022/176] chore(internal): codegen related update --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fea3454..2601677 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.0.0" + ".": "1.1.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4755f1a..a9d0496 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.0.0" +version = "1.1.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 0b3fad1..655680d 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.0.0" # x-release-please-version +__version__ = "1.1.0" # x-release-please-version From 2428cfa90083d52d2beb3d9619ca9549a87cfdee Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 04:05:53 +0000 Subject: [PATCH 023/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 0221da5..45760cc 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 6 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-c921d60adf854da13dbb83d547cbd8a32fd86d625fb12a325b7d305da7f3a93a.yml -openapi_spec_hash: c02b88f26faaf9fd04177b77d34fd5c3 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-1f1bc5d70a89b56425a3bafbc06a80c233300b5d5d64438aa633597085a45974.yml +openapi_spec_hash: e87e758c5f59476e0ec486fa59455d60 config_hash: 372b187172495fc2f76f05ba016b4a45 From 5c727c9c3d19059956c8a1f7bae17210049d42af Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 04:43:25 +0000 Subject: [PATCH 024/176] feat(api): manual updates --- .stats.yml | 4 +- api.md | 2 + src/brand/dev/resources/brand.py | 88 +++++++++++++++++++ src/brand/dev/types/__init__.py | 2 + src/brand/dev/types/brand_prefetch_params.py | 12 +++ .../dev/types/brand_prefetch_response.py | 18 ++++ tests/api_resources/test_brand.py | 69 +++++++++++++++ 7 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 src/brand/dev/types/brand_prefetch_params.py create mode 100644 src/brand/dev/types/brand_prefetch_response.py diff --git a/.stats.yml b/.stats.yml index 45760cc..91105ad 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 6 +configured_endpoints: 7 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-1f1bc5d70a89b56425a3bafbc06a80c233300b5d5d64438aa633597085a45974.yml openapi_spec_hash: e87e758c5f59476e0ec486fa59455d60 -config_hash: 372b187172495fc2f76f05ba016b4a45 +config_hash: bb3f3ba0dca413263e40968648f9a1a6 diff --git a/api.md b/api.md index da9469f..0792451 100644 --- a/api.md +++ b/api.md @@ -7,6 +7,7 @@ from brand.dev.types import ( BrandRetrieveResponse, BrandAIQueryResponse, BrandIdentifyFromTransactionResponse, + BrandPrefetchResponse, BrandRetrieveByTickerResponse, BrandRetrieveNaicsResponse, BrandSearchResponse, @@ -18,6 +19,7 @@ Methods: - client.brand.retrieve(\*\*params) -> BrandRetrieveResponse - client.brand.ai_query(\*\*params) -> BrandAIQueryResponse - client.brand.identify_from_transaction(\*\*params) -> BrandIdentifyFromTransactionResponse +- client.brand.prefetch(\*\*params) -> BrandPrefetchResponse - client.brand.retrieve_by_ticker(\*\*params) -> BrandRetrieveByTickerResponse - client.brand.retrieve_naics(\*\*params) -> BrandRetrieveNaicsResponse - client.brand.search(\*\*params) -> BrandSearchResponse diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 95ecfa0..7428064 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -10,6 +10,7 @@ from ..types import ( brand_search_params, brand_ai_query_params, + brand_prefetch_params, brand_retrieve_params, brand_retrieve_naics_params, brand_retrieve_by_ticker_params, @@ -28,6 +29,7 @@ from .._base_client import make_request_options from ..types.brand_search_response import BrandSearchResponse from ..types.brand_ai_query_response import BrandAIQueryResponse +from ..types.brand_prefetch_response import BrandPrefetchResponse from ..types.brand_retrieve_response import BrandRetrieveResponse from ..types.brand_retrieve_naics_response import BrandRetrieveNaicsResponse from ..types.brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse @@ -247,6 +249,43 @@ def identify_from_transaction( cast_to=BrandIdentifyFromTransactionResponse, ) + def prefetch( + self, + *, + domain: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandPrefetchResponse: + """ + Signal that you may fetch brand data for a particular domain soon to improve + latency. This endpoint does not charge credits and is available for paid + customers to optimize future requests. [You must be on a paid plan to use this + endpoint] + + Args: + domain: Domain name to prefetch brand data for + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/brand/prefetch", + body=maybe_transform({"domain": domain}, brand_prefetch_params.BrandPrefetchParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrandPrefetchResponse, + ) + def retrieve_by_ticker( self, *, @@ -573,6 +612,43 @@ async def identify_from_transaction( cast_to=BrandIdentifyFromTransactionResponse, ) + async def prefetch( + self, + *, + domain: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandPrefetchResponse: + """ + Signal that you may fetch brand data for a particular domain soon to improve + latency. This endpoint does not charge credits and is available for paid + customers to optimize future requests. [You must be on a paid plan to use this + endpoint] + + Args: + domain: Domain name to prefetch brand data for + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/brand/prefetch", + body=await async_maybe_transform({"domain": domain}, brand_prefetch_params.BrandPrefetchParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrandPrefetchResponse, + ) + async def retrieve_by_ticker( self, *, @@ -705,6 +781,9 @@ def __init__(self, brand: BrandResource) -> None: self.identify_from_transaction = to_raw_response_wrapper( brand.identify_from_transaction, ) + self.prefetch = to_raw_response_wrapper( + brand.prefetch, + ) self.retrieve_by_ticker = to_raw_response_wrapper( brand.retrieve_by_ticker, ) @@ -729,6 +808,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.identify_from_transaction = async_to_raw_response_wrapper( brand.identify_from_transaction, ) + self.prefetch = async_to_raw_response_wrapper( + brand.prefetch, + ) self.retrieve_by_ticker = async_to_raw_response_wrapper( brand.retrieve_by_ticker, ) @@ -753,6 +835,9 @@ def __init__(self, brand: BrandResource) -> None: self.identify_from_transaction = to_streamed_response_wrapper( brand.identify_from_transaction, ) + self.prefetch = to_streamed_response_wrapper( + brand.prefetch, + ) self.retrieve_by_ticker = to_streamed_response_wrapper( brand.retrieve_by_ticker, ) @@ -777,6 +862,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.identify_from_transaction = async_to_streamed_response_wrapper( brand.identify_from_transaction, ) + self.prefetch = async_to_streamed_response_wrapper( + brand.prefetch, + ) self.retrieve_by_ticker = async_to_streamed_response_wrapper( brand.retrieve_by_ticker, ) diff --git a/src/brand/dev/types/__init__.py b/src/brand/dev/types/__init__.py index 00e8bf7..3feb8cc 100644 --- a/src/brand/dev/types/__init__.py +++ b/src/brand/dev/types/__init__.py @@ -4,9 +4,11 @@ from .brand_search_params import BrandSearchParams as BrandSearchParams from .brand_ai_query_params import BrandAIQueryParams as BrandAIQueryParams +from .brand_prefetch_params import BrandPrefetchParams as BrandPrefetchParams from .brand_retrieve_params import BrandRetrieveParams as BrandRetrieveParams from .brand_search_response import BrandSearchResponse as BrandSearchResponse from .brand_ai_query_response import BrandAIQueryResponse as BrandAIQueryResponse +from .brand_prefetch_response import BrandPrefetchResponse as BrandPrefetchResponse from .brand_retrieve_response import BrandRetrieveResponse as BrandRetrieveResponse from .brand_retrieve_naics_params import BrandRetrieveNaicsParams as BrandRetrieveNaicsParams from .brand_retrieve_naics_response import BrandRetrieveNaicsResponse as BrandRetrieveNaicsResponse diff --git a/src/brand/dev/types/brand_prefetch_params.py b/src/brand/dev/types/brand_prefetch_params.py new file mode 100644 index 0000000..2053c51 --- /dev/null +++ b/src/brand/dev/types/brand_prefetch_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["BrandPrefetchParams"] + + +class BrandPrefetchParams(TypedDict, total=False): + domain: Required[str] + """Domain name to prefetch brand data for""" diff --git a/src/brand/dev/types/brand_prefetch_response.py b/src/brand/dev/types/brand_prefetch_response.py new file mode 100644 index 0000000..4995856 --- /dev/null +++ b/src/brand/dev/types/brand_prefetch_response.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrandPrefetchResponse"] + + +class BrandPrefetchResponse(BaseModel): + domain: Optional[str] = None + """The domain that was queued for prefetching""" + + message: Optional[str] = None + """Success message""" + + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 3f1629e..78704e4 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -12,6 +12,7 @@ from brand.dev.types import ( BrandSearchResponse, BrandAIQueryResponse, + BrandPrefetchResponse, BrandRetrieveResponse, BrandRetrieveNaicsResponse, BrandRetrieveByTickerResponse, @@ -185,6 +186,40 @@ def test_streaming_response_identify_from_transaction(self, client: BrandDev) -> assert cast(Any, response.is_closed) is True + @pytest.mark.skip() + @parametrize + def test_method_prefetch(self, client: BrandDev) -> None: + brand = client.brand.prefetch( + domain="domain", + ) + assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_raw_response_prefetch(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.prefetch( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_streaming_response_prefetch(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.prefetch( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip() @parametrize def test_method_retrieve_by_ticker(self, client: BrandDev) -> None: @@ -452,6 +487,40 @@ async def test_streaming_response_identify_from_transaction(self, async_client: assert cast(Any, response.is_closed) is True + @pytest.mark.skip() + @parametrize + async def test_method_prefetch(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.prefetch( + domain="domain", + ) + assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_raw_response_prefetch(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.prefetch( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_streaming_response_prefetch(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.prefetch( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip() @parametrize async def test_method_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: From 14c6d371f1d516232db1c452510bcb9d5bbceaba Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 04:59:35 +0000 Subject: [PATCH 025/176] chore(internal): codegen related update --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2601677..d0ab664 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.1.0" + ".": "1.2.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a9d0496..e3bee89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.1.0" +version = "1.2.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 655680d..f500d38 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.1.0" # x-release-please-version +__version__ = "1.2.0" # x-release-please-version From b7fe431da6afa57adcb435d0d66ad5863bc44d4e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 15:17:34 +0000 Subject: [PATCH 026/176] feat(api): manual updates --- .stats.yml | 4 ++-- src/brand/dev/resources/brand.py | 12 ++++++++++++ src/brand/dev/types/brand_retrieve_params.py | 12 +++++++++++- tests/api_resources/test_brand.py | 2 ++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.stats.yml b/.stats.yml index 91105ad..8c0b71c 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 7 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-1f1bc5d70a89b56425a3bafbc06a80c233300b5d5d64438aa633597085a45974.yml -openapi_spec_hash: e87e758c5f59476e0ec486fa59455d60 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-194e1804daeb0a1bcd2443be8893ab999d428dcacaab17cf355097942627439a.yml +openapi_spec_hash: de3391b2db78e2fb0aaa03d30ec9a0f3 config_hash: bb3f3ba0dca413263e40968648f9a1a6 diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 7428064..3820062 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -117,6 +117,7 @@ def retrieve( "welsh", ] | NotGiven = NOT_GIVEN, + max_speed: bool | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -132,6 +133,10 @@ def retrieve( force_language: Optional parameter to force the language of the retrieved brand data + max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, + the API will skip social media data extraction and external service calls (like + Crunchbase) to return results faster with basic brand information only. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -151,6 +156,7 @@ def retrieve( { "domain": domain, "force_language": force_language, + "max_speed": max_speed, }, brand_retrieve_params.BrandRetrieveParams, ), @@ -480,6 +486,7 @@ async def retrieve( "welsh", ] | NotGiven = NOT_GIVEN, + max_speed: bool | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -495,6 +502,10 @@ async def retrieve( force_language: Optional parameter to force the language of the retrieved brand data + max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, + the API will skip social media data extraction and external service calls (like + Crunchbase) to return results faster with basic brand information only. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -514,6 +525,7 @@ async def retrieve( { "domain": domain, "force_language": force_language, + "max_speed": max_speed, }, brand_retrieve_params.BrandRetrieveParams, ), diff --git a/src/brand/dev/types/brand_retrieve_params.py b/src/brand/dev/types/brand_retrieve_params.py index e28d2db..cba5f6d 100644 --- a/src/brand/dev/types/brand_retrieve_params.py +++ b/src/brand/dev/types/brand_retrieve_params.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing_extensions import Literal, Required, TypedDict +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._utils import PropertyInfo __all__ = ["BrandRetrieveParams"] @@ -66,3 +68,11 @@ class BrandRetrieveParams(TypedDict, total=False): "welsh", ] """Optional parameter to force the language of the retrieved brand data""" + + max_speed: Annotated[bool, PropertyInfo(alias="maxSpeed")] + """Optional parameter to optimize the API call for maximum speed. + + When set to true, the API will skip social media data extraction and external + service calls (like Crunchbase) to return results faster with basic brand + information only. + """ diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 78704e4..d0e5640 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -39,6 +39,7 @@ def test_method_retrieve_with_all_params(self, client: BrandDev) -> None: brand = client.brand.retrieve( domain="domain", force_language="albanian", + max_speed=True, ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) @@ -340,6 +341,7 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncBrandDev brand = await async_client.brand.retrieve( domain="domain", force_language="albanian", + max_speed=True, ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) From 91cd9dfea7abde726ddaa8754a0719594eeb57c9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 15:18:51 +0000 Subject: [PATCH 027/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d0ab664..2a8f4ff 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.2.0" + ".": "1.3.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e3bee89..847f359 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.2.0" +version = "1.3.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index f500d38..8ce81b0 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.2.0" # x-release-please-version +__version__ = "1.3.0" # x-release-please-version From 1611439d4deddea3b4c6b456685cc12a2709ea8d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 15:22:19 +0000 Subject: [PATCH 028/176] feat(api): manual updates --- .stats.yml | 4 ++-- src/brand/dev/resources/brand.py | 8 ++++---- src/brand/dev/types/brand_retrieve_params.py | 5 ++--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.stats.yml b/.stats.yml index 8c0b71c..8862041 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 7 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-194e1804daeb0a1bcd2443be8893ab999d428dcacaab17cf355097942627439a.yml -openapi_spec_hash: de3391b2db78e2fb0aaa03d30ec9a0f3 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-a7423608799de2e84c90b72631f43efa0cf95b539851c18a2516d1a42f4112ad.yml +openapi_spec_hash: f992d862ed76ee352ab0a240ec0a44e9 config_hash: bb3f3ba0dca413263e40968648f9a1a6 diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 3820062..2b589dd 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -134,8 +134,8 @@ def retrieve( force_language: Optional parameter to force the language of the retrieved brand data max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, - the API will skip social media data extraction and external service calls (like - Crunchbase) to return results faster with basic brand information only. + the API will skip time-consuming operations for faster response at the cost of + less comprehensive data. extra_headers: Send extra headers @@ -503,8 +503,8 @@ async def retrieve( force_language: Optional parameter to force the language of the retrieved brand data max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, - the API will skip social media data extraction and external service calls (like - Crunchbase) to return results faster with basic brand information only. + the API will skip time-consuming operations for faster response at the cost of + less comprehensive data. extra_headers: Send extra headers diff --git a/src/brand/dev/types/brand_retrieve_params.py b/src/brand/dev/types/brand_retrieve_params.py index cba5f6d..407f5e4 100644 --- a/src/brand/dev/types/brand_retrieve_params.py +++ b/src/brand/dev/types/brand_retrieve_params.py @@ -72,7 +72,6 @@ class BrandRetrieveParams(TypedDict, total=False): max_speed: Annotated[bool, PropertyInfo(alias="maxSpeed")] """Optional parameter to optimize the API call for maximum speed. - When set to true, the API will skip social media data extraction and external - service calls (like Crunchbase) to return results faster with basic brand - information only. + When set to true, the API will skip time-consuming operations for faster + response at the cost of less comprehensive data. """ From e1e3b0569873c6e22d1ac58dc104f107f79d777e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 15:22:44 +0000 Subject: [PATCH 029/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 8862041..950b0fd 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 7 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-a7423608799de2e84c90b72631f43efa0cf95b539851c18a2516d1a42f4112ad.yml -openapi_spec_hash: f992d862ed76ee352ab0a240ec0a44e9 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-2df861cbe235900388f93a2e603090b713f6c5029e4daf2220bddface7882032.yml +openapi_spec_hash: d5a5643aea6c45631d7df49692cf9328 config_hash: bb3f3ba0dca413263e40968648f9a1a6 From 510b3fab7859c16dfabf99640d4821bf8f2dfe2d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 15:25:53 +0000 Subject: [PATCH 030/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2a8f4ff..3e9af1b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.3.0" + ".": "1.4.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 847f359..838e554 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.3.0" +version = "1.4.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 8ce81b0..74eaf26 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.3.0" # x-release-please-version +__version__ = "1.4.0" # x-release-please-version From bad16d58b7e37a2e2dbbffed91801063f14a3f5e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 8 Jun 2025 09:34:45 +0000 Subject: [PATCH 031/176] feat(api): manual updates --- .stats.yml | 4 +- src/brand/dev/resources/brand.py | 144 ++++++++++++++++-- src/brand/dev/types/brand_ai_query_params.py | 11 +- .../brand_identify_from_transaction_params.py | 11 +- src/brand/dev/types/brand_prefetch_params.py | 11 +- .../types/brand_retrieve_by_ticker_params.py | 11 +- .../dev/types/brand_retrieve_naics_params.py | 11 +- src/brand/dev/types/brand_retrieve_params.py | 7 + src/brand/dev/types/brand_search_params.py | 11 +- tests/api_resources/test_brand.py | 94 ++++++++++++ 10 files changed, 297 insertions(+), 18 deletions(-) diff --git a/.stats.yml b/.stats.yml index 950b0fd..fc24298 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 7 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-2df861cbe235900388f93a2e603090b713f6c5029e4daf2220bddface7882032.yml -openapi_spec_hash: d5a5643aea6c45631d7df49692cf9328 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-a960d67a89f2e62fcb3fb61f13e0cba71a803ff00b378730cf72a8209ae8e36a.yml +openapi_spec_hash: a2c7aa9e4b1e5265d502d3f005ffb5f9 config_hash: bb3f3ba0dca413263e40968648f9a1a6 diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 2b589dd..b3fd81a 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -118,6 +118,7 @@ def retrieve( ] | NotGiven = NOT_GIVEN, max_speed: bool | NotGiven = NOT_GIVEN, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -137,6 +138,10 @@ def retrieve( the API will skip time-consuming operations for faster response at the cost of less comprehensive data. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -157,6 +162,7 @@ def retrieve( "domain": domain, "force_language": force_language, "max_speed": max_speed, + "timeout_ms": timeout_ms, }, brand_retrieve_params.BrandRetrieveParams, ), @@ -170,6 +176,7 @@ def ai_query( data_to_extract: Iterable[brand_ai_query_params.DataToExtract], domain: str, specific_pages: brand_ai_query_params.SpecificPages | NotGiven = NOT_GIVEN, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -190,6 +197,10 @@ def ai_query( specific_pages: Optional object specifying which pages to analyze + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -205,6 +216,7 @@ def ai_query( "data_to_extract": data_to_extract, "domain": domain, "specific_pages": specific_pages, + "timeout_ms": timeout_ms, }, brand_ai_query_params.BrandAIQueryParams, ), @@ -218,6 +230,7 @@ def identify_from_transaction( self, *, transaction_info: str, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -232,6 +245,10 @@ def identify_from_transaction( Args: transaction_info: Transaction information to identify the brand + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -248,7 +265,10 @@ def identify_from_transaction( extra_body=extra_body, timeout=timeout, query=maybe_transform( - {"transaction_info": transaction_info}, + { + "transaction_info": transaction_info, + "timeout_ms": timeout_ms, + }, brand_identify_from_transaction_params.BrandIdentifyFromTransactionParams, ), ), @@ -259,6 +279,7 @@ def prefetch( self, *, domain: str, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -275,6 +296,10 @@ def prefetch( Args: domain: Domain name to prefetch brand data for + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -285,7 +310,13 @@ def prefetch( """ return self._post( "/brand/prefetch", - body=maybe_transform({"domain": domain}, brand_prefetch_params.BrandPrefetchParams), + body=maybe_transform( + { + "domain": domain, + "timeout_ms": timeout_ms, + }, + brand_prefetch_params.BrandPrefetchParams, + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -296,6 +327,7 @@ def retrieve_by_ticker( self, *, ticker: str, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -310,6 +342,10 @@ def retrieve_by_ticker( Args: ticker: Stock ticker symbol to retrieve brand data for (e.g. AAPL, TSLA, etc.) + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -325,7 +361,13 @@ def retrieve_by_ticker( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform({"ticker": ticker}, brand_retrieve_by_ticker_params.BrandRetrieveByTickerParams), + query=maybe_transform( + { + "ticker": ticker, + "timeout_ms": timeout_ms, + }, + brand_retrieve_by_ticker_params.BrandRetrieveByTickerParams, + ), ), cast_to=BrandRetrieveByTickerResponse, ) @@ -334,6 +376,7 @@ def retrieve_naics( self, *, input: str, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -349,6 +392,10 @@ def retrieve_naics( in `input`, it will be used for classification, otherwise, we will search for the brand using the provided title. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -364,7 +411,13 @@ def retrieve_naics( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform({"input": input}, brand_retrieve_naics_params.BrandRetrieveNaicsParams), + query=maybe_transform( + { + "input": input, + "timeout_ms": timeout_ms, + }, + brand_retrieve_naics_params.BrandRetrieveNaicsParams, + ), ), cast_to=BrandRetrieveNaicsResponse, ) @@ -373,6 +426,7 @@ def search( self, *, query: str, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -386,6 +440,10 @@ def search( Args: query: Query string to search brands + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -401,7 +459,13 @@ def search( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform({"query": query}, brand_search_params.BrandSearchParams), + query=maybe_transform( + { + "query": query, + "timeout_ms": timeout_ms, + }, + brand_search_params.BrandSearchParams, + ), ), cast_to=BrandSearchResponse, ) @@ -487,6 +551,7 @@ async def retrieve( ] | NotGiven = NOT_GIVEN, max_speed: bool | NotGiven = NOT_GIVEN, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -506,6 +571,10 @@ async def retrieve( the API will skip time-consuming operations for faster response at the cost of less comprehensive data. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -526,6 +595,7 @@ async def retrieve( "domain": domain, "force_language": force_language, "max_speed": max_speed, + "timeout_ms": timeout_ms, }, brand_retrieve_params.BrandRetrieveParams, ), @@ -539,6 +609,7 @@ async def ai_query( data_to_extract: Iterable[brand_ai_query_params.DataToExtract], domain: str, specific_pages: brand_ai_query_params.SpecificPages | NotGiven = NOT_GIVEN, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -559,6 +630,10 @@ async def ai_query( specific_pages: Optional object specifying which pages to analyze + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -574,6 +649,7 @@ async def ai_query( "data_to_extract": data_to_extract, "domain": domain, "specific_pages": specific_pages, + "timeout_ms": timeout_ms, }, brand_ai_query_params.BrandAIQueryParams, ), @@ -587,6 +663,7 @@ async def identify_from_transaction( self, *, transaction_info: str, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -601,6 +678,10 @@ async def identify_from_transaction( Args: transaction_info: Transaction information to identify the brand + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -617,7 +698,10 @@ async def identify_from_transaction( extra_body=extra_body, timeout=timeout, query=await async_maybe_transform( - {"transaction_info": transaction_info}, + { + "transaction_info": transaction_info, + "timeout_ms": timeout_ms, + }, brand_identify_from_transaction_params.BrandIdentifyFromTransactionParams, ), ), @@ -628,6 +712,7 @@ async def prefetch( self, *, domain: str, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -644,6 +729,10 @@ async def prefetch( Args: domain: Domain name to prefetch brand data for + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -654,7 +743,13 @@ async def prefetch( """ return await self._post( "/brand/prefetch", - body=await async_maybe_transform({"domain": domain}, brand_prefetch_params.BrandPrefetchParams), + body=await async_maybe_transform( + { + "domain": domain, + "timeout_ms": timeout_ms, + }, + brand_prefetch_params.BrandPrefetchParams, + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -665,6 +760,7 @@ async def retrieve_by_ticker( self, *, ticker: str, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -679,6 +775,10 @@ async def retrieve_by_ticker( Args: ticker: Stock ticker symbol to retrieve brand data for (e.g. AAPL, TSLA, etc.) + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -695,7 +795,11 @@ async def retrieve_by_ticker( extra_body=extra_body, timeout=timeout, query=await async_maybe_transform( - {"ticker": ticker}, brand_retrieve_by_ticker_params.BrandRetrieveByTickerParams + { + "ticker": ticker, + "timeout_ms": timeout_ms, + }, + brand_retrieve_by_ticker_params.BrandRetrieveByTickerParams, ), ), cast_to=BrandRetrieveByTickerResponse, @@ -705,6 +809,7 @@ async def retrieve_naics( self, *, input: str, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -720,6 +825,10 @@ async def retrieve_naics( in `input`, it will be used for classification, otherwise, we will search for the brand using the provided title. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -736,7 +845,11 @@ async def retrieve_naics( extra_body=extra_body, timeout=timeout, query=await async_maybe_transform( - {"input": input}, brand_retrieve_naics_params.BrandRetrieveNaicsParams + { + "input": input, + "timeout_ms": timeout_ms, + }, + brand_retrieve_naics_params.BrandRetrieveNaicsParams, ), ), cast_to=BrandRetrieveNaicsResponse, @@ -746,6 +859,7 @@ async def search( self, *, query: str, + timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -759,6 +873,10 @@ async def search( Args: query: Query string to search brands + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -774,7 +892,13 @@ async def search( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=await async_maybe_transform({"query": query}, brand_search_params.BrandSearchParams), + query=await async_maybe_transform( + { + "query": query, + "timeout_ms": timeout_ms, + }, + brand_search_params.BrandSearchParams, + ), ), cast_to=BrandSearchResponse, ) diff --git a/src/brand/dev/types/brand_ai_query_params.py b/src/brand/dev/types/brand_ai_query_params.py index d199778..303a508 100644 --- a/src/brand/dev/types/brand_ai_query_params.py +++ b/src/brand/dev/types/brand_ai_query_params.py @@ -3,7 +3,9 @@ from __future__ import annotations from typing import Iterable -from typing_extensions import Literal, Required, TypedDict +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._utils import PropertyInfo __all__ = ["BrandAIQueryParams", "DataToExtract", "SpecificPages"] @@ -18,6 +20,13 @@ class BrandAIQueryParams(TypedDict, total=False): specific_pages: SpecificPages """Optional object specifying which pages to analyze""" + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ + class DataToExtract(TypedDict, total=False): datapoint_description: Required[str] diff --git a/src/brand/dev/types/brand_identify_from_transaction_params.py b/src/brand/dev/types/brand_identify_from_transaction_params.py index 0112bdf..186f08f 100644 --- a/src/brand/dev/types/brand_identify_from_transaction_params.py +++ b/src/brand/dev/types/brand_identify_from_transaction_params.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing_extensions import Required, TypedDict +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo __all__ = ["BrandIdentifyFromTransactionParams"] @@ -10,3 +12,10 @@ class BrandIdentifyFromTransactionParams(TypedDict, total=False): transaction_info: Required[str] """Transaction information to identify the brand""" + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_prefetch_params.py b/src/brand/dev/types/brand_prefetch_params.py index 2053c51..02c13f6 100644 --- a/src/brand/dev/types/brand_prefetch_params.py +++ b/src/brand/dev/types/brand_prefetch_params.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing_extensions import Required, TypedDict +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo __all__ = ["BrandPrefetchParams"] @@ -10,3 +12,10 @@ class BrandPrefetchParams(TypedDict, total=False): domain: Required[str] """Domain name to prefetch brand data for""" + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_retrieve_by_ticker_params.py b/src/brand/dev/types/brand_retrieve_by_ticker_params.py index 4c63ed2..05d7c79 100644 --- a/src/brand/dev/types/brand_retrieve_by_ticker_params.py +++ b/src/brand/dev/types/brand_retrieve_by_ticker_params.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing_extensions import Required, TypedDict +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo __all__ = ["BrandRetrieveByTickerParams"] @@ -10,3 +12,10 @@ class BrandRetrieveByTickerParams(TypedDict, total=False): ticker: Required[str] """Stock ticker symbol to retrieve brand data for (e.g. AAPL, TSLA, etc.)""" + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_retrieve_naics_params.py b/src/brand/dev/types/brand_retrieve_naics_params.py index 8d954fc..95ad345 100644 --- a/src/brand/dev/types/brand_retrieve_naics_params.py +++ b/src/brand/dev/types/brand_retrieve_naics_params.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing_extensions import Required, TypedDict +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo __all__ = ["BrandRetrieveNaicsParams"] @@ -14,3 +16,10 @@ class BrandRetrieveNaicsParams(TypedDict, total=False): If a valid domain is provided in `input`, it will be used for classification, otherwise, we will search for the brand using the provided title. """ + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_retrieve_params.py b/src/brand/dev/types/brand_retrieve_params.py index 407f5e4..e5e8555 100644 --- a/src/brand/dev/types/brand_retrieve_params.py +++ b/src/brand/dev/types/brand_retrieve_params.py @@ -75,3 +75,10 @@ class BrandRetrieveParams(TypedDict, total=False): When set to true, the API will skip time-consuming operations for faster response at the cost of less comprehensive data. """ + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_search_params.py b/src/brand/dev/types/brand_search_params.py index 5181e54..2541682 100644 --- a/src/brand/dev/types/brand_search_params.py +++ b/src/brand/dev/types/brand_search_params.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing_extensions import Required, TypedDict +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo __all__ = ["BrandSearchParams"] @@ -10,3 +12,10 @@ class BrandSearchParams(TypedDict, total=False): query: Required[str] """Query string to search brands""" + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index d0e5640..dcfe95d 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -40,6 +40,7 @@ def test_method_retrieve_with_all_params(self, client: BrandDev) -> None: domain="domain", force_language="albanian", max_speed=True, + timeout_ms=1, ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) @@ -108,6 +109,7 @@ def test_method_ai_query_with_all_params(self, client: BrandDev) -> None: "privacy_policy": True, "terms_and_conditions": True, }, + timeout_ms=1, ) assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) @@ -161,6 +163,15 @@ def test_method_identify_from_transaction(self, client: BrandDev) -> None: ) assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) + @pytest.mark.skip() + @parametrize + def test_method_identify_from_transaction_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.identify_from_transaction( + transaction_info="transaction_info", + timeout_ms=1, + ) + assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) + @pytest.mark.skip() @parametrize def test_raw_response_identify_from_transaction(self, client: BrandDev) -> None: @@ -195,6 +206,15 @@ def test_method_prefetch(self, client: BrandDev) -> None: ) assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) + @pytest.mark.skip() + @parametrize + def test_method_prefetch_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.prefetch( + domain="domain", + timeout_ms=1, + ) + assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) + @pytest.mark.skip() @parametrize def test_raw_response_prefetch(self, client: BrandDev) -> None: @@ -229,6 +249,15 @@ def test_method_retrieve_by_ticker(self, client: BrandDev) -> None: ) assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + @pytest.mark.skip() + @parametrize + def test_method_retrieve_by_ticker_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.retrieve_by_ticker( + ticker="ticker", + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + @pytest.mark.skip() @parametrize def test_raw_response_retrieve_by_ticker(self, client: BrandDev) -> None: @@ -263,6 +292,15 @@ def test_method_retrieve_naics(self, client: BrandDev) -> None: ) assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) + @pytest.mark.skip() + @parametrize + def test_method_retrieve_naics_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.retrieve_naics( + input="input", + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) + @pytest.mark.skip() @parametrize def test_raw_response_retrieve_naics(self, client: BrandDev) -> None: @@ -297,6 +335,15 @@ def test_method_search(self, client: BrandDev) -> None: ) assert_matches_type(BrandSearchResponse, brand, path=["response"]) + @pytest.mark.skip() + @parametrize + def test_method_search_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.search( + query="query", + timeout_ms=1, + ) + assert_matches_type(BrandSearchResponse, brand, path=["response"]) + @pytest.mark.skip() @parametrize def test_raw_response_search(self, client: BrandDev) -> None: @@ -342,6 +389,7 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncBrandDev domain="domain", force_language="albanian", max_speed=True, + timeout_ms=1, ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) @@ -410,6 +458,7 @@ async def test_method_ai_query_with_all_params(self, async_client: AsyncBrandDev "privacy_policy": True, "terms_and_conditions": True, }, + timeout_ms=1, ) assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) @@ -463,6 +512,15 @@ async def test_method_identify_from_transaction(self, async_client: AsyncBrandDe ) assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) + @pytest.mark.skip() + @parametrize + async def test_method_identify_from_transaction_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.identify_from_transaction( + transaction_info="transaction_info", + timeout_ms=1, + ) + assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) + @pytest.mark.skip() @parametrize async def test_raw_response_identify_from_transaction(self, async_client: AsyncBrandDev) -> None: @@ -497,6 +555,15 @@ async def test_method_prefetch(self, async_client: AsyncBrandDev) -> None: ) assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) + @pytest.mark.skip() + @parametrize + async def test_method_prefetch_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.prefetch( + domain="domain", + timeout_ms=1, + ) + assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) + @pytest.mark.skip() @parametrize async def test_raw_response_prefetch(self, async_client: AsyncBrandDev) -> None: @@ -531,6 +598,15 @@ async def test_method_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> N ) assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + @pytest.mark.skip() + @parametrize + async def test_method_retrieve_by_ticker_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_by_ticker( + ticker="ticker", + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + @pytest.mark.skip() @parametrize async def test_raw_response_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: @@ -565,6 +641,15 @@ async def test_method_retrieve_naics(self, async_client: AsyncBrandDev) -> None: ) assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) + @pytest.mark.skip() + @parametrize + async def test_method_retrieve_naics_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_naics( + input="input", + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) + @pytest.mark.skip() @parametrize async def test_raw_response_retrieve_naics(self, async_client: AsyncBrandDev) -> None: @@ -599,6 +684,15 @@ async def test_method_search(self, async_client: AsyncBrandDev) -> None: ) assert_matches_type(BrandSearchResponse, brand, path=["response"]) + @pytest.mark.skip() + @parametrize + async def test_method_search_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.search( + query="query", + timeout_ms=1, + ) + assert_matches_type(BrandSearchResponse, brand, path=["response"]) + @pytest.mark.skip() @parametrize async def test_raw_response_search(self, async_client: AsyncBrandDev) -> None: From 5c9919c9c6c91a50a7184f2a85a51c8d7c04635b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 8 Jun 2025 09:36:27 +0000 Subject: [PATCH 032/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3e9af1b..fbd9082 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.4.0" + ".": "1.5.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 838e554..84c87d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.4.0" +version = "1.5.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 74eaf26..929055c 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.4.0" # x-release-please-version +__version__ = "1.5.0" # x-release-please-version From f55bad6fec543dc39d69c581de6eec2dff29c289 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 11 Jun 2025 05:30:39 +0000 Subject: [PATCH 033/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index fc24298..6be4170 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 7 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-a960d67a89f2e62fcb3fb61f13e0cba71a803ff00b378730cf72a8209ae8e36a.yml -openapi_spec_hash: a2c7aa9e4b1e5265d502d3f005ffb5f9 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-a0307b03264f35449e6f919d727c947ac3bfaace32b45d04752598ad47bb9f09.yml +openapi_spec_hash: c88aaaf2607057f4f1ff7f5fd06e1b5d config_hash: bb3f3ba0dca413263e40968648f9a1a6 From dfb9c15b4e3892fefa8cccd5e19f6b005c6f5e73 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 11 Jun 2025 05:32:02 +0000 Subject: [PATCH 034/176] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 6be4170..d85b78c 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 7 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-a0307b03264f35449e6f919d727c947ac3bfaace32b45d04752598ad47bb9f09.yml openapi_spec_hash: c88aaaf2607057f4f1ff7f5fd06e1b5d -config_hash: bb3f3ba0dca413263e40968648f9a1a6 +config_hash: 4ab8d35881cc0191126ff443317e03ba From 93cb7634167ad4d8f3d6e4391272b20c381725f8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 11 Jun 2025 18:17:55 +0000 Subject: [PATCH 035/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index d85b78c..e5a78c5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 7 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-a0307b03264f35449e6f919d727c947ac3bfaace32b45d04752598ad47bb9f09.yml -openapi_spec_hash: c88aaaf2607057f4f1ff7f5fd06e1b5d +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-103a28182099d9866bc8c40db00f3356fe5be36302874e7ee84ee4fd2f593859.yml +openapi_spec_hash: 30241efa79f9aab6e430c29383d9a2cf config_hash: 4ab8d35881cc0191126ff443317e03ba From 415ac21d8ced562e39f9f5d9cb43b81ce62e9d97 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 13 Jun 2025 02:02:46 +0000 Subject: [PATCH 036/176] chore(tests): run tests in parallel --- pyproject.toml | 3 ++- requirements-dev.lock | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 84c87d5..e9593a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev-dependencies = [ "importlib-metadata>=6.7.0", "rich>=13.7.1", "nest_asyncio==1.6.0", + "pytest-xdist>=3.6.1", ] [tool.rye.scripts] @@ -125,7 +126,7 @@ replacement = '[\1](https://github.com/brand-dot-dev/python-sdk/tree/main/\g<2>) [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "--tb=short" +addopts = "--tb=short -n auto" xfail_strict = true asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" diff --git a/requirements-dev.lock b/requirements-dev.lock index f169f3b..8c972f9 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -30,6 +30,8 @@ distro==1.8.0 exceptiongroup==1.2.2 # via anyio # via pytest +execnet==2.1.1 + # via pytest-xdist filelock==3.12.4 # via virtualenv h11==0.14.0 @@ -72,7 +74,9 @@ pygments==2.18.0 pyright==1.1.399 pytest==8.3.3 # via pytest-asyncio + # via pytest-xdist pytest-asyncio==0.24.0 +pytest-xdist==3.7.0 python-dateutil==2.8.2 # via time-machine pytz==2023.3.post1 From 301b6f33f66caddec9621236031adf2dd09be767 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 13 Jun 2025 02:27:45 +0000 Subject: [PATCH 037/176] fix(client): correctly parse binary response | stream --- src/brand/dev/_base_client.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/brand/dev/_base_client.py b/src/brand/dev/_base_client.py index 35eb16c..359751d 100644 --- a/src/brand/dev/_base_client.py +++ b/src/brand/dev/_base_client.py @@ -1071,7 +1071,14 @@ def _process_response( ) -> ResponseT: origin = get_origin(cast_to) or cast_to - if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse): + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): if not issubclass(origin, APIResponse): raise TypeError(f"API Response types must subclass {APIResponse}; Received {origin}") @@ -1574,7 +1581,14 @@ async def _process_response( ) -> ResponseT: origin = get_origin(cast_to) or cast_to - if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse): + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): if not issubclass(origin, AsyncAPIResponse): raise TypeError(f"API Response types must subclass {AsyncAPIResponse}; Received {origin}") From f34f1443af64709dfbb9ff2a8e82977b7f5c1e65 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 02:23:22 +0000 Subject: [PATCH 038/176] chore(tests): add tests for httpx client instantiation & proxies --- tests/test_client.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 391b339..9dd52bf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -30,6 +30,8 @@ DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, + DefaultHttpxClient, + DefaultAsyncHttpxClient, make_request_options, ) @@ -814,6 +816,28 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" + def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + @pytest.mark.respx(base_url=base_url) def test_follow_redirects(self, respx_mock: MockRouter) -> None: # Test that the default follow_redirects=True allows following redirects @@ -1663,6 +1687,28 @@ async def test_main() -> None: time.sleep(0.1) + async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultAsyncHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + async def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultAsyncHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + @pytest.mark.respx(base_url=base_url) async def test_follow_redirects(self, respx_mock: MockRouter) -> None: # Test that the default follow_redirects=True allows following redirects From 9d169fc7b6341767776a06b4aab7b8456fb95947 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 03:53:51 +0000 Subject: [PATCH 039/176] chore(internal): update conftest.py --- tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 5486879..4871488 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + from __future__ import annotations import os From 117204046fc8b7ab4cb07003bcf7e7af2c849441 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 06:22:15 +0000 Subject: [PATCH 040/176] chore(ci): enable for pull requests --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5f2211..5b251b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,10 @@ on: - 'integrated/**' - 'stl-preview-head/**' - 'stl-preview-base/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' jobs: lint: From 0df29eb0edccf1352b66182894c74b2a19d0a351 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 02:03:19 +0000 Subject: [PATCH 041/176] chore(readme): update badges --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 81f4734..2156d86 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Brand Dev Python API library -[![PyPI version](https://img.shields.io/pypi/v/brand.dev.svg)](https://pypi.org/project/brand.dev/) +[![PyPI version]()](https://pypi.org/project/brand.dev/) The Brand Dev Python library provides convenient access to the Brand Dev REST API from any Python 3.8+ application. The library includes type definitions for all request params and response fields, From c5ab43421064c85e0677e5a33d211245e0febae8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 05:33:56 +0000 Subject: [PATCH 042/176] fix(tests): fix: tests which call HTTP endpoints directly with the example parameters --- tests/test_client.py | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 9dd52bf..1156a03 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -24,7 +24,6 @@ from brand.dev import BrandDev, AsyncBrandDev, APIResponseValidationError from brand.dev._types import Omit from brand.dev._models import BaseModel, FinalRequestOptions -from brand.dev._constants import RAW_RESPONSE_HEADER from brand.dev._exceptions import BrandDevError, APIStatusError, APITimeoutError, APIResponseValidationError from brand.dev._base_client import ( DEFAULT_TIMEOUT, @@ -713,26 +712,21 @@ def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: BrandDev) -> None: respx_mock.get("/brand/retrieve").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - self.client.get( - "/brand/retrieve", cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}} - ) + client.brand.with_streaming_response.retrieve(domain="domain").__enter__() assert _get_open_connections(self.client) == 0 @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: BrandDev) -> None: respx_mock.get("/brand/retrieve").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - self.client.get( - "/brand/retrieve", cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}} - ) - + client.brand.with_streaming_response.retrieve(domain="domain").__enter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -1536,26 +1530,25 @@ async def test_parse_retry_after_header(self, remaining_retries: int, retry_afte @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + async def test_retrying_timeout_errors_doesnt_leak( + self, respx_mock: MockRouter, async_client: AsyncBrandDev + ) -> None: respx_mock.get("/brand/retrieve").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - await self.client.get( - "/brand/retrieve", cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}} - ) + await async_client.brand.with_streaming_response.retrieve(domain="domain").__aenter__() assert _get_open_connections(self.client) == 0 @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + async def test_retrying_status_errors_doesnt_leak( + self, respx_mock: MockRouter, async_client: AsyncBrandDev + ) -> None: respx_mock.get("/brand/retrieve").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - await self.client.get( - "/brand/retrieve", cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}} - ) - + await async_client.brand.with_streaming_response.retrieve(domain="domain").__aenter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) From 7cb89eff42a49484138bfb015f08b0c464a6642d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 02:36:56 +0000 Subject: [PATCH 043/176] docs(client): fix httpx.Timeout documentation reference --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2156d86..40020f4 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ client.with_options(max_retries=5).brand.retrieve( ### Timeouts By default requests time out after 1 minute. You can configure this with a `timeout` option, -which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/#fine-tuning-the-configuration) object: +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: ```python from brand.dev import BrandDev From 93f92392afc56f2424e0df92fb1853f8f40d6d8f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 19:43:28 +0000 Subject: [PATCH 044/176] feat(api): manual updates --- .stats.yml | 8 +- api.md | 4 + src/brand/dev/resources/brand.py | 236 ++++++++++++++ src/brand/dev/types/__init__.py | 4 + .../dev/types/brand_screenshot_params.py | 24 ++ .../dev/types/brand_screenshot_response.py | 27 ++ .../dev/types/brand_styleguide_params.py | 24 ++ .../dev/types/brand_styleguide_response.py | 291 ++++++++++++++++++ tests/api_resources/test_brand.py | 174 +++++++++++ 9 files changed, 788 insertions(+), 4 deletions(-) create mode 100644 src/brand/dev/types/brand_screenshot_params.py create mode 100644 src/brand/dev/types/brand_screenshot_response.py create mode 100644 src/brand/dev/types/brand_styleguide_params.py create mode 100644 src/brand/dev/types/brand_styleguide_response.py diff --git a/.stats.yml b/.stats.yml index e5a78c5..137e5c9 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 7 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-103a28182099d9866bc8c40db00f3356fe5be36302874e7ee84ee4fd2f593859.yml -openapi_spec_hash: 30241efa79f9aab6e430c29383d9a2cf -config_hash: 4ab8d35881cc0191126ff443317e03ba +configured_endpoints: 9 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-3f6d4c0f819a0d3128951d315ad216df22050fbc47c5f601d261d56f8b1d80a5.yml +openapi_spec_hash: 8e7953259a1b6bd7440a780eccad1742 +config_hash: 6ce904be333b502d5fd8b746088aad6d diff --git a/api.md b/api.md index 0792451..1f7efcb 100644 --- a/api.md +++ b/api.md @@ -10,7 +10,9 @@ from brand.dev.types import ( BrandPrefetchResponse, BrandRetrieveByTickerResponse, BrandRetrieveNaicsResponse, + BrandScreenshotResponse, BrandSearchResponse, + BrandStyleguideResponse, ) ``` @@ -22,4 +24,6 @@ Methods: - client.brand.prefetch(\*\*params) -> BrandPrefetchResponse - client.brand.retrieve_by_ticker(\*\*params) -> BrandRetrieveByTickerResponse - client.brand.retrieve_naics(\*\*params) -> BrandRetrieveNaicsResponse +- client.brand.screenshot(\*\*params) -> BrandScreenshotResponse - client.brand.search(\*\*params) -> BrandSearchResponse +- client.brand.styleguide(\*\*params) -> BrandStyleguideResponse diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index b3fd81a..3be4c62 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -12,6 +12,8 @@ brand_ai_query_params, brand_prefetch_params, brand_retrieve_params, + brand_screenshot_params, + brand_styleguide_params, brand_retrieve_naics_params, brand_retrieve_by_ticker_params, brand_identify_from_transaction_params, @@ -31,6 +33,8 @@ from ..types.brand_ai_query_response import BrandAIQueryResponse from ..types.brand_prefetch_response import BrandPrefetchResponse from ..types.brand_retrieve_response import BrandRetrieveResponse +from ..types.brand_screenshot_response import BrandScreenshotResponse +from ..types.brand_styleguide_response import BrandStyleguideResponse from ..types.brand_retrieve_naics_response import BrandRetrieveNaicsResponse from ..types.brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse from ..types.brand_identify_from_transaction_response import BrandIdentifyFromTransactionResponse @@ -422,6 +426,58 @@ def retrieve_naics( cast_to=BrandRetrieveNaicsResponse, ) + def screenshot( + self, + *, + domain: str, + full_screenshot: Literal["true", "false"] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandScreenshotResponse: + """Beta feature: Capture a screenshot of a website. + + Supports both viewport + (standard browser view) and full-page screenshots. Returns a URL to the uploaded + screenshot image hosted on our CDN. + + Args: + domain: Domain name to take screenshot of (e.g., 'example.com', 'google.com'). The + domain will be automatically normalized and validated. + + full_screenshot: Optional parameter to determine screenshot type. If 'true', takes a full page + screenshot capturing all content. If 'false' or not provided, takes a viewport + screenshot (standard browser view). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/screenshot", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "domain": domain, + "full_screenshot": full_screenshot, + }, + brand_screenshot_params.BrandScreenshotParams, + ), + ), + cast_to=BrandScreenshotResponse, + ) + def search( self, *, @@ -470,6 +526,58 @@ def search( cast_to=BrandSearchResponse, ) + def styleguide( + self, + *, + domain: str, + timeout_ms: int | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandStyleguideResponse: + """ + Beta feature: Automatically extract comprehensive design system information from + a brand's website including colors, typography, spacing, shadows, and UI + components. Uses AI-powered analysis of website screenshots to identify design + patterns and create a reusable styleguide. + + Args: + domain: Domain name to extract styleguide from (e.g., 'example.com', 'google.com'). The + domain will be automatically normalized and validated. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/styleguide", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "domain": domain, + "timeout_ms": timeout_ms, + }, + brand_styleguide_params.BrandStyleguideParams, + ), + ), + cast_to=BrandStyleguideResponse, + ) + class AsyncBrandResource(AsyncAPIResource): @cached_property @@ -855,6 +963,58 @@ async def retrieve_naics( cast_to=BrandRetrieveNaicsResponse, ) + async def screenshot( + self, + *, + domain: str, + full_screenshot: Literal["true", "false"] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandScreenshotResponse: + """Beta feature: Capture a screenshot of a website. + + Supports both viewport + (standard browser view) and full-page screenshots. Returns a URL to the uploaded + screenshot image hosted on our CDN. + + Args: + domain: Domain name to take screenshot of (e.g., 'example.com', 'google.com'). The + domain will be automatically normalized and validated. + + full_screenshot: Optional parameter to determine screenshot type. If 'true', takes a full page + screenshot capturing all content. If 'false' or not provided, takes a viewport + screenshot (standard browser view). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/brand/screenshot", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "domain": domain, + "full_screenshot": full_screenshot, + }, + brand_screenshot_params.BrandScreenshotParams, + ), + ), + cast_to=BrandScreenshotResponse, + ) + async def search( self, *, @@ -903,6 +1063,58 @@ async def search( cast_to=BrandSearchResponse, ) + async def styleguide( + self, + *, + domain: str, + timeout_ms: int | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandStyleguideResponse: + """ + Beta feature: Automatically extract comprehensive design system information from + a brand's website including colors, typography, spacing, shadows, and UI + components. Uses AI-powered analysis of website screenshots to identify design + patterns and create a reusable styleguide. + + Args: + domain: Domain name to extract styleguide from (e.g., 'example.com', 'google.com'). The + domain will be automatically normalized and validated. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/brand/styleguide", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "domain": domain, + "timeout_ms": timeout_ms, + }, + brand_styleguide_params.BrandStyleguideParams, + ), + ), + cast_to=BrandStyleguideResponse, + ) + class BrandResourceWithRawResponse: def __init__(self, brand: BrandResource) -> None: @@ -926,9 +1138,15 @@ def __init__(self, brand: BrandResource) -> None: self.retrieve_naics = to_raw_response_wrapper( brand.retrieve_naics, ) + self.screenshot = to_raw_response_wrapper( + brand.screenshot, + ) self.search = to_raw_response_wrapper( brand.search, ) + self.styleguide = to_raw_response_wrapper( + brand.styleguide, + ) class AsyncBrandResourceWithRawResponse: @@ -953,9 +1171,15 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.retrieve_naics = async_to_raw_response_wrapper( brand.retrieve_naics, ) + self.screenshot = async_to_raw_response_wrapper( + brand.screenshot, + ) self.search = async_to_raw_response_wrapper( brand.search, ) + self.styleguide = async_to_raw_response_wrapper( + brand.styleguide, + ) class BrandResourceWithStreamingResponse: @@ -980,9 +1204,15 @@ def __init__(self, brand: BrandResource) -> None: self.retrieve_naics = to_streamed_response_wrapper( brand.retrieve_naics, ) + self.screenshot = to_streamed_response_wrapper( + brand.screenshot, + ) self.search = to_streamed_response_wrapper( brand.search, ) + self.styleguide = to_streamed_response_wrapper( + brand.styleguide, + ) class AsyncBrandResourceWithStreamingResponse: @@ -1007,6 +1237,12 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.retrieve_naics = async_to_streamed_response_wrapper( brand.retrieve_naics, ) + self.screenshot = async_to_streamed_response_wrapper( + brand.screenshot, + ) self.search = async_to_streamed_response_wrapper( brand.search, ) + self.styleguide = async_to_streamed_response_wrapper( + brand.styleguide, + ) diff --git a/src/brand/dev/types/__init__.py b/src/brand/dev/types/__init__.py index 3feb8cc..50cd9a3 100644 --- a/src/brand/dev/types/__init__.py +++ b/src/brand/dev/types/__init__.py @@ -10,6 +10,10 @@ from .brand_ai_query_response import BrandAIQueryResponse as BrandAIQueryResponse from .brand_prefetch_response import BrandPrefetchResponse as BrandPrefetchResponse from .brand_retrieve_response import BrandRetrieveResponse as BrandRetrieveResponse +from .brand_screenshot_params import BrandScreenshotParams as BrandScreenshotParams +from .brand_styleguide_params import BrandStyleguideParams as BrandStyleguideParams +from .brand_screenshot_response import BrandScreenshotResponse as BrandScreenshotResponse +from .brand_styleguide_response import BrandStyleguideResponse as BrandStyleguideResponse from .brand_retrieve_naics_params import BrandRetrieveNaicsParams as BrandRetrieveNaicsParams from .brand_retrieve_naics_response import BrandRetrieveNaicsResponse as BrandRetrieveNaicsResponse from .brand_retrieve_by_ticker_params import BrandRetrieveByTickerParams as BrandRetrieveByTickerParams diff --git a/src/brand/dev/types/brand_screenshot_params.py b/src/brand/dev/types/brand_screenshot_params.py new file mode 100644 index 0000000..5027d90 --- /dev/null +++ b/src/brand/dev/types/brand_screenshot_params.py @@ -0,0 +1,24 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["BrandScreenshotParams"] + + +class BrandScreenshotParams(TypedDict, total=False): + domain: Required[str] + """Domain name to take screenshot of (e.g., 'example.com', 'google.com'). + + The domain will be automatically normalized and validated. + """ + + full_screenshot: Annotated[Literal["true", "false"], PropertyInfo(alias="fullScreenshot")] + """Optional parameter to determine screenshot type. + + If 'true', takes a full page screenshot capturing all content. If 'false' or not + provided, takes a viewport screenshot (standard browser view). + """ diff --git a/src/brand/dev/types/brand_screenshot_response.py b/src/brand/dev/types/brand_screenshot_response.py new file mode 100644 index 0000000..c43ae74 --- /dev/null +++ b/src/brand/dev/types/brand_screenshot_response.py @@ -0,0 +1,27 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["BrandScreenshotResponse"] + + +class BrandScreenshotResponse(BaseModel): + code: Optional[int] = None + """HTTP status code""" + + domain: Optional[str] = None + """The normalized domain that was processed""" + + screenshot: Optional[str] = None + """Public URL of the uploaded screenshot image""" + + screenshot_type: Optional[Literal["viewport", "fullPage"]] = FieldInfo(alias="screenshotType", default=None) + """Type of screenshot that was captured""" + + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" diff --git a/src/brand/dev/types/brand_styleguide_params.py b/src/brand/dev/types/brand_styleguide_params.py new file mode 100644 index 0000000..42b3f38 --- /dev/null +++ b/src/brand/dev/types/brand_styleguide_params.py @@ -0,0 +1,24 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["BrandStyleguideParams"] + + +class BrandStyleguideParams(TypedDict, total=False): + domain: Required[str] + """Domain name to extract styleguide from (e.g., 'example.com', 'google.com'). + + The domain will be automatically normalized and validated. + """ + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_styleguide_response.py b/src/brand/dev/types/brand_styleguide_response.py new file mode 100644 index 0000000..37198f2 --- /dev/null +++ b/src/brand/dev/types/brand_styleguide_response.py @@ -0,0 +1,291 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = [ + "BrandStyleguideResponse", + "Styleguide", + "StyleguideColors", + "StyleguideComponents", + "StyleguideComponentsButton", + "StyleguideComponentsButtonLink", + "StyleguideComponentsButtonPrimary", + "StyleguideComponentsButtonSecondary", + "StyleguideComponentsCard", + "StyleguideElementSpacing", + "StyleguideShadows", + "StyleguideTypography", + "StyleguideTypographyHeadings", + "StyleguideTypographyHeadingsH1", + "StyleguideTypographyHeadingsH2", + "StyleguideTypographyHeadingsH3", + "StyleguideTypographyHeadingsH4", + "StyleguideTypographyP", +] + + +class StyleguideColors(BaseModel): + accent: Optional[str] = None + """Accent color of the website (hex format)""" + + background: Optional[str] = None + """Background color of the website (hex format)""" + + text: Optional[str] = None + """Text color of the website (hex format)""" + + +class StyleguideComponentsButtonLink(BaseModel): + background_color: Optional[str] = FieldInfo(alias="backgroundColor", default=None) + + border_color: Optional[str] = FieldInfo(alias="borderColor", default=None) + + border_radius: Optional[str] = FieldInfo(alias="borderRadius", default=None) + + border_style: Optional[str] = FieldInfo(alias="borderStyle", default=None) + + border_width: Optional[str] = FieldInfo(alias="borderWidth", default=None) + + box_shadow: Optional[str] = FieldInfo(alias="boxShadow", default=None) + + color: Optional[str] = None + + font_size: Optional[str] = FieldInfo(alias="fontSize", default=None) + + font_weight: Optional[float] = FieldInfo(alias="fontWeight", default=None) + + padding: Optional[str] = None + + text_decoration: Optional[str] = FieldInfo(alias="textDecoration", default=None) + + +class StyleguideComponentsButtonPrimary(BaseModel): + background_color: Optional[str] = FieldInfo(alias="backgroundColor", default=None) + + border_color: Optional[str] = FieldInfo(alias="borderColor", default=None) + + border_radius: Optional[str] = FieldInfo(alias="borderRadius", default=None) + + border_style: Optional[str] = FieldInfo(alias="borderStyle", default=None) + + border_width: Optional[str] = FieldInfo(alias="borderWidth", default=None) + + box_shadow: Optional[str] = FieldInfo(alias="boxShadow", default=None) + + color: Optional[str] = None + + font_size: Optional[str] = FieldInfo(alias="fontSize", default=None) + + font_weight: Optional[float] = FieldInfo(alias="fontWeight", default=None) + + padding: Optional[str] = None + + text_decoration: Optional[str] = FieldInfo(alias="textDecoration", default=None) + + +class StyleguideComponentsButtonSecondary(BaseModel): + background_color: Optional[str] = FieldInfo(alias="backgroundColor", default=None) + + border_color: Optional[str] = FieldInfo(alias="borderColor", default=None) + + border_radius: Optional[str] = FieldInfo(alias="borderRadius", default=None) + + border_style: Optional[str] = FieldInfo(alias="borderStyle", default=None) + + border_width: Optional[str] = FieldInfo(alias="borderWidth", default=None) + + box_shadow: Optional[str] = FieldInfo(alias="boxShadow", default=None) + + color: Optional[str] = None + + font_size: Optional[str] = FieldInfo(alias="fontSize", default=None) + + font_weight: Optional[float] = FieldInfo(alias="fontWeight", default=None) + + padding: Optional[str] = None + + text_decoration: Optional[str] = FieldInfo(alias="textDecoration", default=None) + + +class StyleguideComponentsButton(BaseModel): + link: Optional[StyleguideComponentsButtonLink] = None + """Link button style""" + + primary: Optional[StyleguideComponentsButtonPrimary] = None + """Primary button style""" + + secondary: Optional[StyleguideComponentsButtonSecondary] = None + """Secondary button style""" + + +class StyleguideComponentsCard(BaseModel): + background_color: Optional[str] = FieldInfo(alias="backgroundColor", default=None) + + border_color: Optional[str] = FieldInfo(alias="borderColor", default=None) + + border_radius: Optional[str] = FieldInfo(alias="borderRadius", default=None) + + border_style: Optional[str] = FieldInfo(alias="borderStyle", default=None) + + border_width: Optional[str] = FieldInfo(alias="borderWidth", default=None) + + box_shadow: Optional[str] = FieldInfo(alias="boxShadow", default=None) + + padding: Optional[str] = None + + text_color: Optional[str] = FieldInfo(alias="textColor", default=None) + + +class StyleguideComponents(BaseModel): + button: Optional[StyleguideComponentsButton] = None + """Button component styles""" + + card: Optional[StyleguideComponentsCard] = None + """Card component style""" + + +class StyleguideElementSpacing(BaseModel): + lg: Optional[str] = None + """Large spacing value""" + + md: Optional[str] = None + """Medium spacing value""" + + sm: Optional[str] = None + """Small spacing value""" + + xl: Optional[str] = None + """Extra large spacing value""" + + xs: Optional[str] = None + """Extra small spacing value""" + + +class StyleguideShadows(BaseModel): + inner: Optional[str] = None + """Inner shadow value""" + + lg: Optional[str] = None + """Large shadow value""" + + md: Optional[str] = None + """Medium shadow value""" + + sm: Optional[str] = None + """Small shadow value""" + + xl: Optional[str] = None + """Extra large shadow value""" + + +class StyleguideTypographyHeadingsH1(BaseModel): + font_family: Optional[str] = FieldInfo(alias="fontFamily", default=None) + + font_size: Optional[str] = FieldInfo(alias="fontSize", default=None) + + font_weight: Optional[float] = FieldInfo(alias="fontWeight", default=None) + + letter_spacing: Optional[str] = FieldInfo(alias="letterSpacing", default=None) + + line_height: Optional[str] = FieldInfo(alias="lineHeight", default=None) + + +class StyleguideTypographyHeadingsH2(BaseModel): + font_family: Optional[str] = FieldInfo(alias="fontFamily", default=None) + + font_size: Optional[str] = FieldInfo(alias="fontSize", default=None) + + font_weight: Optional[float] = FieldInfo(alias="fontWeight", default=None) + + letter_spacing: Optional[str] = FieldInfo(alias="letterSpacing", default=None) + + line_height: Optional[str] = FieldInfo(alias="lineHeight", default=None) + + +class StyleguideTypographyHeadingsH3(BaseModel): + font_family: Optional[str] = FieldInfo(alias="fontFamily", default=None) + + font_size: Optional[str] = FieldInfo(alias="fontSize", default=None) + + font_weight: Optional[float] = FieldInfo(alias="fontWeight", default=None) + + letter_spacing: Optional[str] = FieldInfo(alias="letterSpacing", default=None) + + line_height: Optional[str] = FieldInfo(alias="lineHeight", default=None) + + +class StyleguideTypographyHeadingsH4(BaseModel): + font_family: Optional[str] = FieldInfo(alias="fontFamily", default=None) + + font_size: Optional[str] = FieldInfo(alias="fontSize", default=None) + + font_weight: Optional[float] = FieldInfo(alias="fontWeight", default=None) + + letter_spacing: Optional[str] = FieldInfo(alias="letterSpacing", default=None) + + line_height: Optional[str] = FieldInfo(alias="lineHeight", default=None) + + +class StyleguideTypographyHeadings(BaseModel): + h1: Optional[StyleguideTypographyHeadingsH1] = None + + h2: Optional[StyleguideTypographyHeadingsH2] = None + + h3: Optional[StyleguideTypographyHeadingsH3] = None + + h4: Optional[StyleguideTypographyHeadingsH4] = None + + +class StyleguideTypographyP(BaseModel): + font_family: Optional[str] = FieldInfo(alias="fontFamily", default=None) + + font_size: Optional[str] = FieldInfo(alias="fontSize", default=None) + + font_weight: Optional[float] = FieldInfo(alias="fontWeight", default=None) + + letter_spacing: Optional[str] = FieldInfo(alias="letterSpacing", default=None) + + line_height: Optional[str] = FieldInfo(alias="lineHeight", default=None) + + +class StyleguideTypography(BaseModel): + headings: Optional[StyleguideTypographyHeadings] = None + """Heading styles""" + + p: Optional[StyleguideTypographyP] = None + """Paragraph text styles""" + + +class Styleguide(BaseModel): + colors: Optional[StyleguideColors] = None + """Primary colors used on the website""" + + components: Optional[StyleguideComponents] = None + """UI component styles""" + + element_spacing: Optional[StyleguideElementSpacing] = FieldInfo(alias="elementSpacing", default=None) + """Spacing system used on the website""" + + shadows: Optional[StyleguideShadows] = None + """Shadow styles used on the website""" + + typography: Optional[StyleguideTypography] = None + """Typography styles used on the website""" + + +class BrandStyleguideResponse(BaseModel): + code: Optional[int] = None + """HTTP status code""" + + domain: Optional[str] = None + """The normalized domain that was processed""" + + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" + + styleguide: Optional[Styleguide] = None + """Comprehensive styleguide data extracted from the website""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index dcfe95d..a64940a 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -14,6 +14,8 @@ BrandAIQueryResponse, BrandPrefetchResponse, BrandRetrieveResponse, + BrandScreenshotResponse, + BrandStyleguideResponse, BrandRetrieveNaicsResponse, BrandRetrieveByTickerResponse, BrandIdentifyFromTransactionResponse, @@ -327,6 +329,49 @@ def test_streaming_response_retrieve_naics(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True + @pytest.mark.skip() + @parametrize + def test_method_screenshot(self, client: BrandDev) -> None: + brand = client.brand.screenshot( + domain="domain", + ) + assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_method_screenshot_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.screenshot( + domain="domain", + full_screenshot="true", + ) + assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_raw_response_screenshot(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.screenshot( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_streaming_response_screenshot(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.screenshot( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip() @parametrize def test_method_search(self, client: BrandDev) -> None: @@ -370,6 +415,49 @@ def test_streaming_response_search(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True + @pytest.mark.skip() + @parametrize + def test_method_styleguide(self, client: BrandDev) -> None: + brand = client.brand.styleguide( + domain="domain", + ) + assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_method_styleguide_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.styleguide( + domain="domain", + timeout_ms=1, + ) + assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_raw_response_styleguide(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.styleguide( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_streaming_response_styleguide(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.styleguide( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + class TestAsyncBrand: parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) @@ -676,6 +764,49 @@ async def test_streaming_response_retrieve_naics(self, async_client: AsyncBrandD assert cast(Any, response.is_closed) is True + @pytest.mark.skip() + @parametrize + async def test_method_screenshot(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.screenshot( + domain="domain", + ) + assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_method_screenshot_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.screenshot( + domain="domain", + full_screenshot="true", + ) + assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_raw_response_screenshot(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.screenshot( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_streaming_response_screenshot(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.screenshot( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip() @parametrize async def test_method_search(self, async_client: AsyncBrandDev) -> None: @@ -718,3 +849,46 @@ async def test_streaming_response_search(self, async_client: AsyncBrandDev) -> N assert_matches_type(BrandSearchResponse, brand, path=["response"]) assert cast(Any, response.is_closed) is True + + @pytest.mark.skip() + @parametrize + async def test_method_styleguide(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.styleguide( + domain="domain", + ) + assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_method_styleguide_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.styleguide( + domain="domain", + timeout_ms=1, + ) + assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_raw_response_styleguide(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.styleguide( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_streaming_response_styleguide(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.styleguide( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True From 5d2495c7f97ad7064b9a20775b842596b061e0a0 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 19:45:19 +0000 Subject: [PATCH 045/176] feat(api): manual updates --- .stats.yml | 4 +- api.md | 2 + src/brand/dev/resources/brand.py | 114 ++++++++++++++++ src/brand/dev/types/__init__.py | 2 + .../types/brand_retrieve_simplified_params.py | 21 +++ .../brand_retrieve_simplified_response.py | 122 ++++++++++++++++++ tests/api_resources/test_brand.py | 87 +++++++++++++ 7 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 src/brand/dev/types/brand_retrieve_simplified_params.py create mode 100644 src/brand/dev/types/brand_retrieve_simplified_response.py diff --git a/.stats.yml b/.stats.yml index 137e5c9..f0d7da7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 9 +configured_endpoints: 10 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-3f6d4c0f819a0d3128951d315ad216df22050fbc47c5f601d261d56f8b1d80a5.yml openapi_spec_hash: 8e7953259a1b6bd7440a780eccad1742 -config_hash: 6ce904be333b502d5fd8b746088aad6d +config_hash: 8f3ee44d690a305369555016a77ed016 diff --git a/api.md b/api.md index 1f7efcb..da93d3d 100644 --- a/api.md +++ b/api.md @@ -10,6 +10,7 @@ from brand.dev.types import ( BrandPrefetchResponse, BrandRetrieveByTickerResponse, BrandRetrieveNaicsResponse, + BrandRetrieveSimplifiedResponse, BrandScreenshotResponse, BrandSearchResponse, BrandStyleguideResponse, @@ -24,6 +25,7 @@ Methods: - client.brand.prefetch(\*\*params) -> BrandPrefetchResponse - client.brand.retrieve_by_ticker(\*\*params) -> BrandRetrieveByTickerResponse - client.brand.retrieve_naics(\*\*params) -> BrandRetrieveNaicsResponse +- client.brand.retrieve_simplified(\*\*params) -> BrandRetrieveSimplifiedResponse - client.brand.screenshot(\*\*params) -> BrandScreenshotResponse - client.brand.search(\*\*params) -> BrandSearchResponse - client.brand.styleguide(\*\*params) -> BrandStyleguideResponse diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 3be4c62..542b0ff 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -16,6 +16,7 @@ brand_styleguide_params, brand_retrieve_naics_params, brand_retrieve_by_ticker_params, + brand_retrieve_simplified_params, brand_identify_from_transaction_params, ) from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven @@ -37,6 +38,7 @@ from ..types.brand_styleguide_response import BrandStyleguideResponse from ..types.brand_retrieve_naics_response import BrandRetrieveNaicsResponse from ..types.brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse +from ..types.brand_retrieve_simplified_response import BrandRetrieveSimplifiedResponse from ..types.brand_identify_from_transaction_response import BrandIdentifyFromTransactionResponse __all__ = ["BrandResource", "AsyncBrandResource"] @@ -426,6 +428,56 @@ def retrieve_naics( cast_to=BrandRetrieveNaicsResponse, ) + def retrieve_simplified( + self, + *, + domain: str, + timeout_ms: int | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandRetrieveSimplifiedResponse: + """ + Returns a simplified version of brand data containing only essential + information: domain, title, colors, logos, and backdrops. This endpoint is + optimized for faster responses and reduced data transfer. + + Args: + domain: Domain name to retrieve simplified brand data for + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/retrieve-simplified", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "domain": domain, + "timeout_ms": timeout_ms, + }, + brand_retrieve_simplified_params.BrandRetrieveSimplifiedParams, + ), + ), + cast_to=BrandRetrieveSimplifiedResponse, + ) + def screenshot( self, *, @@ -963,6 +1015,56 @@ async def retrieve_naics( cast_to=BrandRetrieveNaicsResponse, ) + async def retrieve_simplified( + self, + *, + domain: str, + timeout_ms: int | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> BrandRetrieveSimplifiedResponse: + """ + Returns a simplified version of brand data containing only essential + information: domain, title, colors, logos, and backdrops. This endpoint is + optimized for faster responses and reduced data transfer. + + Args: + domain: Domain name to retrieve simplified brand data for + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/brand/retrieve-simplified", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "domain": domain, + "timeout_ms": timeout_ms, + }, + brand_retrieve_simplified_params.BrandRetrieveSimplifiedParams, + ), + ), + cast_to=BrandRetrieveSimplifiedResponse, + ) + async def screenshot( self, *, @@ -1138,6 +1240,9 @@ def __init__(self, brand: BrandResource) -> None: self.retrieve_naics = to_raw_response_wrapper( brand.retrieve_naics, ) + self.retrieve_simplified = to_raw_response_wrapper( + brand.retrieve_simplified, + ) self.screenshot = to_raw_response_wrapper( brand.screenshot, ) @@ -1171,6 +1276,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.retrieve_naics = async_to_raw_response_wrapper( brand.retrieve_naics, ) + self.retrieve_simplified = async_to_raw_response_wrapper( + brand.retrieve_simplified, + ) self.screenshot = async_to_raw_response_wrapper( brand.screenshot, ) @@ -1204,6 +1312,9 @@ def __init__(self, brand: BrandResource) -> None: self.retrieve_naics = to_streamed_response_wrapper( brand.retrieve_naics, ) + self.retrieve_simplified = to_streamed_response_wrapper( + brand.retrieve_simplified, + ) self.screenshot = to_streamed_response_wrapper( brand.screenshot, ) @@ -1237,6 +1348,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.retrieve_naics = async_to_streamed_response_wrapper( brand.retrieve_naics, ) + self.retrieve_simplified = async_to_streamed_response_wrapper( + brand.retrieve_simplified, + ) self.screenshot = async_to_streamed_response_wrapper( brand.screenshot, ) diff --git a/src/brand/dev/types/__init__.py b/src/brand/dev/types/__init__.py index 50cd9a3..c7428ce 100644 --- a/src/brand/dev/types/__init__.py +++ b/src/brand/dev/types/__init__.py @@ -17,7 +17,9 @@ from .brand_retrieve_naics_params import BrandRetrieveNaicsParams as BrandRetrieveNaicsParams from .brand_retrieve_naics_response import BrandRetrieveNaicsResponse as BrandRetrieveNaicsResponse from .brand_retrieve_by_ticker_params import BrandRetrieveByTickerParams as BrandRetrieveByTickerParams +from .brand_retrieve_simplified_params import BrandRetrieveSimplifiedParams as BrandRetrieveSimplifiedParams from .brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse as BrandRetrieveByTickerResponse +from .brand_retrieve_simplified_response import BrandRetrieveSimplifiedResponse as BrandRetrieveSimplifiedResponse from .brand_identify_from_transaction_params import ( BrandIdentifyFromTransactionParams as BrandIdentifyFromTransactionParams, ) diff --git a/src/brand/dev/types/brand_retrieve_simplified_params.py b/src/brand/dev/types/brand_retrieve_simplified_params.py new file mode 100644 index 0000000..b9d9cd3 --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_simplified_params.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["BrandRetrieveSimplifiedParams"] + + +class BrandRetrieveSimplifiedParams(TypedDict, total=False): + domain: Required[str] + """Domain name to retrieve simplified brand data for""" + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_retrieve_simplified_response.py b/src/brand/dev/types/brand_retrieve_simplified_response.py new file mode 100644 index 0000000..b865ff8 --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_simplified_response.py @@ -0,0 +1,122 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from .._models import BaseModel + +__all__ = [ + "BrandRetrieveSimplifiedResponse", + "Brand", + "BrandBackdrop", + "BrandBackdropColor", + "BrandBackdropResolution", + "BrandColor", + "BrandLogo", + "BrandLogoColor", + "BrandLogoResolution", +] + + +class BrandBackdropColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandBackdropResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandBackdrop(BaseModel): + colors: Optional[List[BrandBackdropColor]] = None + """Array of colors in the backdrop image""" + + resolution: Optional[BrandBackdropResolution] = None + """Resolution of the backdrop image""" + + url: Optional[str] = None + """URL of the backdrop image""" + + +class BrandColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandLogoColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandLogoResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandLogo(BaseModel): + colors: Optional[List[BrandLogoColor]] = None + """Array of colors in the logo""" + + group: Optional[int] = None + """Group identifier for logos""" + + mode: Optional[str] = None + """Mode of the logo, e.g., 'dark', 'light'""" + + resolution: Optional[BrandLogoResolution] = None + """Resolution of the logo image""" + + type: Optional[str] = None + """Type of the logo based on resolution (e.g., 'icon', 'logo', 'banner')""" + + url: Optional[str] = None + """URL of the logo image""" + + +class Brand(BaseModel): + backdrops: Optional[List[BrandBackdrop]] = None + """An array of backdrop images for the brand""" + + colors: Optional[List[BrandColor]] = None + """An array of brand colors""" + + domain: Optional[str] = None + """The domain name of the brand""" + + logos: Optional[List[BrandLogo]] = None + """An array of logos associated with the brand""" + + title: Optional[str] = None + """The title or name of the brand""" + + +class BrandRetrieveSimplifiedResponse(BaseModel): + brand: Optional[Brand] = None + """Simplified brand information""" + + code: Optional[int] = None + """HTTP status code of the response""" + + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index a64940a..5647f52 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -18,6 +18,7 @@ BrandStyleguideResponse, BrandRetrieveNaicsResponse, BrandRetrieveByTickerResponse, + BrandRetrieveSimplifiedResponse, BrandIdentifyFromTransactionResponse, ) @@ -329,6 +330,49 @@ def test_streaming_response_retrieve_naics(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True + @pytest.mark.skip() + @parametrize + def test_method_retrieve_simplified(self, client: BrandDev) -> None: + brand = client.brand.retrieve_simplified( + domain="domain", + ) + assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_method_retrieve_simplified_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.retrieve_simplified( + domain="domain", + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_raw_response_retrieve_simplified(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.retrieve_simplified( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + def test_streaming_response_retrieve_simplified(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.retrieve_simplified( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip() @parametrize def test_method_screenshot(self, client: BrandDev) -> None: @@ -764,6 +808,49 @@ async def test_streaming_response_retrieve_naics(self, async_client: AsyncBrandD assert cast(Any, response.is_closed) is True + @pytest.mark.skip() + @parametrize + async def test_method_retrieve_simplified(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_simplified( + domain="domain", + ) + assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_method_retrieve_simplified_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_simplified( + domain="domain", + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_raw_response_retrieve_simplified(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.retrieve_simplified( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) + + @pytest.mark.skip() + @parametrize + async def test_streaming_response_retrieve_simplified(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.retrieve_simplified( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip() @parametrize async def test_method_screenshot(self, async_client: AsyncBrandDev) -> None: From 4da1388df293e20dc1e09da48f6b96d8cac059f3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 23:52:48 +0000 Subject: [PATCH 046/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fbd9082..7deae33 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.5.0" + ".": "1.6.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e9593a2..5c8307d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.5.0" +version = "1.6.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 929055c..44f0df3 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.5.0" # x-release-please-version +__version__ = "1.6.0" # x-release-please-version From f5d8bde1e5d0cd3c970e7f4f28281c955a53231c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 23:56:51 +0000 Subject: [PATCH 047/176] feat(api): manual updates --- .stats.yml | 4 ++-- src/brand/dev/resources/brand.py | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.stats.yml b/.stats.yml index f0d7da7..1fa2e23 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 10 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-3f6d4c0f819a0d3128951d315ad216df22050fbc47c5f601d261d56f8b1d80a5.yml -openapi_spec_hash: 8e7953259a1b6bd7440a780eccad1742 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-7fc7261db520645d5bd06a27492b32aa869d787c382908bb4a608034b757348b.yml +openapi_spec_hash: 8a9e05fe1ca53f6d8c11ddee625a8908 config_hash: 8f3ee44d690a305369555016a77ed016 diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 542b0ff..f30f7cf 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -593,8 +593,7 @@ def styleguide( """ Beta feature: Automatically extract comprehensive design system information from a brand's website including colors, typography, spacing, shadows, and UI - components. Uses AI-powered analysis of website screenshots to identify design - patterns and create a reusable styleguide. + components. Args: domain: Domain name to extract styleguide from (e.g., 'example.com', 'google.com'). The @@ -1180,8 +1179,7 @@ async def styleguide( """ Beta feature: Automatically extract comprehensive design system information from a brand's website including colors, typography, spacing, shadows, and UI - components. Uses AI-powered analysis of website screenshots to identify design - patterns and create a reusable styleguide. + components. Args: domain: Domain name to extract styleguide from (e.g., 'example.com', 'google.com'). The From 3989b0d5a9c3dcddce5385e5d298937a638f75cb Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Jun 2025 00:00:50 +0000 Subject: [PATCH 048/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7deae33..cce9d1c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.6.0" + ".": "1.7.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5c8307d..5889a9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.6.0" +version = "1.7.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 44f0df3..e4979b6 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.6.0" # x-release-please-version +__version__ = "1.7.0" # x-release-please-version From 47ab8383f1b2980cda7f6af997573c26942491ce Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 21 Jun 2025 03:47:39 +0000 Subject: [PATCH 049/176] feat(client): add support for aiohttp --- README.md | 34 ++++++++++++++++++++++++ pyproject.toml | 2 ++ requirements-dev.lock | 27 +++++++++++++++++++ requirements.lock | 27 +++++++++++++++++++ src/brand/dev/__init__.py | 3 ++- src/brand/dev/_base_client.py | 22 ++++++++++++++++ tests/api_resources/test_brand.py | 4 ++- tests/conftest.py | 43 ++++++++++++++++++++++++++----- 8 files changed, 154 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 40020f4..e006b6e 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,40 @@ asyncio.run(main()) Functionality between the synchronous and asynchronous clients is otherwise identical. +### With aiohttp + +By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. + +You can enable this by installing `aiohttp`: + +```sh +# install from PyPI +pip install brand.dev[aiohttp] +``` + +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: + +```python +import os +import asyncio +from brand.dev import DefaultAioHttpClient +from brand.dev import AsyncBrandDev + + +async def main() -> None: + async with AsyncBrandDev( + api_key=os.environ.get("BRAND_DEV_API_KEY"), # This is the default and can be omitted + http_client=DefaultAioHttpClient(), + ) as client: + brand = await client.brand.retrieve( + domain="REPLACE_ME", + ) + print(brand.brand) + + +asyncio.run(main()) +``` + ## Using types Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like: diff --git a/pyproject.toml b/pyproject.toml index 5889a9d..5839d69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,8 @@ classifiers = [ Homepage = "https://github.com/brand-dot-dev/python-sdk" Repository = "https://github.com/brand-dot-dev/python-sdk" +[project.optional-dependencies] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.6"] [tool.rye] managed = true diff --git a/requirements-dev.lock b/requirements-dev.lock index 8c972f9..c39bff6 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -10,6 +10,13 @@ # universal: false -e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.8 + # via brand-dev + # via httpx-aiohttp +aiosignal==1.3.2 + # via aiohttp annotated-types==0.6.0 # via pydantic anyio==4.4.0 @@ -17,6 +24,10 @@ anyio==4.4.0 # via httpx argcomplete==3.1.2 # via nox +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp certifi==2023.7.22 # via httpcore # via httpx @@ -34,16 +45,23 @@ execnet==2.1.1 # via pytest-xdist filelock==3.12.4 # via virtualenv +frozenlist==1.6.2 + # via aiohttp + # via aiosignal h11==0.14.0 # via httpcore httpcore==1.0.2 # via httpx httpx==0.28.1 # via brand-dev + # via httpx-aiohttp # via respx +httpx-aiohttp==0.1.6 + # via brand-dev idna==3.4 # via anyio # via httpx + # via yarl importlib-metadata==7.0.0 iniconfig==2.0.0 # via pytest @@ -51,6 +69,9 @@ markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py +multidict==6.4.4 + # via aiohttp + # via yarl mypy==1.14.1 mypy-extensions==1.0.0 # via mypy @@ -65,6 +86,9 @@ platformdirs==3.11.0 # via virtualenv pluggy==1.5.0 # via pytest +propcache==0.3.1 + # via aiohttp + # via yarl pydantic==2.10.3 # via brand-dev pydantic-core==2.27.1 @@ -98,11 +122,14 @@ tomli==2.0.2 typing-extensions==4.12.2 # via anyio # via brand-dev + # via multidict # via mypy # via pydantic # via pydantic-core # via pyright virtualenv==20.24.5 # via nox +yarl==1.20.0 + # via aiohttp zipp==3.17.0 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index 5d6d6a4..7b3af9e 100644 --- a/requirements.lock +++ b/requirements.lock @@ -10,11 +10,22 @@ # universal: false -e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.8 + # via brand-dev + # via httpx-aiohttp +aiosignal==1.3.2 + # via aiohttp annotated-types==0.6.0 # via pydantic anyio==4.4.0 # via brand-dev # via httpx +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp certifi==2023.7.22 # via httpcore # via httpx @@ -22,15 +33,28 @@ distro==1.8.0 # via brand-dev exceptiongroup==1.2.2 # via anyio +frozenlist==1.6.2 + # via aiohttp + # via aiosignal h11==0.14.0 # via httpcore httpcore==1.0.2 # via httpx httpx==0.28.1 # via brand-dev + # via httpx-aiohttp +httpx-aiohttp==0.1.6 + # via brand-dev idna==3.4 # via anyio # via httpx + # via yarl +multidict==6.4.4 + # via aiohttp + # via yarl +propcache==0.3.1 + # via aiohttp + # via yarl pydantic==2.10.3 # via brand-dev pydantic-core==2.27.1 @@ -41,5 +65,8 @@ sniffio==1.3.0 typing-extensions==4.12.2 # via anyio # via brand-dev + # via multidict # via pydantic # via pydantic-core +yarl==1.20.0 + # via aiohttp diff --git a/src/brand/dev/__init__.py b/src/brand/dev/__init__.py index 0de5457..3912da5 100644 --- a/src/brand/dev/__init__.py +++ b/src/brand/dev/__init__.py @@ -36,7 +36,7 @@ UnprocessableEntityError, APIResponseValidationError, ) -from ._base_client import DefaultHttpxClient, DefaultAsyncHttpxClient +from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient from ._utils._logs import setup_logging as _setup_logging __all__ = [ @@ -78,6 +78,7 @@ "DEFAULT_CONNECTION_LIMITS", "DefaultHttpxClient", "DefaultAsyncHttpxClient", + "DefaultAioHttpClient", ] if not _t.TYPE_CHECKING: diff --git a/src/brand/dev/_base_client.py b/src/brand/dev/_base_client.py index 359751d..76be9cc 100644 --- a/src/brand/dev/_base_client.py +++ b/src/brand/dev/_base_client.py @@ -1289,6 +1289,24 @@ def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) +try: + import httpx_aiohttp +except ImportError: + + class _DefaultAioHttpClient(httpx.AsyncClient): + def __init__(self, **_kwargs: Any) -> None: + raise RuntimeError("To use the aiohttp client you must have installed the package with the `aiohttp` extra") +else: + + class _DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + + super().__init__(**kwargs) + + if TYPE_CHECKING: DefaultAsyncHttpxClient = httpx.AsyncClient """An alias to `httpx.AsyncClient` that provides the same defaults that this SDK @@ -1297,8 +1315,12 @@ def __init__(self, **kwargs: Any) -> None: This is useful because overriding the `http_client` with your own instance of `httpx.AsyncClient` will result in httpx's defaults being used, not ours. """ + + DefaultAioHttpClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that changes the default HTTP transport to `aiohttp`.""" else: DefaultAsyncHttpxClient = _DefaultAsyncHttpxClient + DefaultAioHttpClient = _DefaultAioHttpClient class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient): diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 5647f52..4814262 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -504,7 +504,9 @@ def test_streaming_response_styleguide(self, client: BrandDev) -> None: class TestAsyncBrand: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @pytest.mark.skip() @parametrize diff --git a/tests/conftest.py b/tests/conftest.py index 4871488..682e869 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,10 +6,12 @@ import logging from typing import TYPE_CHECKING, Iterator, AsyncIterator +import httpx import pytest from pytest_asyncio import is_async_test -from brand.dev import BrandDev, AsyncBrandDev +from brand.dev import BrandDev, AsyncBrandDev, DefaultAioHttpClient +from brand.dev._utils import is_dict if TYPE_CHECKING: from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] @@ -27,6 +29,19 @@ def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: for async_test in pytest_asyncio_tests: async_test.add_marker(session_scope_marker, append=False) + # We skip tests that use both the aiohttp client and respx_mock as respx_mock + # doesn't support custom transports. + for item in items: + if "async_client" not in item.fixturenames or "respx_mock" not in item.fixturenames: + continue + + if not hasattr(item, "callspec"): + continue + + async_client_param = item.callspec.params.get("async_client") + if is_dict(async_client_param) and async_client_param.get("http_client") == "aiohttp": + item.add_marker(pytest.mark.skip(reason="aiohttp client is not compatible with respx_mock")) + base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -45,9 +60,25 @@ def client(request: FixtureRequest) -> Iterator[BrandDev]: @pytest.fixture(scope="session") async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncBrandDev]: - strict = getattr(request, "param", True) - if not isinstance(strict, bool): - raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") - - async with AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=strict) as client: + param = getattr(request, "param", True) + + # defaults + strict = True + http_client: None | httpx.AsyncClient = None + + if isinstance(param, bool): + strict = param + elif is_dict(param): + strict = param.get("strict", True) + assert isinstance(strict, bool) + + http_client_type = param.get("http_client", "httpx") + if http_client_type == "aiohttp": + http_client = DefaultAioHttpClient() + else: + raise TypeError(f"Unexpected fixture parameter type {type(param)}, expected bool or dict") + + async with AsyncBrandDev( + base_url=base_url, api_key=api_key, _strict_response_validation=strict, http_client=http_client + ) as client: yield client From c9b23bbd26d054da0622a053fcb14ecdef2aee27 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Jun 2025 03:57:03 +0000 Subject: [PATCH 050/176] chore(tests): skip some failing tests on the latest python versions --- tests/test_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 1156a03..6b13a36 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -191,6 +191,7 @@ def test_copy_signature(self) -> None: copy_param = copy_signature.parameters.get(name) assert copy_param is not None, f"copy() signature is missing the {name} param" + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") def test_copy_build_request(self) -> None: options = FinalRequestOptions(method="get", url="/foo") @@ -995,6 +996,7 @@ def test_copy_signature(self) -> None: copy_param = copy_signature.parameters.get(name) assert copy_param is not None, f"copy() signature is missing the {name} param" + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") def test_copy_build_request(self) -> None: options = FinalRequestOptions(method="get", url="/foo") From fd05e46b2e2163c279c1b23f428524503c0ffc15 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 02:24:35 +0000 Subject: [PATCH 051/176] =?UTF-8?q?fix(ci):=20release-doctor=20=E2=80=94?= =?UTF-8?q?=20report=20correct=20token=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/check-release-environment | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/check-release-environment b/bin/check-release-environment index def918a..b845b0f 100644 --- a/bin/check-release-environment +++ b/bin/check-release-environment @@ -3,7 +3,7 @@ errors=() if [ -z "${PYPI_TOKEN}" ]; then - errors+=("The BRAND_DEV_PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") + errors+=("The PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") fi lenErrors=${#errors[@]} From a271b22ad075918ec3d75aa8dc3e126e9c7f0ab2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 28 Jun 2025 08:36:07 +0000 Subject: [PATCH 052/176] chore(ci): only run for pushes and fork pull requests --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b251b1..2e60c76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: timeout-minutes: 10 name: lint runs-on: ${{ github.repository == 'stainless-sdks/brand.dev-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 @@ -42,6 +43,7 @@ jobs: contents: read id-token: write runs-on: depot-ubuntu-24.04 + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 @@ -62,6 +64,7 @@ jobs: timeout-minutes: 10 name: test runs-on: ${{ github.repository == 'stainless-sdks/brand.dev-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 From 4fc3d98b9371c19e67db08f83d235e2c3eee0868 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 29 Jun 2025 17:44:55 +0000 Subject: [PATCH 053/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cce9d1c..c523ce1 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.7.0" + ".": "1.8.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5839d69..17051e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.7.0" +version = "1.8.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index e4979b6..52946cc 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.7.0" # x-release-please-version +__version__ = "1.8.0" # x-release-please-version From 69b4ef95f7ab95e6a560c3fcbcf657cf299ad149 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 29 Jun 2025 19:51:32 +0000 Subject: [PATCH 054/176] feat(api): manual updates --- .stats.yml | 4 ++-- src/brand/dev/types/brand_retrieve_response.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.stats.yml b/.stats.yml index 1fa2e23..b3fbada 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 10 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-7fc7261db520645d5bd06a27492b32aa869d787c382908bb4a608034b757348b.yml -openapi_spec_hash: 8a9e05fe1ca53f6d8c11ddee625a8908 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-aff48154fa37f0eb293cf660842ceab35c597509aee08f6b76df066828229c58.yml +openapi_spec_hash: a8ade5d7246da14e2ff161e829d11c12 config_hash: 8f3ee44d690a305369555016a77ed016 diff --git a/src/brand/dev/types/brand_retrieve_response.py b/src/brand/dev/types/brand_retrieve_response.py index 33ff13d..426b99c 100644 --- a/src/brand/dev/types/brand_retrieve_response.py +++ b/src/brand/dev/types/brand_retrieve_response.py @@ -153,7 +153,11 @@ class Brand(BaseModel): """The domain name of the brand""" fonts: Optional[List[BrandFont]] = None - """An array of fonts used by the brand's website""" + """An array of fonts used by the brand's website. + + NOTE: This is deprecated and will be removed in the future. Please migrate to + the styleguide API. + """ logos: Optional[List[BrandLogo]] = None """An array of logos associated with the brand""" From 1f62167802ab8a95959b77972f6d61a04b11d5bd Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 29 Jun 2025 19:54:46 +0000 Subject: [PATCH 055/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c523ce1..c3c9552 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.8.0" + ".": "1.9.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 17051e3..da0538e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.8.0" +version = "1.9.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 52946cc..94cbe8c 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.8.0" # x-release-please-version +__version__ = "1.9.0" # x-release-please-version From e4af2b99cca130a63fd2e4a1f5e1417619b735d3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 02:21:26 +0000 Subject: [PATCH 056/176] fix(ci): correct conditional --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e60c76..38fa79b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,14 +36,13 @@ jobs: run: ./scripts/lint upload: - if: github.repository == 'stainless-sdks/brand.dev-python' + if: github.repository == 'stainless-sdks/brand.dev-python' && (github.event_name == 'push' || github.event.pull_request.head.repo.fork) timeout-minutes: 10 name: upload permissions: contents: read id-token: write runs-on: depot-ubuntu-24.04 - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 From 7eb185f0f99acb575d925022dfc9cc9ed24a975d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 04:57:11 +0000 Subject: [PATCH 057/176] chore(ci): change upload type --- .github/workflows/ci.yml | 18 ++++++++++++++++-- scripts/utils/upload-artifact.sh | 12 +++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38fa79b..bd39ae7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,10 +35,10 @@ jobs: - name: Run lints run: ./scripts/lint - upload: + build: if: github.repository == 'stainless-sdks/brand.dev-python' && (github.event_name == 'push' || github.event.pull_request.head.repo.fork) timeout-minutes: 10 - name: upload + name: build permissions: contents: read id-token: write @@ -46,6 +46,20 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Install dependencies + run: rye sync --all-features + + - name: Run build + run: rye build + - name: Get GitHub OIDC Token id: github-oidc uses: actions/github-script@v6 diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh index ce09341..46ad6c7 100755 --- a/scripts/utils/upload-artifact.sh +++ b/scripts/utils/upload-artifact.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash set -exuo pipefail -RESPONSE=$(curl -X POST "$URL" \ +FILENAME=$(basename dist/*.whl) + +RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ -H "Authorization: Bearer $AUTH" \ -H "Content-Type: application/json") @@ -12,13 +14,13 @@ if [[ "$SIGNED_URL" == "null" ]]; then exit 1 fi -UPLOAD_RESPONSE=$(tar -cz . | curl -v -X PUT \ - -H "Content-Type: application/gzip" \ - --data-binary @- "$SIGNED_URL" 2>&1) +UPLOAD_RESPONSE=$(curl -v -X PUT \ + -H "Content-Type: binary/octet-stream" \ + --data-binary "@dist/$FILENAME" "$SIGNED_URL" 2>&1) if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/brand.dev-python/$SHA'\033[0m" + echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/brand.dev-python/$SHA/$FILENAME'\033[0m" else echo -e "\033[31mFailed to upload artifact.\033[0m" exit 1 From 780262add0eba02ab741b44d445078f2851c9878 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 02:06:50 +0000 Subject: [PATCH 058/176] chore(internal): codegen related update --- requirements-dev.lock | 2 +- requirements.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-dev.lock b/requirements-dev.lock index c39bff6..9fb2ca3 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -56,7 +56,7 @@ httpx==0.28.1 # via brand-dev # via httpx-aiohttp # via respx -httpx-aiohttp==0.1.6 +httpx-aiohttp==0.1.8 # via brand-dev idna==3.4 # via anyio diff --git a/requirements.lock b/requirements.lock index 7b3af9e..073884b 100644 --- a/requirements.lock +++ b/requirements.lock @@ -43,7 +43,7 @@ httpcore==1.0.2 httpx==0.28.1 # via brand-dev # via httpx-aiohttp -httpx-aiohttp==0.1.6 +httpx-aiohttp==0.1.8 # via brand-dev idna==3.4 # via anyio From 31d8713b9dd548d8bf15013e9f788664603162b3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 02:21:46 +0000 Subject: [PATCH 059/176] chore(internal): bump pinned h11 dep --- requirements-dev.lock | 4 ++-- requirements.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements-dev.lock b/requirements-dev.lock index 9fb2ca3..1c0d9f6 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -48,9 +48,9 @@ filelock==3.12.4 frozenlist==1.6.2 # via aiohttp # via aiosignal -h11==0.14.0 +h11==0.16.0 # via httpcore -httpcore==1.0.2 +httpcore==1.0.9 # via httpx httpx==0.28.1 # via brand-dev diff --git a/requirements.lock b/requirements.lock index 073884b..900e4ea 100644 --- a/requirements.lock +++ b/requirements.lock @@ -36,9 +36,9 @@ exceptiongroup==1.2.2 frozenlist==1.6.2 # via aiohttp # via aiosignal -h11==0.14.0 +h11==0.16.0 # via httpcore -httpcore==1.0.2 +httpcore==1.0.9 # via httpx httpx==0.28.1 # via brand-dev From de8a6c9d463b75dfbbf931c2349f09820a7b2468 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 02:41:27 +0000 Subject: [PATCH 060/176] chore(package): mark python 3.13 as supported --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index da0538e..f23cb96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", From 933bf7c50dc189b1153ebff013c5718aec068b05 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 10 Jul 2025 02:37:32 +0000 Subject: [PATCH 061/176] fix(parsing): correctly handle nested discriminated unions --- src/brand/dev/_models.py | 13 +++++++----- tests/test_models.py | 45 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/brand/dev/_models.py b/src/brand/dev/_models.py index 4f21498..528d568 100644 --- a/src/brand/dev/_models.py +++ b/src/brand/dev/_models.py @@ -2,9 +2,10 @@ import os import inspect -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, cast +from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast from datetime import date, datetime from typing_extensions import ( + List, Unpack, Literal, ClassVar, @@ -366,7 +367,7 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: if type_ is None: raise RuntimeError(f"Unexpected field type is None for {key}") - return construct_type(value=value, type_=type_) + return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) def is_basemodel(type_: type) -> bool: @@ -420,7 +421,7 @@ def construct_type_unchecked(*, value: object, type_: type[_T]) -> _T: return cast(_T, construct_type(value=value, type_=type_)) -def construct_type(*, value: object, type_: object) -> object: +def construct_type(*, value: object, type_: object, metadata: Optional[List[Any]] = None) -> object: """Loose coercion to the expected type with construction of nested values. If the given value does not match the expected type then it is returned as-is. @@ -438,8 +439,10 @@ def construct_type(*, value: object, type_: object) -> object: type_ = type_.__value__ # type: ignore[unreachable] # unwrap `Annotated[T, ...]` -> `T` - if is_annotated_type(type_): - meta: tuple[Any, ...] = get_args(type_)[1:] + if metadata is not None: + meta: tuple[Any, ...] = tuple(metadata) + elif is_annotated_type(type_): + meta = get_args(type_)[1:] type_ = extract_type_arg(type_, 0) else: meta = tuple() diff --git a/tests/test_models.py b/tests/test_models.py index 3e4688d..d6413aa 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -889,3 +889,48 @@ class ModelB(BaseModel): ) assert isinstance(m, ModelB) + + +def test_nested_discriminated_union() -> None: + class InnerType1(BaseModel): + type: Literal["type_1"] + + class InnerModel(BaseModel): + inner_value: str + + class InnerType2(BaseModel): + type: Literal["type_2"] + some_inner_model: InnerModel + + class Type1(BaseModel): + base_type: Literal["base_type_1"] + value: Annotated[ + Union[ + InnerType1, + InnerType2, + ], + PropertyInfo(discriminator="type"), + ] + + class Type2(BaseModel): + base_type: Literal["base_type_2"] + + T = Annotated[ + Union[ + Type1, + Type2, + ], + PropertyInfo(discriminator="base_type"), + ] + + model = construct_type( + type_=T, + value={ + "base_type": "base_type_1", + "value": { + "type": "type_2", + }, + }, + ) + assert isinstance(model, Type1) + assert isinstance(model.value, InnerType2) From cd8b5a6106d52f4d61c7c2e8a958db706c441652 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 11 Jul 2025 02:55:30 +0000 Subject: [PATCH 062/176] chore(readme): fix version rendering on pypi --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e006b6e..a87e394 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Brand Dev Python API library -[![PyPI version]()](https://pypi.org/project/brand.dev/) + +[![PyPI version](https://img.shields.io/pypi/v/brand.dev.svg?label=pypi%20(stable))](https://pypi.org/project/brand.dev/) The Brand Dev Python library provides convenient access to the Brand Dev REST API from any Python 3.8+ application. The library includes type definitions for all request params and response fields, From 6a3bf9f22646230a04e46795656808ffb34d1120 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 12 Jul 2025 02:02:16 +0000 Subject: [PATCH 063/176] fix(client): don't send Content-Type header on GET requests --- pyproject.toml | 2 +- src/brand/dev/_base_client.py | 11 +++++++++-- tests/test_client.py | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f23cb96..c73c8e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Homepage = "https://github.com/brand-dot-dev/python-sdk" Repository = "https://github.com/brand-dot-dev/python-sdk" [project.optional-dependencies] -aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.6"] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] [tool.rye] managed = true diff --git a/src/brand/dev/_base_client.py b/src/brand/dev/_base_client.py index 76be9cc..fcf0f40 100644 --- a/src/brand/dev/_base_client.py +++ b/src/brand/dev/_base_client.py @@ -529,6 +529,15 @@ def _build_request( # work around https://github.com/encode/httpx/discussions/2880 kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} + is_body_allowed = options.method.lower() != "get" + + if is_body_allowed: + kwargs["json"] = json_data if is_given(json_data) else None + kwargs["files"] = files + else: + headers.pop("Content-Type", None) + kwargs.pop("data", None) + # TODO: report this error to httpx return self._client.build_request( # pyright: ignore[reportUnknownMemberType] headers=headers, @@ -540,8 +549,6 @@ def _build_request( # so that passing a `TypedDict` doesn't cause an error. # https://github.com/microsoft/pyright/issues/3526#event-6715453066 params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, - json=json_data if is_given(json_data) else None, - files=files, **kwargs, ) diff --git a/tests/test_client.py b/tests/test_client.py index 6b13a36..a2b359c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -464,7 +464,7 @@ def test_request_extra_query(self) -> None: def test_multipart_repeating_array(self, client: BrandDev) -> None: request = client._build_request( FinalRequestOptions.construct( - method="get", + method="post", url="/foo", headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, json_data={"array": ["foo", "bar"]}, @@ -1269,7 +1269,7 @@ def test_request_extra_query(self) -> None: def test_multipart_repeating_array(self, async_client: AsyncBrandDev) -> None: request = async_client._build_request( FinalRequestOptions.construct( - method="get", + method="post", url="/foo", headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, json_data={"array": ["foo", "bar"]}, From 881f60a0f398acf561238c1a9cce88c0b4ea13e4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 15 Jul 2025 02:02:18 +0000 Subject: [PATCH 064/176] feat: clean up environment call outs --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index a87e394..40d897e 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,6 @@ pip install brand.dev[aiohttp] Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python -import os import asyncio from brand.dev import DefaultAioHttpClient from brand.dev import AsyncBrandDev @@ -91,7 +90,7 @@ from brand.dev import AsyncBrandDev async def main() -> None: async with AsyncBrandDev( - api_key=os.environ.get("BRAND_DEV_API_KEY"), # This is the default and can be omitted + api_key="My API Key", http_client=DefaultAioHttpClient(), ) as client: brand = await client.brand.retrieve( From f3626f76938465845a686485b08a4632e1887d5b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 00:41:02 +0000 Subject: [PATCH 065/176] feat(api): manual updates --- .stats.yml | 6 +- api.md | 2 - src/brand/dev/resources/brand.py | 110 ------------------ src/brand/dev/types/__init__.py | 2 - ...rand_identify_from_transaction_response.py | 12 -- .../brand_retrieve_by_ticker_response.py | 12 -- .../dev/types/brand_retrieve_response.py | 16 --- src/brand/dev/types/brand_search_params.py | 21 ---- src/brand/dev/types/brand_search_response.py | 22 ---- tests/api_resources/test_brand.py | 87 -------------- 10 files changed, 3 insertions(+), 287 deletions(-) delete mode 100644 src/brand/dev/types/brand_search_params.py delete mode 100644 src/brand/dev/types/brand_search_response.py diff --git a/.stats.yml b/.stats.yml index b3fbada..7daf4a5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 10 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-aff48154fa37f0eb293cf660842ceab35c597509aee08f6b76df066828229c58.yml -openapi_spec_hash: a8ade5d7246da14e2ff161e829d11c12 +configured_endpoints: 9 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-70f44e886c51bd700af031ad9b5c8f0042ef15fde038ba83ed08f61cd9d05266.yml +openapi_spec_hash: 9b834ba9e373689a8e2fbd8312b1f2de config_hash: 8f3ee44d690a305369555016a77ed016 diff --git a/api.md b/api.md index da93d3d..ef7c6cc 100644 --- a/api.md +++ b/api.md @@ -12,7 +12,6 @@ from brand.dev.types import ( BrandRetrieveNaicsResponse, BrandRetrieveSimplifiedResponse, BrandScreenshotResponse, - BrandSearchResponse, BrandStyleguideResponse, ) ``` @@ -27,5 +26,4 @@ Methods: - client.brand.retrieve_naics(\*\*params) -> BrandRetrieveNaicsResponse - client.brand.retrieve_simplified(\*\*params) -> BrandRetrieveSimplifiedResponse - client.brand.screenshot(\*\*params) -> BrandScreenshotResponse -- client.brand.search(\*\*params) -> BrandSearchResponse - client.brand.styleguide(\*\*params) -> BrandStyleguideResponse diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index f30f7cf..58a3116 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -8,7 +8,6 @@ import httpx from ..types import ( - brand_search_params, brand_ai_query_params, brand_prefetch_params, brand_retrieve_params, @@ -30,7 +29,6 @@ async_to_streamed_response_wrapper, ) from .._base_client import make_request_options -from ..types.brand_search_response import BrandSearchResponse from ..types.brand_ai_query_response import BrandAIQueryResponse from ..types.brand_prefetch_response import BrandPrefetchResponse from ..types.brand_retrieve_response import BrandRetrieveResponse @@ -530,54 +528,6 @@ def screenshot( cast_to=BrandScreenshotResponse, ) - def search( - self, - *, - query: str, - timeout_ms: int | NotGiven = NOT_GIVEN, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BrandSearchResponse: - """ - Search brands by query - - Args: - query: Query string to search brands - - timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer - than this value, it will be aborted with a 408 status code. Maximum allowed - value is 300000ms (5 minutes). - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get( - "/brand/search", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform( - { - "query": query, - "timeout_ms": timeout_ms, - }, - brand_search_params.BrandSearchParams, - ), - ), - cast_to=BrandSearchResponse, - ) - def styleguide( self, *, @@ -1116,54 +1066,6 @@ async def screenshot( cast_to=BrandScreenshotResponse, ) - async def search( - self, - *, - query: str, - timeout_ms: int | NotGiven = NOT_GIVEN, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BrandSearchResponse: - """ - Search brands by query - - Args: - query: Query string to search brands - - timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer - than this value, it will be aborted with a 408 status code. Maximum allowed - value is 300000ms (5 minutes). - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._get( - "/brand/search", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=await async_maybe_transform( - { - "query": query, - "timeout_ms": timeout_ms, - }, - brand_search_params.BrandSearchParams, - ), - ), - cast_to=BrandSearchResponse, - ) - async def styleguide( self, *, @@ -1244,9 +1146,6 @@ def __init__(self, brand: BrandResource) -> None: self.screenshot = to_raw_response_wrapper( brand.screenshot, ) - self.search = to_raw_response_wrapper( - brand.search, - ) self.styleguide = to_raw_response_wrapper( brand.styleguide, ) @@ -1280,9 +1179,6 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.screenshot = async_to_raw_response_wrapper( brand.screenshot, ) - self.search = async_to_raw_response_wrapper( - brand.search, - ) self.styleguide = async_to_raw_response_wrapper( brand.styleguide, ) @@ -1316,9 +1212,6 @@ def __init__(self, brand: BrandResource) -> None: self.screenshot = to_streamed_response_wrapper( brand.screenshot, ) - self.search = to_streamed_response_wrapper( - brand.search, - ) self.styleguide = to_streamed_response_wrapper( brand.styleguide, ) @@ -1352,9 +1245,6 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.screenshot = async_to_streamed_response_wrapper( brand.screenshot, ) - self.search = async_to_streamed_response_wrapper( - brand.search, - ) self.styleguide = async_to_streamed_response_wrapper( brand.styleguide, ) diff --git a/src/brand/dev/types/__init__.py b/src/brand/dev/types/__init__.py index c7428ce..35536b0 100644 --- a/src/brand/dev/types/__init__.py +++ b/src/brand/dev/types/__init__.py @@ -2,11 +2,9 @@ from __future__ import annotations -from .brand_search_params import BrandSearchParams as BrandSearchParams from .brand_ai_query_params import BrandAIQueryParams as BrandAIQueryParams from .brand_prefetch_params import BrandPrefetchParams as BrandPrefetchParams from .brand_retrieve_params import BrandRetrieveParams as BrandRetrieveParams -from .brand_search_response import BrandSearchResponse as BrandSearchResponse from .brand_ai_query_response import BrandAIQueryResponse as BrandAIQueryResponse from .brand_prefetch_response import BrandPrefetchResponse as BrandPrefetchResponse from .brand_retrieve_response import BrandRetrieveResponse as BrandRetrieveResponse diff --git a/src/brand/dev/types/brand_identify_from_transaction_response.py b/src/brand/dev/types/brand_identify_from_transaction_response.py index 48411d6..a48a4df 100644 --- a/src/brand/dev/types/brand_identify_from_transaction_response.py +++ b/src/brand/dev/types/brand_identify_from_transaction_response.py @@ -12,7 +12,6 @@ "BrandBackdropColor", "BrandBackdropResolution", "BrandColor", - "BrandFont", "BrandLogo", "BrandLogoColor", "BrandLogoResolution", @@ -79,14 +78,6 @@ class BrandColor(BaseModel): """Name of the color""" -class BrandFont(BaseModel): - name: Optional[str] = None - """Name of the font""" - - usage: Optional[str] = None - """Usage of the font, e.g., 'title', 'body', 'button'""" - - class BrandLogoColor(BaseModel): hex: Optional[str] = None """Color in hexadecimal format""" @@ -152,9 +143,6 @@ class Brand(BaseModel): domain: Optional[str] = None """The domain name of the brand""" - fonts: Optional[List[BrandFont]] = None - """An array of fonts used by the brand's website""" - logos: Optional[List[BrandLogo]] = None """An array of logos associated with the brand""" diff --git a/src/brand/dev/types/brand_retrieve_by_ticker_response.py b/src/brand/dev/types/brand_retrieve_by_ticker_response.py index 9da5d28..fb0cfce 100644 --- a/src/brand/dev/types/brand_retrieve_by_ticker_response.py +++ b/src/brand/dev/types/brand_retrieve_by_ticker_response.py @@ -12,7 +12,6 @@ "BrandBackdropColor", "BrandBackdropResolution", "BrandColor", - "BrandFont", "BrandLogo", "BrandLogoColor", "BrandLogoResolution", @@ -79,14 +78,6 @@ class BrandColor(BaseModel): """Name of the color""" -class BrandFont(BaseModel): - name: Optional[str] = None - """Name of the font""" - - usage: Optional[str] = None - """Usage of the font, e.g., 'title', 'body', 'button'""" - - class BrandLogoColor(BaseModel): hex: Optional[str] = None """Color in hexadecimal format""" @@ -152,9 +143,6 @@ class Brand(BaseModel): domain: Optional[str] = None """The domain name of the brand""" - fonts: Optional[List[BrandFont]] = None - """An array of fonts used by the brand's website""" - logos: Optional[List[BrandLogo]] = None """An array of logos associated with the brand""" diff --git a/src/brand/dev/types/brand_retrieve_response.py b/src/brand/dev/types/brand_retrieve_response.py index 426b99c..32f2115 100644 --- a/src/brand/dev/types/brand_retrieve_response.py +++ b/src/brand/dev/types/brand_retrieve_response.py @@ -12,7 +12,6 @@ "BrandBackdropColor", "BrandBackdropResolution", "BrandColor", - "BrandFont", "BrandLogo", "BrandLogoColor", "BrandLogoResolution", @@ -79,14 +78,6 @@ class BrandColor(BaseModel): """Name of the color""" -class BrandFont(BaseModel): - name: Optional[str] = None - """Name of the font""" - - usage: Optional[str] = None - """Usage of the font, e.g., 'title', 'body', 'button'""" - - class BrandLogoColor(BaseModel): hex: Optional[str] = None """Color in hexadecimal format""" @@ -152,13 +143,6 @@ class Brand(BaseModel): domain: Optional[str] = None """The domain name of the brand""" - fonts: Optional[List[BrandFont]] = None - """An array of fonts used by the brand's website. - - NOTE: This is deprecated and will be removed in the future. Please migrate to - the styleguide API. - """ - logos: Optional[List[BrandLogo]] = None """An array of logos associated with the brand""" diff --git a/src/brand/dev/types/brand_search_params.py b/src/brand/dev/types/brand_search_params.py deleted file mode 100644 index 2541682..0000000 --- a/src/brand/dev/types/brand_search_params.py +++ /dev/null @@ -1,21 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, Annotated, TypedDict - -from .._utils import PropertyInfo - -__all__ = ["BrandSearchParams"] - - -class BrandSearchParams(TypedDict, total=False): - query: Required[str] - """Query string to search brands""" - - timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] - """Optional timeout in milliseconds for the request. - - If the request takes longer than this value, it will be aborted with a 408 - status code. Maximum allowed value is 300000ms (5 minutes). - """ diff --git a/src/brand/dev/types/brand_search_response.py b/src/brand/dev/types/brand_search_response.py deleted file mode 100644 index c1c30b3..0000000 --- a/src/brand/dev/types/brand_search_response.py +++ /dev/null @@ -1,22 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List, Optional -from typing_extensions import TypeAlias - -from .._models import BaseModel - -__all__ = ["BrandSearchResponse", "BrandSearchResponseItem"] - - -class BrandSearchResponseItem(BaseModel): - domain: Optional[str] = None - """Domain name of the brand""" - - logo: Optional[str] = None - """URL of the brand's logo""" - - title: Optional[str] = None - """Title or name of the brand""" - - -BrandSearchResponse: TypeAlias = List[BrandSearchResponseItem] diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 4814262..571963c 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -10,7 +10,6 @@ from brand.dev import BrandDev, AsyncBrandDev from tests.utils import assert_matches_type from brand.dev.types import ( - BrandSearchResponse, BrandAIQueryResponse, BrandPrefetchResponse, BrandRetrieveResponse, @@ -416,49 +415,6 @@ def test_streaming_response_screenshot(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip() - @parametrize - def test_method_search(self, client: BrandDev) -> None: - brand = client.brand.search( - query="query", - ) - assert_matches_type(BrandSearchResponse, brand, path=["response"]) - - @pytest.mark.skip() - @parametrize - def test_method_search_with_all_params(self, client: BrandDev) -> None: - brand = client.brand.search( - query="query", - timeout_ms=1, - ) - assert_matches_type(BrandSearchResponse, brand, path=["response"]) - - @pytest.mark.skip() - @parametrize - def test_raw_response_search(self, client: BrandDev) -> None: - response = client.brand.with_raw_response.search( - query="query", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - brand = response.parse() - assert_matches_type(BrandSearchResponse, brand, path=["response"]) - - @pytest.mark.skip() - @parametrize - def test_streaming_response_search(self, client: BrandDev) -> None: - with client.brand.with_streaming_response.search( - query="query", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - brand = response.parse() - assert_matches_type(BrandSearchResponse, brand, path=["response"]) - - assert cast(Any, response.is_closed) is True - @pytest.mark.skip() @parametrize def test_method_styleguide(self, client: BrandDev) -> None: @@ -896,49 +852,6 @@ async def test_streaming_response_screenshot(self, async_client: AsyncBrandDev) assert cast(Any, response.is_closed) is True - @pytest.mark.skip() - @parametrize - async def test_method_search(self, async_client: AsyncBrandDev) -> None: - brand = await async_client.brand.search( - query="query", - ) - assert_matches_type(BrandSearchResponse, brand, path=["response"]) - - @pytest.mark.skip() - @parametrize - async def test_method_search_with_all_params(self, async_client: AsyncBrandDev) -> None: - brand = await async_client.brand.search( - query="query", - timeout_ms=1, - ) - assert_matches_type(BrandSearchResponse, brand, path=["response"]) - - @pytest.mark.skip() - @parametrize - async def test_raw_response_search(self, async_client: AsyncBrandDev) -> None: - response = await async_client.brand.with_raw_response.search( - query="query", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - brand = await response.parse() - assert_matches_type(BrandSearchResponse, brand, path=["response"]) - - @pytest.mark.skip() - @parametrize - async def test_streaming_response_search(self, async_client: AsyncBrandDev) -> None: - async with async_client.brand.with_streaming_response.search( - query="query", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - brand = await response.parse() - assert_matches_type(BrandSearchResponse, brand, path=["response"]) - - assert cast(Any, response.is_closed) is True - @pytest.mark.skip() @parametrize async def test_method_styleguide(self, async_client: AsyncBrandDev) -> None: From 41c35540e9db2e09e6217e63cad618080611972f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 00:41:21 +0000 Subject: [PATCH 066/176] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 7daf4a5..2a58fb8 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 9 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-70f44e886c51bd700af031ad9b5c8f0042ef15fde038ba83ed08f61cd9d05266.yml openapi_spec_hash: 9b834ba9e373689a8e2fbd8312b1f2de -config_hash: 8f3ee44d690a305369555016a77ed016 +config_hash: 4b10254ea5b8e26ce632222b94a918aa From 5b95820d098adfcf31037bce8a94b9c3c2773a9b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 00:49:04 +0000 Subject: [PATCH 067/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c3c9552..eb4e0db 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.9.0" + ".": "1.10.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c73c8e1..2e2b992 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.9.0" +version = "1.10.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 94cbe8c..46e2f07 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.9.0" # x-release-please-version +__version__ = "1.10.0" # x-release-please-version From 8ab602eb96b0bba3eca465f29d993fe386cac304 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 22 Jul 2025 02:02:16 +0000 Subject: [PATCH 068/176] fix(parsing): ignore empty metadata --- src/brand/dev/_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/brand/dev/_models.py b/src/brand/dev/_models.py index 528d568..ffcbf67 100644 --- a/src/brand/dev/_models.py +++ b/src/brand/dev/_models.py @@ -439,7 +439,7 @@ def construct_type(*, value: object, type_: object, metadata: Optional[List[Any] type_ = type_.__value__ # type: ignore[unreachable] # unwrap `Annotated[T, ...]` -> `T` - if metadata is not None: + if metadata is not None and len(metadata) > 0: meta: tuple[Any, ...] = tuple(metadata) elif is_annotated_type(type_): meta = get_args(type_)[1:] From 0bdc4e3dd3c5df00fc70ffe4ba667121c080d4e8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 02:02:59 +0000 Subject: [PATCH 069/176] fix(parsing): parse extra field types --- src/brand/dev/_models.py | 25 +++++++++++++++++++++++-- tests/test_models.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/brand/dev/_models.py b/src/brand/dev/_models.py index ffcbf67..b8387ce 100644 --- a/src/brand/dev/_models.py +++ b/src/brand/dev/_models.py @@ -208,14 +208,18 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] else: fields_values[name] = field_get_default(field) + extra_field_type = _get_extra_fields_type(__cls) + _extra = {} for key, value in values.items(): if key not in model_fields: + parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value + if PYDANTIC_V2: - _extra[key] = value + _extra[key] = parsed else: _fields_set.add(key) - fields_values[key] = value + fields_values[key] = parsed object.__setattr__(m, "__dict__", fields_values) @@ -370,6 +374,23 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) +def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: + if not PYDANTIC_V2: + # TODO + return None + + schema = cls.__pydantic_core_schema__ + if schema["type"] == "model": + fields = schema["schema"] + if fields["type"] == "model-fields": + extras = fields.get("extras_schema") + if extras and "cls" in extras: + # mypy can't narrow the type + return extras["cls"] # type: ignore[no-any-return] + + return None + + def is_basemodel(type_: type) -> bool: """Returns whether or not the given type is either a `BaseModel` or a union of `BaseModel`""" if is_union(type_): diff --git a/tests/test_models.py b/tests/test_models.py index d6413aa..fafd8c4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, List, Union, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast from datetime import datetime, timezone from typing_extensions import Literal, Annotated, TypeAliasType @@ -934,3 +934,30 @@ class Type2(BaseModel): ) assert isinstance(model, Type1) assert isinstance(model.value, InnerType2) + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="this is only supported in pydantic v2 for now") +def test_extra_properties() -> None: + class Item(BaseModel): + prop: int + + class Model(BaseModel): + __pydantic_extra__: Dict[str, Item] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + + other: str + + if TYPE_CHECKING: + + def __getattr__(self, attr: str) -> Item: ... + + model = construct_type( + type_=Model, + value={ + "a": {"prop": 1}, + "other": "foo", + }, + ) + assert isinstance(model, Model) + assert model.a.prop == 1 + assert isinstance(model.a, Item) + assert model.other == "foo" From 597e58c29f2498b358a96f129ef362cc3c90d7db Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 02:30:39 +0000 Subject: [PATCH 070/176] feat(api): manual updates --- .stats.yml | 4 ++-- src/brand/dev/types/brand_ai_query_response.py | 6 ++++++ .../dev/types/brand_identify_from_transaction_response.py | 3 +++ src/brand/dev/types/brand_retrieve_by_ticker_response.py | 3 +++ src/brand/dev/types/brand_retrieve_response.py | 3 +++ 5 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 2a58fb8..b669b06 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 9 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-70f44e886c51bd700af031ad9b5c8f0042ef15fde038ba83ed08f61cd9d05266.yml -openapi_spec_hash: 9b834ba9e373689a8e2fbd8312b1f2de +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-1ff30126e780960cb04d5855fb8e9227099f91e1a3293f656cfaad50e1d7eb1c.yml +openapi_spec_hash: 42c1034ce32cbe5410b124e577998de8 config_hash: 4b10254ea5b8e26ce632222b94a918aa diff --git a/src/brand/dev/types/brand_ai_query_response.py b/src/brand/dev/types/brand_ai_query_response.py index 0e9909b..66373a3 100644 --- a/src/brand/dev/types/brand_ai_query_response.py +++ b/src/brand/dev/types/brand_ai_query_response.py @@ -16,11 +16,17 @@ class DataExtracted(BaseModel): class BrandAIQueryResponse(BaseModel): + code: Optional[int] = None + """HTTP status code""" + data_extracted: Optional[List[DataExtracted]] = None """Array of extracted data points""" domain: Optional[str] = None """The domain that was analyzed""" + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" + urls_analyzed: Optional[List[str]] = None """List of URLs that were analyzed""" diff --git a/src/brand/dev/types/brand_identify_from_transaction_response.py b/src/brand/dev/types/brand_identify_from_transaction_response.py index a48a4df..0ca2da2 100644 --- a/src/brand/dev/types/brand_identify_from_transaction_response.py +++ b/src/brand/dev/types/brand_identify_from_transaction_response.py @@ -143,6 +143,9 @@ class Brand(BaseModel): domain: Optional[str] = None """The domain name of the brand""" + is_nsfw: Optional[bool] = None + """Indicates whether the brand content is not safe for work (NSFW)""" + logos: Optional[List[BrandLogo]] = None """An array of logos associated with the brand""" diff --git a/src/brand/dev/types/brand_retrieve_by_ticker_response.py b/src/brand/dev/types/brand_retrieve_by_ticker_response.py index fb0cfce..7c39a59 100644 --- a/src/brand/dev/types/brand_retrieve_by_ticker_response.py +++ b/src/brand/dev/types/brand_retrieve_by_ticker_response.py @@ -143,6 +143,9 @@ class Brand(BaseModel): domain: Optional[str] = None """The domain name of the brand""" + is_nsfw: Optional[bool] = None + """Indicates whether the brand content is not safe for work (NSFW)""" + logos: Optional[List[BrandLogo]] = None """An array of logos associated with the brand""" diff --git a/src/brand/dev/types/brand_retrieve_response.py b/src/brand/dev/types/brand_retrieve_response.py index 32f2115..66fb538 100644 --- a/src/brand/dev/types/brand_retrieve_response.py +++ b/src/brand/dev/types/brand_retrieve_response.py @@ -143,6 +143,9 @@ class Brand(BaseModel): domain: Optional[str] = None """The domain name of the brand""" + is_nsfw: Optional[bool] = None + """Indicates whether the brand content is not safe for work (NSFW)""" + logos: Optional[List[BrandLogo]] = None """An array of logos associated with the brand""" From e415c9825a68116876f955cdbffcdec4f5c3767b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 02:31:00 +0000 Subject: [PATCH 071/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index eb4e0db..e8fdcd6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.10.0" + ".": "1.10.1" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2e2b992..72e6d2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.10.0" +version = "1.10.1" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 46e2f07..9cf1805 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.10.0" # x-release-please-version +__version__ = "1.10.1" # x-release-please-version From c0f42634199b95881db9c66a0fb65a08158e17fa Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 25 Jul 2025 02:18:00 +0000 Subject: [PATCH 072/176] chore(project): add settings file for vscode --- .gitignore | 1 - .vscode/settings.json | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 8779740..95ceb18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ .prism.log -.vscode _dev __pycache__ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5b01030 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.analysis.importFormat": "relative", +} From be6e41f7b101fb3217293a5c7606124ce3df534b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 31 Jul 2025 02:52:55 +0000 Subject: [PATCH 073/176] feat(client): support file upload requests --- src/brand/dev/_base_client.py | 5 ++++- src/brand/dev/_files.py | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/brand/dev/_base_client.py b/src/brand/dev/_base_client.py index fcf0f40..5687adf 100644 --- a/src/brand/dev/_base_client.py +++ b/src/brand/dev/_base_client.py @@ -532,7 +532,10 @@ def _build_request( is_body_allowed = options.method.lower() != "get" if is_body_allowed: - kwargs["json"] = json_data if is_given(json_data) else None + if isinstance(json_data, bytes): + kwargs["content"] = json_data + else: + kwargs["json"] = json_data if is_given(json_data) else None kwargs["files"] = files else: headers.pop("Content-Type", None) diff --git a/src/brand/dev/_files.py b/src/brand/dev/_files.py index 715cc20..cc14c14 100644 --- a/src/brand/dev/_files.py +++ b/src/brand/dev/_files.py @@ -69,12 +69,12 @@ def _transform_file(file: FileTypes) -> HttpxFileTypes: return file if is_tuple_t(file): - return (file[0], _read_file_content(file[1]), *file[2:]) + return (file[0], read_file_content(file[1]), *file[2:]) raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") -def _read_file_content(file: FileContent) -> HttpxFileContent: +def read_file_content(file: FileContent) -> HttpxFileContent: if isinstance(file, os.PathLike): return pathlib.Path(file).read_bytes() return file @@ -111,12 +111,12 @@ async def _async_transform_file(file: FileTypes) -> HttpxFileTypes: return file if is_tuple_t(file): - return (file[0], await _async_read_file_content(file[1]), *file[2:]) + return (file[0], await async_read_file_content(file[1]), *file[2:]) raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") -async def _async_read_file_content(file: FileContent) -> HttpxFileContent: +async def async_read_file_content(file: FileContent) -> HttpxFileContent: if isinstance(file, os.PathLike): return await anyio.Path(file).read_bytes() From 7b55a70fbdea4e07f48b31cb69e53221582da7c6 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 31 Jul 2025 20:45:08 +0000 Subject: [PATCH 074/176] feat(api): manual updates --- .stats.yml | 4 +-- .../dev/types/brand_ai_query_response.py | 3 -- ...rand_identify_from_transaction_response.py | 29 +++++++++++++++---- .../brand_retrieve_by_ticker_response.py | 29 +++++++++++++++---- .../dev/types/brand_retrieve_response.py | 29 +++++++++++++++---- .../brand_retrieve_simplified_response.py | 18 +++++++----- 6 files changed, 81 insertions(+), 31 deletions(-) diff --git a/.stats.yml b/.stats.yml index b669b06..8e1aa70 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 9 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-1ff30126e780960cb04d5855fb8e9227099f91e1a3293f656cfaad50e1d7eb1c.yml -openapi_spec_hash: 42c1034ce32cbe5410b124e577998de8 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-a41dc66f0aa3dbc9e8fe1da75f1101cbcb2fac79f728de42e4877cfe1bde3b6e.yml +openapi_spec_hash: 63c1a53e0899fb63a514dad395fd48f9 config_hash: 4b10254ea5b8e26ce632222b94a918aa diff --git a/src/brand/dev/types/brand_ai_query_response.py b/src/brand/dev/types/brand_ai_query_response.py index 66373a3..53edd47 100644 --- a/src/brand/dev/types/brand_ai_query_response.py +++ b/src/brand/dev/types/brand_ai_query_response.py @@ -16,9 +16,6 @@ class DataExtracted(BaseModel): class BrandAIQueryResponse(BaseModel): - code: Optional[int] = None - """HTTP status code""" - data_extracted: Optional[List[DataExtracted]] = None """Array of extracted data points""" diff --git a/src/brand/dev/types/brand_identify_from_transaction_response.py b/src/brand/dev/types/brand_identify_from_transaction_response.py index 0ca2da2..a4ca52c 100644 --- a/src/brand/dev/types/brand_identify_from_transaction_response.py +++ b/src/brand/dev/types/brand_identify_from_transaction_response.py @@ -1,6 +1,7 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. from typing import List, Optional +from typing_extensions import Literal from .._models import BaseModel @@ -52,6 +53,9 @@ class BrandBackdropColor(BaseModel): class BrandBackdropResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + height: Optional[int] = None """Height of the image in pixels""" @@ -87,6 +91,9 @@ class BrandLogoColor(BaseModel): class BrandLogoResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + height: Optional[int] = None """Height of the image in pixels""" @@ -98,17 +105,21 @@ class BrandLogo(BaseModel): colors: Optional[List[BrandLogoColor]] = None """Array of colors in the logo""" - group: Optional[int] = None - """Group identifier for logos""" - - mode: Optional[str] = None - """Mode of the logo, e.g., 'dark', 'light'""" + mode: Optional[Literal["light", "dark", "has_opaque_background"]] = None + """ + Indicates when this logo is best used: 'light' = best for light mode, 'dark' = + best for dark mode, 'has_opaque_background' = can be used for either as image + has its own background + """ resolution: Optional[BrandLogoResolution] = None """Resolution of the logo image""" + type: Optional[Literal["icon", "logo"]] = None + """Type of the logo based on resolution (e.g., 'icon', 'logo')""" + url: Optional[str] = None - """URL of the logo image""" + """CDN hosted url of the logo (ready for display)""" class BrandSocial(BaseModel): @@ -143,12 +154,18 @@ class Brand(BaseModel): domain: Optional[str] = None """The domain name of the brand""" + email: Optional[str] = None + """Company email address""" + is_nsfw: Optional[bool] = None """Indicates whether the brand content is not safe for work (NSFW)""" logos: Optional[List[BrandLogo]] = None """An array of logos associated with the brand""" + phone: Optional[str] = None + """Company phone number""" + slogan: Optional[str] = None """The brand's slogan""" diff --git a/src/brand/dev/types/brand_retrieve_by_ticker_response.py b/src/brand/dev/types/brand_retrieve_by_ticker_response.py index 7c39a59..918b67c 100644 --- a/src/brand/dev/types/brand_retrieve_by_ticker_response.py +++ b/src/brand/dev/types/brand_retrieve_by_ticker_response.py @@ -1,6 +1,7 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. from typing import List, Optional +from typing_extensions import Literal from .._models import BaseModel @@ -52,6 +53,9 @@ class BrandBackdropColor(BaseModel): class BrandBackdropResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + height: Optional[int] = None """Height of the image in pixels""" @@ -87,6 +91,9 @@ class BrandLogoColor(BaseModel): class BrandLogoResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + height: Optional[int] = None """Height of the image in pixels""" @@ -98,17 +105,21 @@ class BrandLogo(BaseModel): colors: Optional[List[BrandLogoColor]] = None """Array of colors in the logo""" - group: Optional[int] = None - """Group identifier for logos""" - - mode: Optional[str] = None - """Mode of the logo, e.g., 'dark', 'light'""" + mode: Optional[Literal["light", "dark", "has_opaque_background"]] = None + """ + Indicates when this logo is best used: 'light' = best for light mode, 'dark' = + best for dark mode, 'has_opaque_background' = can be used for either as image + has its own background + """ resolution: Optional[BrandLogoResolution] = None """Resolution of the logo image""" + type: Optional[Literal["icon", "logo"]] = None + """Type of the logo based on resolution (e.g., 'icon', 'logo')""" + url: Optional[str] = None - """URL of the logo image""" + """CDN hosted url of the logo (ready for display)""" class BrandSocial(BaseModel): @@ -143,12 +154,18 @@ class Brand(BaseModel): domain: Optional[str] = None """The domain name of the brand""" + email: Optional[str] = None + """Company email address""" + is_nsfw: Optional[bool] = None """Indicates whether the brand content is not safe for work (NSFW)""" logos: Optional[List[BrandLogo]] = None """An array of logos associated with the brand""" + phone: Optional[str] = None + """Company phone number""" + slogan: Optional[str] = None """The brand's slogan""" diff --git a/src/brand/dev/types/brand_retrieve_response.py b/src/brand/dev/types/brand_retrieve_response.py index 66fb538..a684da9 100644 --- a/src/brand/dev/types/brand_retrieve_response.py +++ b/src/brand/dev/types/brand_retrieve_response.py @@ -1,6 +1,7 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. from typing import List, Optional +from typing_extensions import Literal from .._models import BaseModel @@ -52,6 +53,9 @@ class BrandBackdropColor(BaseModel): class BrandBackdropResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + height: Optional[int] = None """Height of the image in pixels""" @@ -87,6 +91,9 @@ class BrandLogoColor(BaseModel): class BrandLogoResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + height: Optional[int] = None """Height of the image in pixels""" @@ -98,17 +105,21 @@ class BrandLogo(BaseModel): colors: Optional[List[BrandLogoColor]] = None """Array of colors in the logo""" - group: Optional[int] = None - """Group identifier for logos""" - - mode: Optional[str] = None - """Mode of the logo, e.g., 'dark', 'light'""" + mode: Optional[Literal["light", "dark", "has_opaque_background"]] = None + """ + Indicates when this logo is best used: 'light' = best for light mode, 'dark' = + best for dark mode, 'has_opaque_background' = can be used for either as image + has its own background + """ resolution: Optional[BrandLogoResolution] = None """Resolution of the logo image""" + type: Optional[Literal["icon", "logo"]] = None + """Type of the logo based on resolution (e.g., 'icon', 'logo')""" + url: Optional[str] = None - """URL of the logo image""" + """CDN hosted url of the logo (ready for display)""" class BrandSocial(BaseModel): @@ -143,12 +154,18 @@ class Brand(BaseModel): domain: Optional[str] = None """The domain name of the brand""" + email: Optional[str] = None + """Company email address""" + is_nsfw: Optional[bool] = None """Indicates whether the brand content is not safe for work (NSFW)""" logos: Optional[List[BrandLogo]] = None """An array of logos associated with the brand""" + phone: Optional[str] = None + """Company phone number""" + slogan: Optional[str] = None """The brand's slogan""" diff --git a/src/brand/dev/types/brand_retrieve_simplified_response.py b/src/brand/dev/types/brand_retrieve_simplified_response.py index b865ff8..523fe18 100644 --- a/src/brand/dev/types/brand_retrieve_simplified_response.py +++ b/src/brand/dev/types/brand_retrieve_simplified_response.py @@ -1,6 +1,7 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. from typing import List, Optional +from typing_extensions import Literal from .._models import BaseModel @@ -78,20 +79,21 @@ class BrandLogo(BaseModel): colors: Optional[List[BrandLogoColor]] = None """Array of colors in the logo""" - group: Optional[int] = None - """Group identifier for logos""" - - mode: Optional[str] = None - """Mode of the logo, e.g., 'dark', 'light'""" + mode: Optional[Literal["light", "dark", "has_opaque_background"]] = None + """ + Indicates when this logo is best used: 'light' = best for light mode, 'dark' = + best for dark mode, 'has_opaque_background' = can be used for either as image + has its own background + """ resolution: Optional[BrandLogoResolution] = None """Resolution of the logo image""" - type: Optional[str] = None - """Type of the logo based on resolution (e.g., 'icon', 'logo', 'banner')""" + type: Optional[Literal["icon", "logo"]] = None + """Type of the logo based on resolution (e.g., 'icon', 'logo')""" url: Optional[str] = None - """URL of the logo image""" + """CDN hosted url of the logo (ready for display)""" class Brand(BaseModel): From 63d625fa54b5d1471f4fc56107dc82bc4de9df0c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 31 Jul 2025 20:50:03 +0000 Subject: [PATCH 075/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e8fdcd6..caf1487 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.10.1" + ".": "1.11.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 72e6d2c..2fa0621 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.10.1" +version = "1.11.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 9cf1805..641ac2a 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.10.1" # x-release-please-version +__version__ = "1.11.0" # x-release-please-version From 5fd1c9b9388b4b38ebf076f3323ddadf7e57e9a5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 02:49:53 +0000 Subject: [PATCH 076/176] chore(internal): fix ruff target version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2fa0621..1d0a248 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,7 +159,7 @@ reportPrivateUsage = false [tool.ruff] line-length = 120 output-format = "grouped" -target-version = "py37" +target-version = "py38" [tool.ruff.format] docstring-code-format = true From 102645e726ffad1bf6a5d60c5d6a1c77d8db0233 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 02:29:39 +0000 Subject: [PATCH 077/176] chore: update @stainless-api/prism-cli to v5.15.0 --- scripts/mock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/mock b/scripts/mock index d2814ae..0b28f6e 100755 --- a/scripts/mock +++ b/scripts/mock @@ -21,7 +21,7 @@ echo "==> Starting mock server with URL ${URL}" # Run prism mock on the given spec if [ "$1" == "--daemon" ]; then - npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" &> .prism.log & + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & # Wait for server to come online echo -n "Waiting for server" @@ -37,5 +37,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" fi From f303cb281aa946377bff9630e92a1378c2b0fff2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 02:46:00 +0000 Subject: [PATCH 078/176] chore(internal): update comment in script --- scripts/test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test b/scripts/test index 2b87845..dbeda2d 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! prism_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the prism command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stoplight/prism-cli@~5.3.2 -- prism mock path/to/your.openapi.yml${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" echo exit 1 From 09c13ffa2ae9fbab5cea403a2c636011a37cf858 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 02:04:38 +0000 Subject: [PATCH 079/176] chore(internal): codegen related update --- tests/api_resources/test_brand.py | 144 +++++++++++++++--------------- 1 file changed, 72 insertions(+), 72 deletions(-) diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 571963c..cc5f035 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -27,7 +27,7 @@ class TestBrand: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve(self, client: BrandDev) -> None: brand = client.brand.retrieve( @@ -35,7 +35,7 @@ def test_method_retrieve(self, client: BrandDev) -> None: ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve_with_all_params(self, client: BrandDev) -> None: brand = client.brand.retrieve( @@ -46,7 +46,7 @@ def test_method_retrieve_with_all_params(self, client: BrandDev) -> None: ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_raw_response_retrieve(self, client: BrandDev) -> None: response = client.brand.with_raw_response.retrieve( @@ -58,7 +58,7 @@ def test_raw_response_retrieve(self, client: BrandDev) -> None: brand = response.parse() assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_streaming_response_retrieve(self, client: BrandDev) -> None: with client.brand.with_streaming_response.retrieve( @@ -72,7 +72,7 @@ def test_streaming_response_retrieve(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_ai_query(self, client: BrandDev) -> None: brand = client.brand.ai_query( @@ -88,7 +88,7 @@ def test_method_ai_query(self, client: BrandDev) -> None: ) assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_ai_query_with_all_params(self, client: BrandDev) -> None: brand = client.brand.ai_query( @@ -115,7 +115,7 @@ def test_method_ai_query_with_all_params(self, client: BrandDev) -> None: ) assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_raw_response_ai_query(self, client: BrandDev) -> None: response = client.brand.with_raw_response.ai_query( @@ -135,7 +135,7 @@ def test_raw_response_ai_query(self, client: BrandDev) -> None: brand = response.parse() assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_streaming_response_ai_query(self, client: BrandDev) -> None: with client.brand.with_streaming_response.ai_query( @@ -157,7 +157,7 @@ def test_streaming_response_ai_query(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_identify_from_transaction(self, client: BrandDev) -> None: brand = client.brand.identify_from_transaction( @@ -165,7 +165,7 @@ def test_method_identify_from_transaction(self, client: BrandDev) -> None: ) assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_identify_from_transaction_with_all_params(self, client: BrandDev) -> None: brand = client.brand.identify_from_transaction( @@ -174,7 +174,7 @@ def test_method_identify_from_transaction_with_all_params(self, client: BrandDev ) assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_raw_response_identify_from_transaction(self, client: BrandDev) -> None: response = client.brand.with_raw_response.identify_from_transaction( @@ -186,7 +186,7 @@ def test_raw_response_identify_from_transaction(self, client: BrandDev) -> None: brand = response.parse() assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_streaming_response_identify_from_transaction(self, client: BrandDev) -> None: with client.brand.with_streaming_response.identify_from_transaction( @@ -200,7 +200,7 @@ def test_streaming_response_identify_from_transaction(self, client: BrandDev) -> assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_prefetch(self, client: BrandDev) -> None: brand = client.brand.prefetch( @@ -208,7 +208,7 @@ def test_method_prefetch(self, client: BrandDev) -> None: ) assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_prefetch_with_all_params(self, client: BrandDev) -> None: brand = client.brand.prefetch( @@ -217,7 +217,7 @@ def test_method_prefetch_with_all_params(self, client: BrandDev) -> None: ) assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_raw_response_prefetch(self, client: BrandDev) -> None: response = client.brand.with_raw_response.prefetch( @@ -229,7 +229,7 @@ def test_raw_response_prefetch(self, client: BrandDev) -> None: brand = response.parse() assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_streaming_response_prefetch(self, client: BrandDev) -> None: with client.brand.with_streaming_response.prefetch( @@ -243,7 +243,7 @@ def test_streaming_response_prefetch(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve_by_ticker(self, client: BrandDev) -> None: brand = client.brand.retrieve_by_ticker( @@ -251,7 +251,7 @@ def test_method_retrieve_by_ticker(self, client: BrandDev) -> None: ) assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve_by_ticker_with_all_params(self, client: BrandDev) -> None: brand = client.brand.retrieve_by_ticker( @@ -260,7 +260,7 @@ def test_method_retrieve_by_ticker_with_all_params(self, client: BrandDev) -> No ) assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_raw_response_retrieve_by_ticker(self, client: BrandDev) -> None: response = client.brand.with_raw_response.retrieve_by_ticker( @@ -272,7 +272,7 @@ def test_raw_response_retrieve_by_ticker(self, client: BrandDev) -> None: brand = response.parse() assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_streaming_response_retrieve_by_ticker(self, client: BrandDev) -> None: with client.brand.with_streaming_response.retrieve_by_ticker( @@ -286,7 +286,7 @@ def test_streaming_response_retrieve_by_ticker(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve_naics(self, client: BrandDev) -> None: brand = client.brand.retrieve_naics( @@ -294,7 +294,7 @@ def test_method_retrieve_naics(self, client: BrandDev) -> None: ) assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve_naics_with_all_params(self, client: BrandDev) -> None: brand = client.brand.retrieve_naics( @@ -303,7 +303,7 @@ def test_method_retrieve_naics_with_all_params(self, client: BrandDev) -> None: ) assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_raw_response_retrieve_naics(self, client: BrandDev) -> None: response = client.brand.with_raw_response.retrieve_naics( @@ -315,7 +315,7 @@ def test_raw_response_retrieve_naics(self, client: BrandDev) -> None: brand = response.parse() assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_streaming_response_retrieve_naics(self, client: BrandDev) -> None: with client.brand.with_streaming_response.retrieve_naics( @@ -329,7 +329,7 @@ def test_streaming_response_retrieve_naics(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve_simplified(self, client: BrandDev) -> None: brand = client.brand.retrieve_simplified( @@ -337,7 +337,7 @@ def test_method_retrieve_simplified(self, client: BrandDev) -> None: ) assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve_simplified_with_all_params(self, client: BrandDev) -> None: brand = client.brand.retrieve_simplified( @@ -346,7 +346,7 @@ def test_method_retrieve_simplified_with_all_params(self, client: BrandDev) -> N ) assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_raw_response_retrieve_simplified(self, client: BrandDev) -> None: response = client.brand.with_raw_response.retrieve_simplified( @@ -358,7 +358,7 @@ def test_raw_response_retrieve_simplified(self, client: BrandDev) -> None: brand = response.parse() assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_streaming_response_retrieve_simplified(self, client: BrandDev) -> None: with client.brand.with_streaming_response.retrieve_simplified( @@ -372,7 +372,7 @@ def test_streaming_response_retrieve_simplified(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_screenshot(self, client: BrandDev) -> None: brand = client.brand.screenshot( @@ -380,7 +380,7 @@ def test_method_screenshot(self, client: BrandDev) -> None: ) assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_screenshot_with_all_params(self, client: BrandDev) -> None: brand = client.brand.screenshot( @@ -389,7 +389,7 @@ def test_method_screenshot_with_all_params(self, client: BrandDev) -> None: ) assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_raw_response_screenshot(self, client: BrandDev) -> None: response = client.brand.with_raw_response.screenshot( @@ -401,7 +401,7 @@ def test_raw_response_screenshot(self, client: BrandDev) -> None: brand = response.parse() assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_streaming_response_screenshot(self, client: BrandDev) -> None: with client.brand.with_streaming_response.screenshot( @@ -415,7 +415,7 @@ def test_streaming_response_screenshot(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_styleguide(self, client: BrandDev) -> None: brand = client.brand.styleguide( @@ -423,7 +423,7 @@ def test_method_styleguide(self, client: BrandDev) -> None: ) assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_styleguide_with_all_params(self, client: BrandDev) -> None: brand = client.brand.styleguide( @@ -432,7 +432,7 @@ def test_method_styleguide_with_all_params(self, client: BrandDev) -> None: ) assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_raw_response_styleguide(self, client: BrandDev) -> None: response = client.brand.with_raw_response.styleguide( @@ -444,7 +444,7 @@ def test_raw_response_styleguide(self, client: BrandDev) -> None: brand = response.parse() assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_streaming_response_styleguide(self, client: BrandDev) -> None: with client.brand.with_streaming_response.styleguide( @@ -464,7 +464,7 @@ class TestAsyncBrand: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.retrieve( @@ -472,7 +472,7 @@ async def test_method_retrieve(self, async_client: AsyncBrandDev) -> None: ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve_with_all_params(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.retrieve( @@ -483,7 +483,7 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncBrandDev ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_raw_response_retrieve(self, async_client: AsyncBrandDev) -> None: response = await async_client.brand.with_raw_response.retrieve( @@ -495,7 +495,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncBrandDev) -> None: brand = await response.parse() assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncBrandDev) -> None: async with async_client.brand.with_streaming_response.retrieve( @@ -509,7 +509,7 @@ async def test_streaming_response_retrieve(self, async_client: AsyncBrandDev) -> assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_ai_query(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.ai_query( @@ -525,7 +525,7 @@ async def test_method_ai_query(self, async_client: AsyncBrandDev) -> None: ) assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_ai_query_with_all_params(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.ai_query( @@ -552,7 +552,7 @@ async def test_method_ai_query_with_all_params(self, async_client: AsyncBrandDev ) assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_raw_response_ai_query(self, async_client: AsyncBrandDev) -> None: response = await async_client.brand.with_raw_response.ai_query( @@ -572,7 +572,7 @@ async def test_raw_response_ai_query(self, async_client: AsyncBrandDev) -> None: brand = await response.parse() assert_matches_type(BrandAIQueryResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_streaming_response_ai_query(self, async_client: AsyncBrandDev) -> None: async with async_client.brand.with_streaming_response.ai_query( @@ -594,7 +594,7 @@ async def test_streaming_response_ai_query(self, async_client: AsyncBrandDev) -> assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_identify_from_transaction(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.identify_from_transaction( @@ -602,7 +602,7 @@ async def test_method_identify_from_transaction(self, async_client: AsyncBrandDe ) assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_identify_from_transaction_with_all_params(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.identify_from_transaction( @@ -611,7 +611,7 @@ async def test_method_identify_from_transaction_with_all_params(self, async_clie ) assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_raw_response_identify_from_transaction(self, async_client: AsyncBrandDev) -> None: response = await async_client.brand.with_raw_response.identify_from_transaction( @@ -623,7 +623,7 @@ async def test_raw_response_identify_from_transaction(self, async_client: AsyncB brand = await response.parse() assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_streaming_response_identify_from_transaction(self, async_client: AsyncBrandDev) -> None: async with async_client.brand.with_streaming_response.identify_from_transaction( @@ -637,7 +637,7 @@ async def test_streaming_response_identify_from_transaction(self, async_client: assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_prefetch(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.prefetch( @@ -645,7 +645,7 @@ async def test_method_prefetch(self, async_client: AsyncBrandDev) -> None: ) assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_prefetch_with_all_params(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.prefetch( @@ -654,7 +654,7 @@ async def test_method_prefetch_with_all_params(self, async_client: AsyncBrandDev ) assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_raw_response_prefetch(self, async_client: AsyncBrandDev) -> None: response = await async_client.brand.with_raw_response.prefetch( @@ -666,7 +666,7 @@ async def test_raw_response_prefetch(self, async_client: AsyncBrandDev) -> None: brand = await response.parse() assert_matches_type(BrandPrefetchResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_streaming_response_prefetch(self, async_client: AsyncBrandDev) -> None: async with async_client.brand.with_streaming_response.prefetch( @@ -680,7 +680,7 @@ async def test_streaming_response_prefetch(self, async_client: AsyncBrandDev) -> assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.retrieve_by_ticker( @@ -688,7 +688,7 @@ async def test_method_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> N ) assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve_by_ticker_with_all_params(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.retrieve_by_ticker( @@ -697,7 +697,7 @@ async def test_method_retrieve_by_ticker_with_all_params(self, async_client: Asy ) assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_raw_response_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: response = await async_client.brand.with_raw_response.retrieve_by_ticker( @@ -709,7 +709,7 @@ async def test_raw_response_retrieve_by_ticker(self, async_client: AsyncBrandDev brand = await response.parse() assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_streaming_response_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: async with async_client.brand.with_streaming_response.retrieve_by_ticker( @@ -723,7 +723,7 @@ async def test_streaming_response_retrieve_by_ticker(self, async_client: AsyncBr assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve_naics(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.retrieve_naics( @@ -731,7 +731,7 @@ async def test_method_retrieve_naics(self, async_client: AsyncBrandDev) -> None: ) assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve_naics_with_all_params(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.retrieve_naics( @@ -740,7 +740,7 @@ async def test_method_retrieve_naics_with_all_params(self, async_client: AsyncBr ) assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_raw_response_retrieve_naics(self, async_client: AsyncBrandDev) -> None: response = await async_client.brand.with_raw_response.retrieve_naics( @@ -752,7 +752,7 @@ async def test_raw_response_retrieve_naics(self, async_client: AsyncBrandDev) -> brand = await response.parse() assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_streaming_response_retrieve_naics(self, async_client: AsyncBrandDev) -> None: async with async_client.brand.with_streaming_response.retrieve_naics( @@ -766,7 +766,7 @@ async def test_streaming_response_retrieve_naics(self, async_client: AsyncBrandD assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve_simplified(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.retrieve_simplified( @@ -774,7 +774,7 @@ async def test_method_retrieve_simplified(self, async_client: AsyncBrandDev) -> ) assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve_simplified_with_all_params(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.retrieve_simplified( @@ -783,7 +783,7 @@ async def test_method_retrieve_simplified_with_all_params(self, async_client: As ) assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_raw_response_retrieve_simplified(self, async_client: AsyncBrandDev) -> None: response = await async_client.brand.with_raw_response.retrieve_simplified( @@ -795,7 +795,7 @@ async def test_raw_response_retrieve_simplified(self, async_client: AsyncBrandDe brand = await response.parse() assert_matches_type(BrandRetrieveSimplifiedResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_streaming_response_retrieve_simplified(self, async_client: AsyncBrandDev) -> None: async with async_client.brand.with_streaming_response.retrieve_simplified( @@ -809,7 +809,7 @@ async def test_streaming_response_retrieve_simplified(self, async_client: AsyncB assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_screenshot(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.screenshot( @@ -817,7 +817,7 @@ async def test_method_screenshot(self, async_client: AsyncBrandDev) -> None: ) assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_screenshot_with_all_params(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.screenshot( @@ -826,7 +826,7 @@ async def test_method_screenshot_with_all_params(self, async_client: AsyncBrandD ) assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_raw_response_screenshot(self, async_client: AsyncBrandDev) -> None: response = await async_client.brand.with_raw_response.screenshot( @@ -838,7 +838,7 @@ async def test_raw_response_screenshot(self, async_client: AsyncBrandDev) -> Non brand = await response.parse() assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_streaming_response_screenshot(self, async_client: AsyncBrandDev) -> None: async with async_client.brand.with_streaming_response.screenshot( @@ -852,7 +852,7 @@ async def test_streaming_response_screenshot(self, async_client: AsyncBrandDev) assert cast(Any, response.is_closed) is True - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_styleguide(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.styleguide( @@ -860,7 +860,7 @@ async def test_method_styleguide(self, async_client: AsyncBrandDev) -> None: ) assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_styleguide_with_all_params(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.styleguide( @@ -869,7 +869,7 @@ async def test_method_styleguide_with_all_params(self, async_client: AsyncBrandD ) assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_raw_response_styleguide(self, async_client: AsyncBrandDev) -> None: response = await async_client.brand.with_raw_response.styleguide( @@ -881,7 +881,7 @@ async def test_raw_response_styleguide(self, async_client: AsyncBrandDev) -> Non brand = await response.parse() assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) - @pytest.mark.skip() + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_streaming_response_styleguide(self, async_client: AsyncBrandDev) -> None: async with async_client.brand.with_streaming_response.styleguide( From f8998d4f603ef7eb6e0c23126b517047ab226509 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 23:15:13 +0000 Subject: [PATCH 080/176] feat(api): api update --- .stats.yml | 4 +- src/brand/dev/resources/brand.py | 50 ++++++++++++++++---- src/brand/dev/types/brand_retrieve_params.py | 31 ++++++++++-- tests/api_resources/test_brand.py | 28 ++++------- tests/test_client.py | 28 ++++------- 5 files changed, 88 insertions(+), 53 deletions(-) diff --git a/.stats.yml b/.stats.yml index 8e1aa70..744470a 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 9 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-a41dc66f0aa3dbc9e8fe1da75f1101cbcb2fac79f728de42e4877cfe1bde3b6e.yml -openapi_spec_hash: 63c1a53e0899fb63a514dad395fd48f9 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-0543797350396398604460eff74d21a58372a2dc9a0910544f9de4d42fed5be5.yml +openapi_spec_hash: c0aaec018ae2ebed2ea50025d49f1430 config_hash: 4b10254ea5b8e26ce632222b94a918aa diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 58a3116..951c66f 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -65,7 +65,7 @@ def with_streaming_response(self) -> BrandResourceWithStreamingResponse: def retrieve( self, *, - domain: str, + domain: str | NotGiven = NOT_GIVEN, force_language: Literal[ "albanian", "arabic", @@ -122,6 +122,8 @@ def retrieve( ] | NotGiven = NOT_GIVEN, max_speed: bool | NotGiven = NOT_GIVEN, + name: str | NotGiven = NOT_GIVEN, + ticker: str | NotGiven = NOT_GIVEN, timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -131,16 +133,27 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> BrandRetrieveResponse: """ - Retrieve brand data by domain + Retrieve brand information using one of three methods: domain name, company + name, or stock ticker symbol. Exactly one of these parameters must be provided. Args: - domain: Domain name to retrieve brand data for + domain: Domain name to retrieve brand data for (e.g., 'example.com', 'google.com'). + Cannot be used with name or ticker parameters. - force_language: Optional parameter to force the language of the retrieved brand data + force_language: Optional parameter to force the language of the retrieved brand data. Works with + all three lookup methods. max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, the API will skip time-consuming operations for faster response at the cost of - less comprehensive data. + less comprehensive data. Works with all three lookup methods. + + name: Company name to retrieve brand data for (e.g., 'Apple Inc', 'Microsoft + Corporation'). Must be 3-30 characters. Cannot be used with domain or ticker + parameters. + + ticker: Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). + Must be 1-6 characters, letters/numbers/dots only. Cannot be used with domain or + name parameters. timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed @@ -166,6 +179,8 @@ def retrieve( "domain": domain, "force_language": force_language, "max_speed": max_speed, + "name": name, + "ticker": ticker, "timeout_ms": timeout_ms, }, brand_retrieve_params.BrandRetrieveParams, @@ -603,7 +618,7 @@ def with_streaming_response(self) -> AsyncBrandResourceWithStreamingResponse: async def retrieve( self, *, - domain: str, + domain: str | NotGiven = NOT_GIVEN, force_language: Literal[ "albanian", "arabic", @@ -660,6 +675,8 @@ async def retrieve( ] | NotGiven = NOT_GIVEN, max_speed: bool | NotGiven = NOT_GIVEN, + name: str | NotGiven = NOT_GIVEN, + ticker: str | NotGiven = NOT_GIVEN, timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -669,16 +686,27 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> BrandRetrieveResponse: """ - Retrieve brand data by domain + Retrieve brand information using one of three methods: domain name, company + name, or stock ticker symbol. Exactly one of these parameters must be provided. Args: - domain: Domain name to retrieve brand data for + domain: Domain name to retrieve brand data for (e.g., 'example.com', 'google.com'). + Cannot be used with name or ticker parameters. - force_language: Optional parameter to force the language of the retrieved brand data + force_language: Optional parameter to force the language of the retrieved brand data. Works with + all three lookup methods. max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, the API will skip time-consuming operations for faster response at the cost of - less comprehensive data. + less comprehensive data. Works with all three lookup methods. + + name: Company name to retrieve brand data for (e.g., 'Apple Inc', 'Microsoft + Corporation'). Must be 3-30 characters. Cannot be used with domain or ticker + parameters. + + ticker: Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). + Must be 1-6 characters, letters/numbers/dots only. Cannot be used with domain or + name parameters. timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed @@ -704,6 +732,8 @@ async def retrieve( "domain": domain, "force_language": force_language, "max_speed": max_speed, + "name": name, + "ticker": ticker, "timeout_ms": timeout_ms, }, brand_retrieve_params.BrandRetrieveParams, diff --git a/src/brand/dev/types/brand_retrieve_params.py b/src/brand/dev/types/brand_retrieve_params.py index e5e8555..8092de7 100644 --- a/src/brand/dev/types/brand_retrieve_params.py +++ b/src/brand/dev/types/brand_retrieve_params.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing_extensions import Literal, Required, Annotated, TypedDict +from typing_extensions import Literal, Annotated, TypedDict from .._utils import PropertyInfo @@ -10,8 +10,11 @@ class BrandRetrieveParams(TypedDict, total=False): - domain: Required[str] - """Domain name to retrieve brand data for""" + domain: str + """Domain name to retrieve brand data for (e.g., 'example.com', 'google.com'). + + Cannot be used with name or ticker parameters. + """ force_language: Literal[ "albanian", @@ -67,13 +70,31 @@ class BrandRetrieveParams(TypedDict, total=False): "vietnamese", "welsh", ] - """Optional parameter to force the language of the retrieved brand data""" + """Optional parameter to force the language of the retrieved brand data. + + Works with all three lookup methods. + """ max_speed: Annotated[bool, PropertyInfo(alias="maxSpeed")] """Optional parameter to optimize the API call for maximum speed. When set to true, the API will skip time-consuming operations for faster - response at the cost of less comprehensive data. + response at the cost of less comprehensive data. Works with all three lookup + methods. + """ + + name: str + """ + Company name to retrieve brand data for (e.g., 'Apple Inc', 'Microsoft + Corporation'). Must be 3-30 characters. Cannot be used with domain or ticker + parameters. + """ + + ticker: str + """Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). + + Must be 1-6 characters, letters/numbers/dots only. Cannot be used with domain or + name parameters. """ timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index cc5f035..117a8e0 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -30,9 +30,7 @@ class TestBrand: @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve(self, client: BrandDev) -> None: - brand = client.brand.retrieve( - domain="domain", - ) + brand = client.brand.retrieve() assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) @pytest.mark.skip(reason="Prism tests are disabled") @@ -42,6 +40,8 @@ def test_method_retrieve_with_all_params(self, client: BrandDev) -> None: domain="domain", force_language="albanian", max_speed=True, + name="xxx", + ticker="ticker", timeout_ms=1, ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) @@ -49,9 +49,7 @@ def test_method_retrieve_with_all_params(self, client: BrandDev) -> None: @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_raw_response_retrieve(self, client: BrandDev) -> None: - response = client.brand.with_raw_response.retrieve( - domain="domain", - ) + response = client.brand.with_raw_response.retrieve() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -61,9 +59,7 @@ def test_raw_response_retrieve(self, client: BrandDev) -> None: @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_streaming_response_retrieve(self, client: BrandDev) -> None: - with client.brand.with_streaming_response.retrieve( - domain="domain", - ) as response: + with client.brand.with_streaming_response.retrieve() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -467,9 +463,7 @@ class TestAsyncBrand: @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve(self, async_client: AsyncBrandDev) -> None: - brand = await async_client.brand.retrieve( - domain="domain", - ) + brand = await async_client.brand.retrieve() assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) @pytest.mark.skip(reason="Prism tests are disabled") @@ -479,6 +473,8 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncBrandDev domain="domain", force_language="albanian", max_speed=True, + name="xxx", + ticker="ticker", timeout_ms=1, ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) @@ -486,9 +482,7 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncBrandDev @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_raw_response_retrieve(self, async_client: AsyncBrandDev) -> None: - response = await async_client.brand.with_raw_response.retrieve( - domain="domain", - ) + response = await async_client.brand.with_raw_response.retrieve() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -498,9 +492,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncBrandDev) -> None: @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncBrandDev) -> None: - async with async_client.brand.with_streaming_response.retrieve( - domain="domain", - ) as response: + async with async_client.brand.with_streaming_response.retrieve() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" diff --git a/tests/test_client.py b/tests/test_client.py index a2b359c..7473b12 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -717,7 +717,7 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, clien respx_mock.get("/brand/retrieve").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - client.brand.with_streaming_response.retrieve(domain="domain").__enter__() + client.brand.with_streaming_response.retrieve().__enter__() assert _get_open_connections(self.client) == 0 @@ -727,7 +727,7 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client respx_mock.get("/brand/retrieve").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - client.brand.with_streaming_response.retrieve(domain="domain").__enter__() + client.brand.with_streaming_response.retrieve().__enter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -756,7 +756,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: respx_mock.get("/brand/retrieve").mock(side_effect=retry_handler) - response = client.brand.with_raw_response.retrieve(domain="domain") + response = client.brand.with_raw_response.retrieve() assert response.retries_taken == failures_before_success assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success @@ -780,9 +780,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: respx_mock.get("/brand/retrieve").mock(side_effect=retry_handler) - response = client.brand.with_raw_response.retrieve( - domain="domain", extra_headers={"x-stainless-retry-count": Omit()} - ) + response = client.brand.with_raw_response.retrieve(extra_headers={"x-stainless-retry-count": Omit()}) assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 @@ -805,9 +803,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: respx_mock.get("/brand/retrieve").mock(side_effect=retry_handler) - response = client.brand.with_raw_response.retrieve( - domain="domain", extra_headers={"x-stainless-retry-count": "42"} - ) + response = client.brand.with_raw_response.retrieve(extra_headers={"x-stainless-retry-count": "42"}) assert response.http_request.headers.get("x-stainless-retry-count") == "42" @@ -1538,7 +1534,7 @@ async def test_retrying_timeout_errors_doesnt_leak( respx_mock.get("/brand/retrieve").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - await async_client.brand.with_streaming_response.retrieve(domain="domain").__aenter__() + await async_client.brand.with_streaming_response.retrieve().__aenter__() assert _get_open_connections(self.client) == 0 @@ -1550,7 +1546,7 @@ async def test_retrying_status_errors_doesnt_leak( respx_mock.get("/brand/retrieve").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - await async_client.brand.with_streaming_response.retrieve(domain="domain").__aenter__() + await async_client.brand.with_streaming_response.retrieve().__aenter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -1580,7 +1576,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: respx_mock.get("/brand/retrieve").mock(side_effect=retry_handler) - response = await client.brand.with_raw_response.retrieve(domain="domain") + response = await client.brand.with_raw_response.retrieve() assert response.retries_taken == failures_before_success assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success @@ -1605,9 +1601,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: respx_mock.get("/brand/retrieve").mock(side_effect=retry_handler) - response = await client.brand.with_raw_response.retrieve( - domain="domain", extra_headers={"x-stainless-retry-count": Omit()} - ) + response = await client.brand.with_raw_response.retrieve(extra_headers={"x-stainless-retry-count": Omit()}) assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 @@ -1631,9 +1625,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: respx_mock.get("/brand/retrieve").mock(side_effect=retry_handler) - response = await client.brand.with_raw_response.retrieve( - domain="domain", extra_headers={"x-stainless-retry-count": "42"} - ) + response = await client.brand.with_raw_response.retrieve(extra_headers={"x-stainless-retry-count": "42"}) assert response.http_request.headers.get("x-stainless-retry-count") == "42" From 14a1501ef91b8b989c22721b153c1236f1f06f5d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 23:32:27 +0000 Subject: [PATCH 081/176] feat(api): api update --- .stats.yml | 6 +- api.md | 2 - src/brand/dev/resources/brand.py | 112 ---------- src/brand/dev/types/__init__.py | 2 - .../types/brand_retrieve_by_ticker_params.py | 21 -- .../brand_retrieve_by_ticker_response.py | 193 ------------------ tests/api_resources/test_brand.py | 87 -------- 7 files changed, 3 insertions(+), 420 deletions(-) delete mode 100644 src/brand/dev/types/brand_retrieve_by_ticker_params.py delete mode 100644 src/brand/dev/types/brand_retrieve_by_ticker_response.py diff --git a/.stats.yml b/.stats.yml index 744470a..b6d4a92 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 9 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-0543797350396398604460eff74d21a58372a2dc9a0910544f9de4d42fed5be5.yml -openapi_spec_hash: c0aaec018ae2ebed2ea50025d49f1430 +configured_endpoints: 8 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-ab54646eee0ba4bcb20ef772f6bf2dbb415766a0debb26b845604aa8b9e2dea6.yml +openapi_spec_hash: b31ff3d5a563d071c654d47ca5564bb1 config_hash: 4b10254ea5b8e26ce632222b94a918aa diff --git a/api.md b/api.md index ef7c6cc..4c1102e 100644 --- a/api.md +++ b/api.md @@ -8,7 +8,6 @@ from brand.dev.types import ( BrandAIQueryResponse, BrandIdentifyFromTransactionResponse, BrandPrefetchResponse, - BrandRetrieveByTickerResponse, BrandRetrieveNaicsResponse, BrandRetrieveSimplifiedResponse, BrandScreenshotResponse, @@ -22,7 +21,6 @@ Methods: - client.brand.ai_query(\*\*params) -> BrandAIQueryResponse - client.brand.identify_from_transaction(\*\*params) -> BrandIdentifyFromTransactionResponse - client.brand.prefetch(\*\*params) -> BrandPrefetchResponse -- client.brand.retrieve_by_ticker(\*\*params) -> BrandRetrieveByTickerResponse - client.brand.retrieve_naics(\*\*params) -> BrandRetrieveNaicsResponse - client.brand.retrieve_simplified(\*\*params) -> BrandRetrieveSimplifiedResponse - client.brand.screenshot(\*\*params) -> BrandScreenshotResponse diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 951c66f..2cee6bb 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -14,7 +14,6 @@ brand_screenshot_params, brand_styleguide_params, brand_retrieve_naics_params, - brand_retrieve_by_ticker_params, brand_retrieve_simplified_params, brand_identify_from_transaction_params, ) @@ -35,7 +34,6 @@ from ..types.brand_screenshot_response import BrandScreenshotResponse from ..types.brand_styleguide_response import BrandStyleguideResponse from ..types.brand_retrieve_naics_response import BrandRetrieveNaicsResponse -from ..types.brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse from ..types.brand_retrieve_simplified_response import BrandRetrieveSimplifiedResponse from ..types.brand_identify_from_transaction_response import BrandIdentifyFromTransactionResponse @@ -342,55 +340,6 @@ def prefetch( cast_to=BrandPrefetchResponse, ) - def retrieve_by_ticker( - self, - *, - ticker: str, - timeout_ms: int | NotGiven = NOT_GIVEN, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BrandRetrieveByTickerResponse: - """Retrieve brand data by stock ticker (e.g. - - AAPL, TSLA, etc.) - - Args: - ticker: Stock ticker symbol to retrieve brand data for (e.g. AAPL, TSLA, etc.) - - timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer - than this value, it will be aborted with a 408 status code. Maximum allowed - value is 300000ms (5 minutes). - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return self._get( - "/brand/retrieve-by-ticker", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform( - { - "ticker": ticker, - "timeout_ms": timeout_ms, - }, - brand_retrieve_by_ticker_params.BrandRetrieveByTickerParams, - ), - ), - cast_to=BrandRetrieveByTickerResponse, - ) - def retrieve_naics( self, *, @@ -895,55 +844,6 @@ async def prefetch( cast_to=BrandPrefetchResponse, ) - async def retrieve_by_ticker( - self, - *, - ticker: str, - timeout_ms: int | NotGiven = NOT_GIVEN, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BrandRetrieveByTickerResponse: - """Retrieve brand data by stock ticker (e.g. - - AAPL, TSLA, etc.) - - Args: - ticker: Stock ticker symbol to retrieve brand data for (e.g. AAPL, TSLA, etc.) - - timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer - than this value, it will be aborted with a 408 status code. Maximum allowed - value is 300000ms (5 minutes). - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._get( - "/brand/retrieve-by-ticker", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=await async_maybe_transform( - { - "ticker": ticker, - "timeout_ms": timeout_ms, - }, - brand_retrieve_by_ticker_params.BrandRetrieveByTickerParams, - ), - ), - cast_to=BrandRetrieveByTickerResponse, - ) - async def retrieve_naics( self, *, @@ -1164,9 +1064,6 @@ def __init__(self, brand: BrandResource) -> None: self.prefetch = to_raw_response_wrapper( brand.prefetch, ) - self.retrieve_by_ticker = to_raw_response_wrapper( - brand.retrieve_by_ticker, - ) self.retrieve_naics = to_raw_response_wrapper( brand.retrieve_naics, ) @@ -1197,9 +1094,6 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.prefetch = async_to_raw_response_wrapper( brand.prefetch, ) - self.retrieve_by_ticker = async_to_raw_response_wrapper( - brand.retrieve_by_ticker, - ) self.retrieve_naics = async_to_raw_response_wrapper( brand.retrieve_naics, ) @@ -1230,9 +1124,6 @@ def __init__(self, brand: BrandResource) -> None: self.prefetch = to_streamed_response_wrapper( brand.prefetch, ) - self.retrieve_by_ticker = to_streamed_response_wrapper( - brand.retrieve_by_ticker, - ) self.retrieve_naics = to_streamed_response_wrapper( brand.retrieve_naics, ) @@ -1263,9 +1154,6 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.prefetch = async_to_streamed_response_wrapper( brand.prefetch, ) - self.retrieve_by_ticker = async_to_streamed_response_wrapper( - brand.retrieve_by_ticker, - ) self.retrieve_naics = async_to_streamed_response_wrapper( brand.retrieve_naics, ) diff --git a/src/brand/dev/types/__init__.py b/src/brand/dev/types/__init__.py index 35536b0..a4dff66 100644 --- a/src/brand/dev/types/__init__.py +++ b/src/brand/dev/types/__init__.py @@ -14,9 +14,7 @@ from .brand_styleguide_response import BrandStyleguideResponse as BrandStyleguideResponse from .brand_retrieve_naics_params import BrandRetrieveNaicsParams as BrandRetrieveNaicsParams from .brand_retrieve_naics_response import BrandRetrieveNaicsResponse as BrandRetrieveNaicsResponse -from .brand_retrieve_by_ticker_params import BrandRetrieveByTickerParams as BrandRetrieveByTickerParams from .brand_retrieve_simplified_params import BrandRetrieveSimplifiedParams as BrandRetrieveSimplifiedParams -from .brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse as BrandRetrieveByTickerResponse from .brand_retrieve_simplified_response import BrandRetrieveSimplifiedResponse as BrandRetrieveSimplifiedResponse from .brand_identify_from_transaction_params import ( BrandIdentifyFromTransactionParams as BrandIdentifyFromTransactionParams, diff --git a/src/brand/dev/types/brand_retrieve_by_ticker_params.py b/src/brand/dev/types/brand_retrieve_by_ticker_params.py deleted file mode 100644 index 05d7c79..0000000 --- a/src/brand/dev/types/brand_retrieve_by_ticker_params.py +++ /dev/null @@ -1,21 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, Annotated, TypedDict - -from .._utils import PropertyInfo - -__all__ = ["BrandRetrieveByTickerParams"] - - -class BrandRetrieveByTickerParams(TypedDict, total=False): - ticker: Required[str] - """Stock ticker symbol to retrieve brand data for (e.g. AAPL, TSLA, etc.)""" - - timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] - """Optional timeout in milliseconds for the request. - - If the request takes longer than this value, it will be aborted with a 408 - status code. Maximum allowed value is 300000ms (5 minutes). - """ diff --git a/src/brand/dev/types/brand_retrieve_by_ticker_response.py b/src/brand/dev/types/brand_retrieve_by_ticker_response.py deleted file mode 100644 index 918b67c..0000000 --- a/src/brand/dev/types/brand_retrieve_by_ticker_response.py +++ /dev/null @@ -1,193 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import List, Optional -from typing_extensions import Literal - -from .._models import BaseModel - -__all__ = [ - "BrandRetrieveByTickerResponse", - "Brand", - "BrandAddress", - "BrandBackdrop", - "BrandBackdropColor", - "BrandBackdropResolution", - "BrandColor", - "BrandLogo", - "BrandLogoColor", - "BrandLogoResolution", - "BrandSocial", - "BrandStock", -] - - -class BrandAddress(BaseModel): - city: Optional[str] = None - """City name""" - - country: Optional[str] = None - """Country name""" - - country_code: Optional[str] = None - """Country code""" - - postal_code: Optional[str] = None - """Postal or ZIP code""" - - state_code: Optional[str] = None - """State or province code""" - - state_province: Optional[str] = None - """State or province name""" - - street: Optional[str] = None - """Street address""" - - -class BrandBackdropColor(BaseModel): - hex: Optional[str] = None - """Color in hexadecimal format""" - - name: Optional[str] = None - """Name of the color""" - - -class BrandBackdropResolution(BaseModel): - aspect_ratio: Optional[float] = None - """Aspect ratio of the image (width/height)""" - - height: Optional[int] = None - """Height of the image in pixels""" - - width: Optional[int] = None - """Width of the image in pixels""" - - -class BrandBackdrop(BaseModel): - colors: Optional[List[BrandBackdropColor]] = None - """Array of colors in the backdrop image""" - - resolution: Optional[BrandBackdropResolution] = None - """Resolution of the backdrop image""" - - url: Optional[str] = None - """URL of the backdrop image""" - - -class BrandColor(BaseModel): - hex: Optional[str] = None - """Color in hexadecimal format""" - - name: Optional[str] = None - """Name of the color""" - - -class BrandLogoColor(BaseModel): - hex: Optional[str] = None - """Color in hexadecimal format""" - - name: Optional[str] = None - """Name of the color""" - - -class BrandLogoResolution(BaseModel): - aspect_ratio: Optional[float] = None - """Aspect ratio of the image (width/height)""" - - height: Optional[int] = None - """Height of the image in pixels""" - - width: Optional[int] = None - """Width of the image in pixels""" - - -class BrandLogo(BaseModel): - colors: Optional[List[BrandLogoColor]] = None - """Array of colors in the logo""" - - mode: Optional[Literal["light", "dark", "has_opaque_background"]] = None - """ - Indicates when this logo is best used: 'light' = best for light mode, 'dark' = - best for dark mode, 'has_opaque_background' = can be used for either as image - has its own background - """ - - resolution: Optional[BrandLogoResolution] = None - """Resolution of the logo image""" - - type: Optional[Literal["icon", "logo"]] = None - """Type of the logo based on resolution (e.g., 'icon', 'logo')""" - - url: Optional[str] = None - """CDN hosted url of the logo (ready for display)""" - - -class BrandSocial(BaseModel): - type: Optional[str] = None - """Type of social media, e.g., 'facebook', 'twitter'""" - - url: Optional[str] = None - """URL of the social media page""" - - -class BrandStock(BaseModel): - exchange: Optional[str] = None - """Stock exchange name""" - - ticker: Optional[str] = None - """Stock ticker symbol""" - - -class Brand(BaseModel): - address: Optional[BrandAddress] = None - """Physical address of the brand""" - - backdrops: Optional[List[BrandBackdrop]] = None - """An array of backdrop images for the brand""" - - colors: Optional[List[BrandColor]] = None - """An array of brand colors""" - - description: Optional[str] = None - """A brief description of the brand""" - - domain: Optional[str] = None - """The domain name of the brand""" - - email: Optional[str] = None - """Company email address""" - - is_nsfw: Optional[bool] = None - """Indicates whether the brand content is not safe for work (NSFW)""" - - logos: Optional[List[BrandLogo]] = None - """An array of logos associated with the brand""" - - phone: Optional[str] = None - """Company phone number""" - - slogan: Optional[str] = None - """The brand's slogan""" - - socials: Optional[List[BrandSocial]] = None - """An array of social media links for the brand""" - - stock: Optional[BrandStock] = None - """ - Stock market information for this brand (will be null if not a publicly traded - company) - """ - - title: Optional[str] = None - """The title or name of the brand""" - - -class BrandRetrieveByTickerResponse(BaseModel): - brand: Optional[Brand] = None - """Detailed brand information""" - - code: Optional[int] = None - """HTTP status code""" - - status: Optional[str] = None - """Status of the response, e.g., 'ok'""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 117a8e0..d1dfb61 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -16,7 +16,6 @@ BrandScreenshotResponse, BrandStyleguideResponse, BrandRetrieveNaicsResponse, - BrandRetrieveByTickerResponse, BrandRetrieveSimplifiedResponse, BrandIdentifyFromTransactionResponse, ) @@ -239,49 +238,6 @@ def test_streaming_response_prefetch(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_retrieve_by_ticker(self, client: BrandDev) -> None: - brand = client.brand.retrieve_by_ticker( - ticker="ticker", - ) - assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_method_retrieve_by_ticker_with_all_params(self, client: BrandDev) -> None: - brand = client.brand.retrieve_by_ticker( - ticker="ticker", - timeout_ms=1, - ) - assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_raw_response_retrieve_by_ticker(self, client: BrandDev) -> None: - response = client.brand.with_raw_response.retrieve_by_ticker( - ticker="ticker", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - brand = response.parse() - assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - def test_streaming_response_retrieve_by_ticker(self, client: BrandDev) -> None: - with client.brand.with_streaming_response.retrieve_by_ticker( - ticker="ticker", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - brand = response.parse() - assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - - assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve_naics(self, client: BrandDev) -> None: @@ -672,49 +628,6 @@ async def test_streaming_response_prefetch(self, async_client: AsyncBrandDev) -> assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: - brand = await async_client.brand.retrieve_by_ticker( - ticker="ticker", - ) - assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_method_retrieve_by_ticker_with_all_params(self, async_client: AsyncBrandDev) -> None: - brand = await async_client.brand.retrieve_by_ticker( - ticker="ticker", - timeout_ms=1, - ) - assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_raw_response_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: - response = await async_client.brand.with_raw_response.retrieve_by_ticker( - ticker="ticker", - ) - - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - brand = await response.parse() - assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - - @pytest.mark.skip(reason="Prism tests are disabled") - @parametrize - async def test_streaming_response_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: - async with async_client.brand.with_streaming_response.retrieve_by_ticker( - ticker="ticker", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - - brand = await response.parse() - assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) - - assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve_naics(self, async_client: AsyncBrandDev) -> None: From bb261e2bac2317055a2179e02001f22eea589c27 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 03:13:13 +0000 Subject: [PATCH 082/176] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index b6d4a92..39a36f8 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-ab54646eee0ba4bcb20ef772f6bf2dbb415766a0debb26b845604aa8b9e2dea6.yml openapi_spec_hash: b31ff3d5a563d071c654d47ca5564bb1 -config_hash: 4b10254ea5b8e26ce632222b94a918aa +config_hash: a7a306a1c85eef178e7989e0ce5979c6 From b57c555f92ac4ad7f514e39f7a04ab07d5b2c71b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 14:15:23 +0000 Subject: [PATCH 083/176] feat(api): api update --- .stats.yml | 4 +- ...rand_identify_from_transaction_response.py | 264 ++++++++++++++++++ .../dev/types/brand_retrieve_response.py | 264 ++++++++++++++++++ 3 files changed, 530 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 39a36f8..56c8a72 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-ab54646eee0ba4bcb20ef772f6bf2dbb415766a0debb26b845604aa8b9e2dea6.yml -openapi_spec_hash: b31ff3d5a563d071c654d47ca5564bb1 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-0758a435064bd016fe211989cb7f6a3441460664aead5870777f8a662aa69794.yml +openapi_spec_hash: d646572eb126d1d18327240959fc96af config_hash: a7a306a1c85eef178e7989e0ce5979c6 diff --git a/src/brand/dev/types/brand_identify_from_transaction_response.py b/src/brand/dev/types/brand_identify_from_transaction_response.py index a4ca52c..d59f558 100644 --- a/src/brand/dev/types/brand_identify_from_transaction_response.py +++ b/src/brand/dev/types/brand_identify_from_transaction_response.py @@ -13,6 +13,8 @@ "BrandBackdropColor", "BrandBackdropResolution", "BrandColor", + "BrandIndustries", + "BrandIndustriesEic", "BrandLogo", "BrandLogoColor", "BrandLogoResolution", @@ -82,6 +84,265 @@ class BrandColor(BaseModel): """Name of the color""" +class BrandIndustriesEic(BaseModel): + industry: Literal[ + "Aerospace & Defense", + "Technology", + "Finance", + "Healthcare", + "Retail & E-commerce", + "Entertainment", + "Education", + "Government & Nonprofit", + "Industrial & Energy", + "Automotive & Transportation", + "Lifestyle & Leisure", + "Luxury & Fashion", + "News & Media", + "Sports", + "Real Estate & PropTech", + "Legal & Compliance", + "Telecommunications", + "Agriculture & Food", + "Professional Services & Agencies", + "Chemicals & Materials", + "Logistics & Supply Chain", + "Hospitality & Tourism", + "Construction & Built Environment", + "Consumer Packaged Goods (CPG)", + ] + """Industry classification enum""" + + subindustry: Literal[ + "Defense Systems & Military Hardware", + "Aerospace Manufacturing", + "Avionics & Navigation Technology", + "Subsea & Naval Defense Systems", + "Space & Satellite Technology", + "Defense IT & Systems Integration", + "Software (B2B)", + "Software (B2C)", + "Cloud Infrastructure & DevOps", + "Cybersecurity", + "Artificial Intelligence & Machine Learning", + "Data Infrastructure & Analytics", + "Hardware & Semiconductors", + "Fintech Infrastructure", + "eCommerce & Marketplace Platforms", + "Developer Tools & APIs", + "Web3 & Blockchain", + "XR & Spatial Computing", + "Banking & Lending", + "Investment Management & WealthTech", + "Insurance & InsurTech", + "Payments & Money Movement", + "Accounting, Tax & Financial Planning Tools", + "Capital Markets & Trading Platforms", + "Financial Infrastructure & APIs", + "Credit Scoring & Risk Management", + "Cryptocurrency & Digital Assets", + "BNPL & Alternative Financing", + "Healthcare Providers & Services", + "Pharmaceuticals & Drug Development", + "Medical Devices & Diagnostics", + "Biotechnology & Genomics", + "Digital Health & Telemedicine", + "Health Insurance & Benefits Tech", + "Clinical Trials & Research Platforms", + "Mental Health & Wellness", + "Healthcare IT & EHR Systems", + "Consumer Health & Wellness Products", + "Online Marketplaces", + "Direct-to-Consumer (DTC) Brands", + "Retail Tech & Point-of-Sale Systems", + "Omnichannel & In-Store Retail", + "E-commerce Enablement & Infrastructure", + "Subscription & Membership Commerce", + "Social Commerce & Influencer Platforms", + "Fashion & Apparel Retail", + "Food, Beverage & Grocery E-commerce", + "Streaming Platforms (Video, Music, Audio)", + "Gaming & Interactive Entertainment", + "Creator Economy & Influencer Platforms", + "Advertising, Adtech & Media Buying", + "Film, TV & Production Studios", + "Events, Venues & Live Entertainment", + "Virtual Worlds & Metaverse Experiences", + "K-12 Education Platforms & Tools", + "Higher Education & University Tech", + "Online Learning & MOOCs", + "Test Prep & Certification", + "Corporate Training & Upskilling", + "Tutoring & Supplemental Learning", + "Education Management Systems (LMS/SIS)", + "Language Learning", + "Creator-Led & Cohort-Based Courses", + "Special Education & Accessibility Tools", + "Government Technology & Digital Services", + "Civic Engagement & Policy Platforms", + "International Development & Humanitarian Aid", + "Philanthropy & Grantmaking", + "Nonprofit Operations & Fundraising Tools", + "Public Health & Social Services", + "Education & Youth Development Programs", + "Environmental & Climate Action Organizations", + "Legal Aid & Social Justice Advocacy", + "Municipal & Infrastructure Services", + "Manufacturing & Industrial Automation", + "Energy Production (Oil, Gas, Nuclear)", + "Renewable Energy & Cleantech", + "Utilities & Grid Infrastructure", + "Industrial IoT & Monitoring Systems", + "Construction & Heavy Equipment", + "Mining & Natural Resources", + "Environmental Engineering & Sustainability", + "Energy Storage & Battery Technology", + "Automotive OEMs & Vehicle Manufacturing", + "Electric Vehicles (EVs) & Charging Infrastructure", + "Mobility-as-a-Service (MaaS)", + "Fleet Management", + "Public Transit & Urban Mobility", + "Autonomous Vehicles & ADAS", + "Aftermarket Parts & Services", + "Telematics & Vehicle Connectivity", + "Aviation & Aerospace Transport", + "Maritime Shipping", + "Fitness & Wellness", + "Beauty & Personal Care", + "Home & Living", + "Dating & Relationships", + "Hobbies, Crafts & DIY", + "Outdoor & Recreational Gear", + "Events, Experiences & Ticketing Platforms", + "Designer & Luxury Apparel", + "Accessories, Jewelry & Watches", + "Footwear & Leather Goods", + "Beauty, Fragrance & Skincare", + "Fashion Marketplaces & Retail Platforms", + "Sustainable & Ethical Fashion", + "Resale, Vintage & Circular Fashion", + "Fashion Tech & Virtual Try-Ons", + "Streetwear & Emerging Luxury", + "Couture & Made-to-Measure", + "News Publishing & Journalism", + "Digital Media & Content Platforms", + "Broadcasting (TV & Radio)", + "Podcasting & Audio Media", + "News Aggregators & Curation Tools", + "Independent & Creator-Led Media", + "Newsletters & Substack-Style Platforms", + "Political & Investigative Media", + "Trade & Niche Publications", + "Media Monitoring & Analytics", + "Professional Teams & Leagues", + "Sports Media & Broadcasting", + "Sports Betting & Fantasy Sports", + "Fitness & Athletic Training Platforms", + "Sportswear & Equipment", + "Esports & Competitive Gaming", + "Sports Venues & Event Management", + "Athlete Management & Talent Agencies", + "Sports Tech & Performance Analytics", + "Youth, Amateur & Collegiate Sports", + "Real Estate Marketplaces", + "Property Management Software", + "Rental Platforms", + "Mortgage & Lending Tech", + "Real Estate Investment Platforms", + "Law Firms & Legal Services", + "Legal Tech & Automation", + "Regulatory Compliance", + "E-Discovery & Litigation Tools", + "Contract Management", + "Governance, Risk & Compliance (GRC)", + "IP & Trademark Management", + "Legal Research & Intelligence", + "Compliance Training & Certification", + "Whistleblower & Ethics Reporting", + "Mobile & Wireless Networks (3G/4G/5G)", + "Broadband & Fiber Internet", + "Satellite & Space-Based Communications", + "Network Equipment & Infrastructure", + "Telecom Billing & OSS/BSS Systems", + "VoIP & Unified Communications", + "Internet Service Providers (ISPs)", + "Edge Computing & Network Virtualization", + "IoT Connectivity Platforms", + "Precision Agriculture & AgTech", + "Crop & Livestock Production", + "Food & Beverage Manufacturing & Processing", + "Food Distribution", + "Restaurants & Food Service", + "Agricultural Inputs & Equipment", + "Sustainable & Regenerative Agriculture", + "Seafood & Aquaculture", + "Management Consulting", + "Marketing & Advertising Agencies", + "Design, Branding & Creative Studios", + "IT Services & Managed Services", + "Staffing, Recruiting & Talent", + "Accounting & Tax Firms", + "Public Relations & Communications", + "Business Process Outsourcing (BPO)", + "Professional Training & Coaching", + "Specialty Chemicals", + "Commodity & Petrochemicals", + "Polymers, Plastics & Rubber", + "Coatings, Adhesives & Sealants", + "Industrial Gases", + "Advanced Materials & Composites", + "Battery Materials & Energy Storage", + "Electronic Materials & Semiconductor Chemicals", + "Agrochemicals & Fertilizers", + "Freight & Transportation Tech", + "Last-Mile Delivery", + "Warehouse Automation", + "Supply Chain Visibility Platforms", + "Logistics Marketplaces", + "Shipping & Freight Forwarding", + "Cold Chain Logistics", + "Reverse Logistics & Returns", + "Cross-Border Trade Tech", + "Transportation Management Systems (TMS)", + "Hotels & Accommodation", + "Vacation Rentals & Short-Term Stays", + "Restaurant Tech & Management", + "Travel Booking Platforms", + "Tourism Experiences & Activities", + "Cruise Lines & Marine Tourism", + "Hospitality Management Systems", + "Event & Venue Management", + "Corporate Travel Management", + "Travel Insurance & Protection", + "Construction Management Software", + "BIM/CAD & Design Tools", + "Construction Marketplaces", + "Equipment Rental & Management", + "Building Materials & Procurement", + "Construction Workforce Management", + "Project Estimation & Bidding", + "Modular & Prefab Construction", + "Construction Safety & Compliance", + "Smart Building Technology", + "Food & Beverage CPG", + "Home & Personal Care CPG", + "CPG Analytics & Insights", + "Direct-to-Consumer CPG Brands", + "CPG Supply Chain & Distribution", + "Private Label Manufacturing", + "CPG Retail Intelligence", + "Sustainable CPG & Packaging", + "Beauty & Cosmetics CPG", + "Health & Wellness CPG", + ] + """Subindustry classification enum""" + + +class BrandIndustries(BaseModel): + eic: Optional[List[BrandIndustriesEic]] = None + """Easy Industry Classification - array of industry and subindustry pairs""" + + class BrandLogoColor(BaseModel): hex: Optional[str] = None """Color in hexadecimal format""" @@ -157,6 +418,9 @@ class Brand(BaseModel): email: Optional[str] = None """Company email address""" + industries: Optional[BrandIndustries] = None + """Industry classification information for the brand""" + is_nsfw: Optional[bool] = None """Indicates whether the brand content is not safe for work (NSFW)""" diff --git a/src/brand/dev/types/brand_retrieve_response.py b/src/brand/dev/types/brand_retrieve_response.py index a684da9..6f927d4 100644 --- a/src/brand/dev/types/brand_retrieve_response.py +++ b/src/brand/dev/types/brand_retrieve_response.py @@ -13,6 +13,8 @@ "BrandBackdropColor", "BrandBackdropResolution", "BrandColor", + "BrandIndustries", + "BrandIndustriesEic", "BrandLogo", "BrandLogoColor", "BrandLogoResolution", @@ -82,6 +84,265 @@ class BrandColor(BaseModel): """Name of the color""" +class BrandIndustriesEic(BaseModel): + industry: Literal[ + "Aerospace & Defense", + "Technology", + "Finance", + "Healthcare", + "Retail & E-commerce", + "Entertainment", + "Education", + "Government & Nonprofit", + "Industrial & Energy", + "Automotive & Transportation", + "Lifestyle & Leisure", + "Luxury & Fashion", + "News & Media", + "Sports", + "Real Estate & PropTech", + "Legal & Compliance", + "Telecommunications", + "Agriculture & Food", + "Professional Services & Agencies", + "Chemicals & Materials", + "Logistics & Supply Chain", + "Hospitality & Tourism", + "Construction & Built Environment", + "Consumer Packaged Goods (CPG)", + ] + """Industry classification enum""" + + subindustry: Literal[ + "Defense Systems & Military Hardware", + "Aerospace Manufacturing", + "Avionics & Navigation Technology", + "Subsea & Naval Defense Systems", + "Space & Satellite Technology", + "Defense IT & Systems Integration", + "Software (B2B)", + "Software (B2C)", + "Cloud Infrastructure & DevOps", + "Cybersecurity", + "Artificial Intelligence & Machine Learning", + "Data Infrastructure & Analytics", + "Hardware & Semiconductors", + "Fintech Infrastructure", + "eCommerce & Marketplace Platforms", + "Developer Tools & APIs", + "Web3 & Blockchain", + "XR & Spatial Computing", + "Banking & Lending", + "Investment Management & WealthTech", + "Insurance & InsurTech", + "Payments & Money Movement", + "Accounting, Tax & Financial Planning Tools", + "Capital Markets & Trading Platforms", + "Financial Infrastructure & APIs", + "Credit Scoring & Risk Management", + "Cryptocurrency & Digital Assets", + "BNPL & Alternative Financing", + "Healthcare Providers & Services", + "Pharmaceuticals & Drug Development", + "Medical Devices & Diagnostics", + "Biotechnology & Genomics", + "Digital Health & Telemedicine", + "Health Insurance & Benefits Tech", + "Clinical Trials & Research Platforms", + "Mental Health & Wellness", + "Healthcare IT & EHR Systems", + "Consumer Health & Wellness Products", + "Online Marketplaces", + "Direct-to-Consumer (DTC) Brands", + "Retail Tech & Point-of-Sale Systems", + "Omnichannel & In-Store Retail", + "E-commerce Enablement & Infrastructure", + "Subscription & Membership Commerce", + "Social Commerce & Influencer Platforms", + "Fashion & Apparel Retail", + "Food, Beverage & Grocery E-commerce", + "Streaming Platforms (Video, Music, Audio)", + "Gaming & Interactive Entertainment", + "Creator Economy & Influencer Platforms", + "Advertising, Adtech & Media Buying", + "Film, TV & Production Studios", + "Events, Venues & Live Entertainment", + "Virtual Worlds & Metaverse Experiences", + "K-12 Education Platforms & Tools", + "Higher Education & University Tech", + "Online Learning & MOOCs", + "Test Prep & Certification", + "Corporate Training & Upskilling", + "Tutoring & Supplemental Learning", + "Education Management Systems (LMS/SIS)", + "Language Learning", + "Creator-Led & Cohort-Based Courses", + "Special Education & Accessibility Tools", + "Government Technology & Digital Services", + "Civic Engagement & Policy Platforms", + "International Development & Humanitarian Aid", + "Philanthropy & Grantmaking", + "Nonprofit Operations & Fundraising Tools", + "Public Health & Social Services", + "Education & Youth Development Programs", + "Environmental & Climate Action Organizations", + "Legal Aid & Social Justice Advocacy", + "Municipal & Infrastructure Services", + "Manufacturing & Industrial Automation", + "Energy Production (Oil, Gas, Nuclear)", + "Renewable Energy & Cleantech", + "Utilities & Grid Infrastructure", + "Industrial IoT & Monitoring Systems", + "Construction & Heavy Equipment", + "Mining & Natural Resources", + "Environmental Engineering & Sustainability", + "Energy Storage & Battery Technology", + "Automotive OEMs & Vehicle Manufacturing", + "Electric Vehicles (EVs) & Charging Infrastructure", + "Mobility-as-a-Service (MaaS)", + "Fleet Management", + "Public Transit & Urban Mobility", + "Autonomous Vehicles & ADAS", + "Aftermarket Parts & Services", + "Telematics & Vehicle Connectivity", + "Aviation & Aerospace Transport", + "Maritime Shipping", + "Fitness & Wellness", + "Beauty & Personal Care", + "Home & Living", + "Dating & Relationships", + "Hobbies, Crafts & DIY", + "Outdoor & Recreational Gear", + "Events, Experiences & Ticketing Platforms", + "Designer & Luxury Apparel", + "Accessories, Jewelry & Watches", + "Footwear & Leather Goods", + "Beauty, Fragrance & Skincare", + "Fashion Marketplaces & Retail Platforms", + "Sustainable & Ethical Fashion", + "Resale, Vintage & Circular Fashion", + "Fashion Tech & Virtual Try-Ons", + "Streetwear & Emerging Luxury", + "Couture & Made-to-Measure", + "News Publishing & Journalism", + "Digital Media & Content Platforms", + "Broadcasting (TV & Radio)", + "Podcasting & Audio Media", + "News Aggregators & Curation Tools", + "Independent & Creator-Led Media", + "Newsletters & Substack-Style Platforms", + "Political & Investigative Media", + "Trade & Niche Publications", + "Media Monitoring & Analytics", + "Professional Teams & Leagues", + "Sports Media & Broadcasting", + "Sports Betting & Fantasy Sports", + "Fitness & Athletic Training Platforms", + "Sportswear & Equipment", + "Esports & Competitive Gaming", + "Sports Venues & Event Management", + "Athlete Management & Talent Agencies", + "Sports Tech & Performance Analytics", + "Youth, Amateur & Collegiate Sports", + "Real Estate Marketplaces", + "Property Management Software", + "Rental Platforms", + "Mortgage & Lending Tech", + "Real Estate Investment Platforms", + "Law Firms & Legal Services", + "Legal Tech & Automation", + "Regulatory Compliance", + "E-Discovery & Litigation Tools", + "Contract Management", + "Governance, Risk & Compliance (GRC)", + "IP & Trademark Management", + "Legal Research & Intelligence", + "Compliance Training & Certification", + "Whistleblower & Ethics Reporting", + "Mobile & Wireless Networks (3G/4G/5G)", + "Broadband & Fiber Internet", + "Satellite & Space-Based Communications", + "Network Equipment & Infrastructure", + "Telecom Billing & OSS/BSS Systems", + "VoIP & Unified Communications", + "Internet Service Providers (ISPs)", + "Edge Computing & Network Virtualization", + "IoT Connectivity Platforms", + "Precision Agriculture & AgTech", + "Crop & Livestock Production", + "Food & Beverage Manufacturing & Processing", + "Food Distribution", + "Restaurants & Food Service", + "Agricultural Inputs & Equipment", + "Sustainable & Regenerative Agriculture", + "Seafood & Aquaculture", + "Management Consulting", + "Marketing & Advertising Agencies", + "Design, Branding & Creative Studios", + "IT Services & Managed Services", + "Staffing, Recruiting & Talent", + "Accounting & Tax Firms", + "Public Relations & Communications", + "Business Process Outsourcing (BPO)", + "Professional Training & Coaching", + "Specialty Chemicals", + "Commodity & Petrochemicals", + "Polymers, Plastics & Rubber", + "Coatings, Adhesives & Sealants", + "Industrial Gases", + "Advanced Materials & Composites", + "Battery Materials & Energy Storage", + "Electronic Materials & Semiconductor Chemicals", + "Agrochemicals & Fertilizers", + "Freight & Transportation Tech", + "Last-Mile Delivery", + "Warehouse Automation", + "Supply Chain Visibility Platforms", + "Logistics Marketplaces", + "Shipping & Freight Forwarding", + "Cold Chain Logistics", + "Reverse Logistics & Returns", + "Cross-Border Trade Tech", + "Transportation Management Systems (TMS)", + "Hotels & Accommodation", + "Vacation Rentals & Short-Term Stays", + "Restaurant Tech & Management", + "Travel Booking Platforms", + "Tourism Experiences & Activities", + "Cruise Lines & Marine Tourism", + "Hospitality Management Systems", + "Event & Venue Management", + "Corporate Travel Management", + "Travel Insurance & Protection", + "Construction Management Software", + "BIM/CAD & Design Tools", + "Construction Marketplaces", + "Equipment Rental & Management", + "Building Materials & Procurement", + "Construction Workforce Management", + "Project Estimation & Bidding", + "Modular & Prefab Construction", + "Construction Safety & Compliance", + "Smart Building Technology", + "Food & Beverage CPG", + "Home & Personal Care CPG", + "CPG Analytics & Insights", + "Direct-to-Consumer CPG Brands", + "CPG Supply Chain & Distribution", + "Private Label Manufacturing", + "CPG Retail Intelligence", + "Sustainable CPG & Packaging", + "Beauty & Cosmetics CPG", + "Health & Wellness CPG", + ] + """Subindustry classification enum""" + + +class BrandIndustries(BaseModel): + eic: Optional[List[BrandIndustriesEic]] = None + """Easy Industry Classification - array of industry and subindustry pairs""" + + class BrandLogoColor(BaseModel): hex: Optional[str] = None """Color in hexadecimal format""" @@ -157,6 +418,9 @@ class Brand(BaseModel): email: Optional[str] = None """Company email address""" + industries: Optional[BrandIndustries] = None + """Industry classification information for the brand""" + is_nsfw: Optional[bool] = None """Indicates whether the brand content is not safe for work (NSFW)""" From e07bf1129687962c65dee58617948cd7aa7f0751 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 14:48:05 +0000 Subject: [PATCH 084/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index caf1487..de0960a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.11.0" + ".": "1.12.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1d0a248..1e84b49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.11.0" +version = "1.12.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 641ac2a..d9b854c 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.11.0" # x-release-please-version +__version__ = "1.12.0" # x-release-please-version From fce2dad8fb14821dac1514b78f3dfba25fa6da50 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 22 Aug 2025 02:46:15 +0000 Subject: [PATCH 085/176] chore: update github action --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd39ae7..94c6918 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: run: ./scripts/lint build: - if: github.repository == 'stainless-sdks/brand.dev-python' && (github.event_name == 'push' || github.event.pull_request.head.repo.fork) + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork timeout-minutes: 10 name: build permissions: @@ -61,12 +61,14 @@ jobs: run: rye build - name: Get GitHub OIDC Token + if: github.repository == 'stainless-sdks/brand.dev-python' id: github-oidc uses: actions/github-script@v6 with: script: core.setOutput('github_token', await core.getIDToken()); - name: Upload tarball + if: github.repository == 'stainless-sdks/brand.dev-python' env: URL: https://pkg.stainless.com/s AUTH: ${{ steps.github-oidc.outputs.github_token }} From 6087f82426032f63bf923f3506664489b5f9796b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 22 Aug 2025 17:15:35 +0000 Subject: [PATCH 086/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index de0960a..ffb929a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.12.0" + ".": "1.12.1" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1e84b49..ed9f622 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.12.0" +version = "1.12.1" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index d9b854c..c3c01f4 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.12.0" # x-release-please-version +__version__ = "1.12.1" # x-release-please-version From 98bfa2856e2178b9a6c7ece03ae24224e2b5bde1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 26 Aug 2025 02:28:44 +0000 Subject: [PATCH 087/176] chore(internal): change ci workflow machines --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94c6918..1f0f03d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: permissions: contents: read id-token: write - runs-on: depot-ubuntu-24.04 + runs-on: ${{ github.repository == 'stainless-sdks/brand.dev-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v4 From f697874e6fa718dadbb1c8870d7b2a8c479e6431 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 27 Aug 2025 02:49:04 +0000 Subject: [PATCH 088/176] fix: avoid newer type syntax --- src/brand/dev/_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/brand/dev/_models.py b/src/brand/dev/_models.py index b8387ce..92f7c10 100644 --- a/src/brand/dev/_models.py +++ b/src/brand/dev/_models.py @@ -304,7 +304,7 @@ def model_dump( exclude_none=exclude_none, ) - return cast(dict[str, Any], json_safe(dumped)) if mode == "json" else dumped + return cast("dict[str, Any]", json_safe(dumped)) if mode == "json" else dumped @override def model_dump_json( From 9547feb333582950c7da1c9cecc9e7788d1c096e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 27 Aug 2025 03:08:23 +0000 Subject: [PATCH 089/176] chore(internal): update pyright exclude list --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index ed9f622..260486e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,6 +148,7 @@ exclude = [ "_dev", ".venv", ".nox", + ".git", ] reportImplicitOverride = true From e8b1b8d2c19ac19ff9fe3062150ec72a01db74b7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 30 Aug 2025 02:24:45 +0000 Subject: [PATCH 090/176] chore(internal): add Sequence related utils --- src/brand/dev/_types.py | 36 +++++++++++++++++++++++++++++++- src/brand/dev/_utils/__init__.py | 1 + src/brand/dev/_utils/_typing.py | 5 +++++ tests/utils.py | 10 ++++++++- 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/brand/dev/_types.py b/src/brand/dev/_types.py index 382635b..1077e99 100644 --- a/src/brand/dev/_types.py +++ b/src/brand/dev/_types.py @@ -13,10 +13,21 @@ Mapping, TypeVar, Callable, + Iterator, Optional, Sequence, ) -from typing_extensions import Set, Literal, Protocol, TypeAlias, TypedDict, override, runtime_checkable +from typing_extensions import ( + Set, + Literal, + Protocol, + TypeAlias, + TypedDict, + SupportsIndex, + overload, + override, + runtime_checkable, +) import httpx import pydantic @@ -217,3 +228,26 @@ class _GenericAlias(Protocol): class HttpxSendArgs(TypedDict, total=False): auth: httpx.Auth follow_redirects: bool + + +_T_co = TypeVar("_T_co", covariant=True) + + +if TYPE_CHECKING: + # This works because str.__contains__ does not accept object (either in typeshed or at runtime) + # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + class SequenceNotStr(Protocol[_T_co]): + @overload + def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... + @overload + def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... + def __contains__(self, value: object, /) -> bool: ... + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[_T_co]: ... + def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... + def count(self, value: Any, /) -> int: ... + def __reversed__(self) -> Iterator[_T_co]: ... +else: + # just point this to a normal `Sequence` at runtime to avoid having to special case + # deserializing our custom sequence type + SequenceNotStr = Sequence diff --git a/src/brand/dev/_utils/__init__.py b/src/brand/dev/_utils/__init__.py index d4fda26..ca547ce 100644 --- a/src/brand/dev/_utils/__init__.py +++ b/src/brand/dev/_utils/__init__.py @@ -38,6 +38,7 @@ extract_type_arg as extract_type_arg, is_iterable_type as is_iterable_type, is_required_type as is_required_type, + is_sequence_type as is_sequence_type, is_annotated_type as is_annotated_type, is_type_alias_type as is_type_alias_type, strip_annotated_type as strip_annotated_type, diff --git a/src/brand/dev/_utils/_typing.py b/src/brand/dev/_utils/_typing.py index 1bac954..845cd6b 100644 --- a/src/brand/dev/_utils/_typing.py +++ b/src/brand/dev/_utils/_typing.py @@ -26,6 +26,11 @@ def is_list_type(typ: type) -> bool: return (get_origin(typ) or typ) == list +def is_sequence_type(typ: type) -> bool: + origin = get_origin(typ) or typ + return origin == typing_extensions.Sequence or origin == typing.Sequence or origin == _c_abc.Sequence + + def is_iterable_type(typ: type) -> bool: """If the given type is `typing.Iterable[T]`""" origin = get_origin(typ) or typ diff --git a/tests/utils.py b/tests/utils.py index 560db72..bccf01c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,7 +4,7 @@ import inspect import traceback import contextlib -from typing import Any, TypeVar, Iterator, cast +from typing import Any, TypeVar, Iterator, Sequence, cast from datetime import date, datetime from typing_extensions import Literal, get_args, get_origin, assert_type @@ -15,6 +15,7 @@ is_list_type, is_union_type, extract_type_arg, + is_sequence_type, is_annotated_type, is_type_alias_type, ) @@ -71,6 +72,13 @@ def assert_matches_type( if is_list_type(type_): return _assert_list_type(type_, value) + if is_sequence_type(type_): + assert isinstance(value, Sequence) + inner_type = get_args(type_)[0] + for entry in value: # type: ignore + assert_type(inner_type, entry) # type: ignore + return + if origin == str: assert isinstance(value, str) elif origin == int: From 8eb9079799d730c7c2a3170628c37228ded8a457 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 02:08:23 +0000 Subject: [PATCH 091/176] feat(types): replace List[str] with SequenceNotStr in params --- src/brand/dev/_utils/_transform.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/brand/dev/_utils/_transform.py b/src/brand/dev/_utils/_transform.py index b0cc20a..f0bcefd 100644 --- a/src/brand/dev/_utils/_transform.py +++ b/src/brand/dev/_utils/_transform.py @@ -16,6 +16,7 @@ lru_cache, is_mapping, is_iterable, + is_sequence, ) from .._files import is_base64_file_input from ._typing import ( @@ -24,6 +25,7 @@ extract_type_arg, is_iterable_type, is_required_type, + is_sequence_type, is_annotated_type, strip_annotated_type, ) @@ -184,6 +186,8 @@ def _transform_recursive( (is_list_type(stripped_type) and is_list(data)) # Iterable[T] or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) ): # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually # intended as an iterable, so we don't transform it. @@ -346,6 +350,8 @@ async def _async_transform_recursive( (is_list_type(stripped_type) and is_list(data)) # Iterable[T] or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) ): # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually # intended as an iterable, so we don't transform it. From ed71281feba797036336249d89aac6a18c057ce2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 02:26:21 +0000 Subject: [PATCH 092/176] feat: improve future compat with pydantic v3 --- src/brand/dev/_base_client.py | 6 +- src/brand/dev/_compat.py | 96 ++++++++--------- src/brand/dev/_models.py | 80 +++++++------- src/brand/dev/_utils/__init__.py | 10 +- src/brand/dev/_utils/_compat.py | 45 ++++++++ src/brand/dev/_utils/_datetime_parse.py | 136 ++++++++++++++++++++++++ src/brand/dev/_utils/_transform.py | 6 +- src/brand/dev/_utils/_typing.py | 2 +- src/brand/dev/_utils/_utils.py | 1 - tests/test_models.py | 48 ++++----- tests/test_transform.py | 16 +-- tests/test_utils/test_datetime_parse.py | 110 +++++++++++++++++++ tests/utils.py | 8 +- 13 files changed, 432 insertions(+), 132 deletions(-) create mode 100644 src/brand/dev/_utils/_compat.py create mode 100644 src/brand/dev/_utils/_datetime_parse.py create mode 100644 tests/test_utils/test_datetime_parse.py diff --git a/src/brand/dev/_base_client.py b/src/brand/dev/_base_client.py index 5687adf..5428ea1 100644 --- a/src/brand/dev/_base_client.py +++ b/src/brand/dev/_base_client.py @@ -59,7 +59,7 @@ ModelBuilderProtocol, ) from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping -from ._compat import PYDANTIC_V2, model_copy, model_dump +from ._compat import PYDANTIC_V1, model_copy, model_dump from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type from ._response import ( APIResponse, @@ -232,7 +232,7 @@ def _set_private_attributes( model: Type[_T], options: FinalRequestOptions, ) -> None: - if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: self.__pydantic_private__ = {} self._model = model @@ -320,7 +320,7 @@ def _set_private_attributes( client: AsyncAPIClient, options: FinalRequestOptions, ) -> None: - if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: self.__pydantic_private__ = {} self._model = model diff --git a/src/brand/dev/_compat.py b/src/brand/dev/_compat.py index 92d9ee6..bdef67f 100644 --- a/src/brand/dev/_compat.py +++ b/src/brand/dev/_compat.py @@ -12,14 +12,13 @@ _T = TypeVar("_T") _ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel) -# --------------- Pydantic v2 compatibility --------------- +# --------------- Pydantic v2, v3 compatibility --------------- # Pyright incorrectly reports some of our functions as overriding a method when they don't # pyright: reportIncompatibleMethodOverride=false -PYDANTIC_V2 = pydantic.VERSION.startswith("2.") +PYDANTIC_V1 = pydantic.VERSION.startswith("1.") -# v1 re-exports if TYPE_CHECKING: def parse_date(value: date | StrBytesIntFloat) -> date: # noqa: ARG001 @@ -44,90 +43,92 @@ def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001 ... else: - if PYDANTIC_V2: - from pydantic.v1.typing import ( + # v1 re-exports + if PYDANTIC_V1: + from pydantic.typing import ( get_args as get_args, is_union as is_union, get_origin as get_origin, is_typeddict as is_typeddict, is_literal_type as is_literal_type, ) - from pydantic.v1.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime else: - from pydantic.typing import ( + from ._utils import ( get_args as get_args, is_union as is_union, get_origin as get_origin, + parse_date as parse_date, is_typeddict as is_typeddict, + parse_datetime as parse_datetime, is_literal_type as is_literal_type, ) - from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime # refactored config if TYPE_CHECKING: from pydantic import ConfigDict as ConfigDict else: - if PYDANTIC_V2: - from pydantic import ConfigDict - else: + if PYDANTIC_V1: # TODO: provide an error message here? ConfigDict = None + else: + from pydantic import ConfigDict as ConfigDict # renamed methods / properties def parse_obj(model: type[_ModelT], value: object) -> _ModelT: - if PYDANTIC_V2: - return model.model_validate(value) - else: + if PYDANTIC_V1: return cast(_ModelT, model.parse_obj(value)) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + else: + return model.model_validate(value) def field_is_required(field: FieldInfo) -> bool: - if PYDANTIC_V2: - return field.is_required() - return field.required # type: ignore + if PYDANTIC_V1: + return field.required # type: ignore + return field.is_required() def field_get_default(field: FieldInfo) -> Any: value = field.get_default() - if PYDANTIC_V2: - from pydantic_core import PydanticUndefined - - if value == PydanticUndefined: - return None + if PYDANTIC_V1: return value + from pydantic_core import PydanticUndefined + + if value == PydanticUndefined: + return None return value def field_outer_type(field: FieldInfo) -> Any: - if PYDANTIC_V2: - return field.annotation - return field.outer_type_ # type: ignore + if PYDANTIC_V1: + return field.outer_type_ # type: ignore + return field.annotation def get_model_config(model: type[pydantic.BaseModel]) -> Any: - if PYDANTIC_V2: - return model.model_config - return model.__config__ # type: ignore + if PYDANTIC_V1: + return model.__config__ # type: ignore + return model.model_config def get_model_fields(model: type[pydantic.BaseModel]) -> dict[str, FieldInfo]: - if PYDANTIC_V2: - return model.model_fields - return model.__fields__ # type: ignore + if PYDANTIC_V1: + return model.__fields__ # type: ignore + return model.model_fields def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT: - if PYDANTIC_V2: - return model.model_copy(deep=deep) - return model.copy(deep=deep) # type: ignore + if PYDANTIC_V1: + return model.copy(deep=deep) # type: ignore + return model.model_copy(deep=deep) def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: - if PYDANTIC_V2: - return model.model_dump_json(indent=indent) - return model.json(indent=indent) # type: ignore + if PYDANTIC_V1: + return model.json(indent=indent) # type: ignore + return model.model_dump_json(indent=indent) def model_dump( @@ -139,14 +140,14 @@ def model_dump( warnings: bool = True, mode: Literal["json", "python"] = "python", ) -> dict[str, Any]: - if PYDANTIC_V2 or hasattr(model, "model_dump"): + if (not PYDANTIC_V1) or hasattr(model, "model_dump"): return model.model_dump( mode=mode, exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 - warnings=warnings if PYDANTIC_V2 else True, + warnings=True if PYDANTIC_V1 else warnings, ) return cast( "dict[str, Any]", @@ -159,9 +160,9 @@ def model_dump( def model_parse(model: type[_ModelT], data: Any) -> _ModelT: - if PYDANTIC_V2: - return model.model_validate(data) - return model.parse_obj(data) # pyright: ignore[reportDeprecated] + if PYDANTIC_V1: + return model.parse_obj(data) # pyright: ignore[reportDeprecated] + return model.model_validate(data) # generic models @@ -170,17 +171,16 @@ def model_parse(model: type[_ModelT], data: Any) -> _ModelT: class GenericModel(pydantic.BaseModel): ... else: - if PYDANTIC_V2: + if PYDANTIC_V1: + import pydantic.generics + + class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... + else: # there no longer needs to be a distinction in v2 but # we still have to create our own subclass to avoid # inconsistent MRO ordering errors class GenericModel(pydantic.BaseModel): ... - else: - import pydantic.generics - - class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... - # cached properties if TYPE_CHECKING: diff --git a/src/brand/dev/_models.py b/src/brand/dev/_models.py index 92f7c10..3a6017e 100644 --- a/src/brand/dev/_models.py +++ b/src/brand/dev/_models.py @@ -50,7 +50,7 @@ strip_annotated_type, ) from ._compat import ( - PYDANTIC_V2, + PYDANTIC_V1, ConfigDict, GenericModel as BaseGenericModel, get_args, @@ -81,11 +81,7 @@ class _ConfigProtocol(Protocol): class BaseModel(pydantic.BaseModel): - if PYDANTIC_V2: - model_config: ClassVar[ConfigDict] = ConfigDict( - extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) - ) - else: + if PYDANTIC_V1: @property @override @@ -95,6 +91,10 @@ def model_fields_set(self) -> set[str]: class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] extra: Any = pydantic.Extra.allow # type: ignore + else: + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) + ) def to_dict( self, @@ -215,25 +215,25 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] if key not in model_fields: parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value - if PYDANTIC_V2: - _extra[key] = parsed - else: + if PYDANTIC_V1: _fields_set.add(key) fields_values[key] = parsed + else: + _extra[key] = parsed object.__setattr__(m, "__dict__", fields_values) - if PYDANTIC_V2: - # these properties are copied from Pydantic's `model_construct()` method - object.__setattr__(m, "__pydantic_private__", None) - object.__setattr__(m, "__pydantic_extra__", _extra) - object.__setattr__(m, "__pydantic_fields_set__", _fields_set) - else: + if PYDANTIC_V1: # init_private_attributes() does not exist in v2 m._init_private_attributes() # type: ignore # copied from Pydantic v1's `construct()` method object.__setattr__(m, "__fields_set__", _fields_set) + else: + # these properties are copied from Pydantic's `model_construct()` method + object.__setattr__(m, "__pydantic_private__", None) + object.__setattr__(m, "__pydantic_extra__", _extra) + object.__setattr__(m, "__pydantic_fields_set__", _fields_set) return m @@ -243,7 +243,7 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] # although not in practice model_construct = construct - if not PYDANTIC_V2: + if PYDANTIC_V1: # we define aliases for some of the new pydantic v2 methods so # that we can just document these methods without having to specify # a specific pydantic version as some users may not know which @@ -363,10 +363,10 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: if value is None: return field_get_default(field) - if PYDANTIC_V2: - type_ = field.annotation - else: + if PYDANTIC_V1: type_ = cast(type, field.outer_type_) # type: ignore + else: + type_ = field.annotation # type: ignore if type_ is None: raise RuntimeError(f"Unexpected field type is None for {key}") @@ -375,7 +375,7 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: - if not PYDANTIC_V2: + if PYDANTIC_V1: # TODO return None @@ -628,30 +628,30 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, for variant in get_args(union): variant = strip_annotated_type(variant) if is_basemodel_type(variant): - if PYDANTIC_V2: - field = _extract_field_schema_pv2(variant, discriminator_field_name) - if not field: + if PYDANTIC_V1: + field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + if not field_info: continue # Note: if one variant defines an alias then they all should - discriminator_alias = field.get("serialization_alias") - - field_schema = field["schema"] + discriminator_alias = field_info.alias - if field_schema["type"] == "literal": - for entry in cast("LiteralSchema", field_schema)["expected"]: + if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): + for entry in get_args(annotation): if isinstance(entry, str): mapping[entry] = variant else: - field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - if not field_info: + field = _extract_field_schema_pv2(variant, discriminator_field_name) + if not field: continue # Note: if one variant defines an alias then they all should - discriminator_alias = field_info.alias + discriminator_alias = field.get("serialization_alias") - if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): - for entry in get_args(annotation): + field_schema = field["schema"] + + if field_schema["type"] == "literal": + for entry in cast("LiteralSchema", field_schema)["expected"]: if isinstance(entry, str): mapping[entry] = variant @@ -714,7 +714,7 @@ class GenericModel(BaseGenericModel, BaseModel): pass -if PYDANTIC_V2: +if not PYDANTIC_V1: from pydantic import TypeAdapter as _TypeAdapter _CachedTypeAdapter = cast("TypeAdapter[object]", lru_cache(maxsize=None)(_TypeAdapter)) @@ -782,12 +782,12 @@ class FinalRequestOptions(pydantic.BaseModel): json_data: Union[Body, None] = None extra_json: Union[AnyMapping, None] = None - if PYDANTIC_V2: - model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) - else: + if PYDANTIC_V1: class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] arbitrary_types_allowed: bool = True + else: + model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) def get_max_retries(self, max_retries: int) -> int: if isinstance(self.max_retries, NotGiven): @@ -820,9 +820,9 @@ def construct( # type: ignore key: strip_not_given(value) for key, value in values.items() } - if PYDANTIC_V2: - return super().model_construct(_fields_set, **kwargs) - return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] + if PYDANTIC_V1: + return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] + return super().model_construct(_fields_set, **kwargs) if not TYPE_CHECKING: # type checkers incorrectly complain about this assignment diff --git a/src/brand/dev/_utils/__init__.py b/src/brand/dev/_utils/__init__.py index ca547ce..dc64e29 100644 --- a/src/brand/dev/_utils/__init__.py +++ b/src/brand/dev/_utils/__init__.py @@ -10,7 +10,6 @@ lru_cache as lru_cache, is_mapping as is_mapping, is_tuple_t as is_tuple_t, - parse_date as parse_date, is_iterable as is_iterable, is_sequence as is_sequence, coerce_float as coerce_float, @@ -23,7 +22,6 @@ coerce_boolean as coerce_boolean, coerce_integer as coerce_integer, file_from_path as file_from_path, - parse_datetime as parse_datetime, strip_not_given as strip_not_given, deepcopy_minimal as deepcopy_minimal, get_async_library as get_async_library, @@ -32,6 +30,13 @@ maybe_coerce_boolean as maybe_coerce_boolean, maybe_coerce_integer as maybe_coerce_integer, ) +from ._compat import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, +) from ._typing import ( is_list_type as is_list_type, is_union_type as is_union_type, @@ -56,3 +61,4 @@ function_has_argument as function_has_argument, assert_signatures_in_sync as assert_signatures_in_sync, ) +from ._datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime diff --git a/src/brand/dev/_utils/_compat.py b/src/brand/dev/_utils/_compat.py new file mode 100644 index 0000000..dd70323 --- /dev/null +++ b/src/brand/dev/_utils/_compat.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +import typing_extensions +from typing import Any, Type, Union, Literal, Optional +from datetime import date, datetime +from typing_extensions import get_args as _get_args, get_origin as _get_origin + +from .._types import StrBytesIntFloat +from ._datetime_parse import parse_date as _parse_date, parse_datetime as _parse_datetime + +_LITERAL_TYPES = {Literal, typing_extensions.Literal} + + +def get_args(tp: type[Any]) -> tuple[Any, ...]: + return _get_args(tp) + + +def get_origin(tp: type[Any]) -> type[Any] | None: + return _get_origin(tp) + + +def is_union(tp: Optional[Type[Any]]) -> bool: + if sys.version_info < (3, 10): + return tp is Union # type: ignore[comparison-overlap] + else: + import types + + return tp is Union or tp is types.UnionType + + +def is_typeddict(tp: Type[Any]) -> bool: + return typing_extensions.is_typeddict(tp) + + +def is_literal_type(tp: Type[Any]) -> bool: + return get_origin(tp) in _LITERAL_TYPES + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + return _parse_date(value) + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + return _parse_datetime(value) diff --git a/src/brand/dev/_utils/_datetime_parse.py b/src/brand/dev/_utils/_datetime_parse.py new file mode 100644 index 0000000..7cb9d9e --- /dev/null +++ b/src/brand/dev/_utils/_datetime_parse.py @@ -0,0 +1,136 @@ +""" +This file contains code from https://github.com/pydantic/pydantic/blob/main/pydantic/v1/datetime_parse.py +without the Pydantic v1 specific errors. +""" + +from __future__ import annotations + +import re +from typing import Dict, Union, Optional +from datetime import date, datetime, timezone, timedelta + +from .._types import StrBytesIntFloat + +date_expr = r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" +time_expr = ( + r"(?P\d{1,2}):(?P\d{1,2})" + r"(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?" + r"(?PZ|[+-]\d{2}(?::?\d{2})?)?$" +) + +date_re = re.compile(f"{date_expr}$") +datetime_re = re.compile(f"{date_expr}[T ]{time_expr}") + + +EPOCH = datetime(1970, 1, 1) +# if greater than this, the number is in ms, if less than or equal it's in seconds +# (in seconds this is 11th October 2603, in ms it's 20th August 1970) +MS_WATERSHED = int(2e10) +# slightly more than datetime.max in ns - (datetime.max - EPOCH).total_seconds() * 1e9 +MAX_NUMBER = int(3e20) + + +def _get_numeric(value: StrBytesIntFloat, native_expected_type: str) -> Union[None, int, float]: + if isinstance(value, (int, float)): + return value + try: + return float(value) + except ValueError: + return None + except TypeError: + raise TypeError(f"invalid type; expected {native_expected_type}, string, bytes, int or float") from None + + +def _from_unix_seconds(seconds: Union[int, float]) -> datetime: + if seconds > MAX_NUMBER: + return datetime.max + elif seconds < -MAX_NUMBER: + return datetime.min + + while abs(seconds) > MS_WATERSHED: + seconds /= 1000 + dt = EPOCH + timedelta(seconds=seconds) + return dt.replace(tzinfo=timezone.utc) + + +def _parse_timezone(value: Optional[str]) -> Union[None, int, timezone]: + if value == "Z": + return timezone.utc + elif value is not None: + offset_mins = int(value[-2:]) if len(value) > 3 else 0 + offset = 60 * int(value[1:3]) + offset_mins + if value[0] == "-": + offset = -offset + return timezone(timedelta(minutes=offset)) + else: + return None + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + """ + Parse a datetime/int/float/string and return a datetime.datetime. + + This function supports time zone offsets. When the input contains one, + the output uses a timezone with a fixed offset from UTC. + + Raise ValueError if the input is well formatted but not a valid datetime. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, datetime): + return value + + number = _get_numeric(value, "datetime") + if number is not None: + return _from_unix_seconds(number) + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + + match = datetime_re.match(value) + if match is None: + raise ValueError("invalid datetime format") + + kw = match.groupdict() + if kw["microsecond"]: + kw["microsecond"] = kw["microsecond"].ljust(6, "0") + + tzinfo = _parse_timezone(kw.pop("tzinfo")) + kw_: Dict[str, Union[None, int, timezone]] = {k: int(v) for k, v in kw.items() if v is not None} + kw_["tzinfo"] = tzinfo + + return datetime(**kw_) # type: ignore + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + """ + Parse a date/int/float/string and return a datetime.date. + + Raise ValueError if the input is well formatted but not a valid date. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, date): + if isinstance(value, datetime): + return value.date() + else: + return value + + number = _get_numeric(value, "date") + if number is not None: + return _from_unix_seconds(number).date() + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + match = date_re.match(value) + if match is None: + raise ValueError("invalid date format") + + kw = {k: int(v) for k, v in match.groupdict().items()} + + try: + return date(**kw) + except ValueError: + raise ValueError("invalid date format") from None diff --git a/src/brand/dev/_utils/_transform.py b/src/brand/dev/_utils/_transform.py index f0bcefd..c19124f 100644 --- a/src/brand/dev/_utils/_transform.py +++ b/src/brand/dev/_utils/_transform.py @@ -19,6 +19,7 @@ is_sequence, ) from .._files import is_base64_file_input +from ._compat import get_origin, is_typeddict from ._typing import ( is_list_type, is_union_type, @@ -29,7 +30,6 @@ is_annotated_type, strip_annotated_type, ) -from .._compat import get_origin, model_dump, is_typeddict _T = TypeVar("_T") @@ -169,6 +169,8 @@ def _transform_recursive( Defaults to the same value as the `annotation` argument. """ + from .._compat import model_dump + if inner_type is None: inner_type = annotation @@ -333,6 +335,8 @@ async def _async_transform_recursive( Defaults to the same value as the `annotation` argument. """ + from .._compat import model_dump + if inner_type is None: inner_type = annotation diff --git a/src/brand/dev/_utils/_typing.py b/src/brand/dev/_utils/_typing.py index 845cd6b..193109f 100644 --- a/src/brand/dev/_utils/_typing.py +++ b/src/brand/dev/_utils/_typing.py @@ -15,7 +15,7 @@ from ._utils import lru_cache from .._types import InheritsGeneric -from .._compat import is_union as _is_union +from ._compat import is_union as _is_union def is_annotated_type(typ: type) -> bool: diff --git a/src/brand/dev/_utils/_utils.py b/src/brand/dev/_utils/_utils.py index ea3cf3f..f081859 100644 --- a/src/brand/dev/_utils/_utils.py +++ b/src/brand/dev/_utils/_utils.py @@ -22,7 +22,6 @@ import sniffio from .._types import NotGiven, FileTypes, NotGivenOr, HeadersLike -from .._compat import parse_date as parse_date, parse_datetime as parse_datetime _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) diff --git a/tests/test_models.py b/tests/test_models.py index fafd8c4..44e5d4c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,7 +8,7 @@ from pydantic import Field from brand.dev._utils import PropertyInfo -from brand.dev._compat import PYDANTIC_V2, parse_obj, model_dump, model_json +from brand.dev._compat import PYDANTIC_V1, parse_obj, model_dump, model_json from brand.dev._models import BaseModel, construct_type @@ -294,12 +294,12 @@ class Model(BaseModel): assert cast(bool, m.foo) is True m = Model.construct(foo={"name": 3}) - if PYDANTIC_V2: - assert isinstance(m.foo, Submodel1) - assert m.foo.name == 3 # type: ignore - else: + if PYDANTIC_V1: assert isinstance(m.foo, Submodel2) assert m.foo.name == "3" + else: + assert isinstance(m.foo, Submodel1) + assert m.foo.name == 3 # type: ignore def test_list_of_unions() -> None: @@ -426,10 +426,10 @@ class Model(BaseModel): expected = datetime(2019, 12, 27, 18, 11, 19, 117000, tzinfo=timezone.utc) - if PYDANTIC_V2: - expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' - else: + if PYDANTIC_V1: expected_json = '{"created_at": "2019-12-27T18:11:19.117000+00:00"}' + else: + expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' model = Model.construct(created_at="2019-12-27T18:11:19.117Z") assert model.created_at == expected @@ -531,7 +531,7 @@ class Model2(BaseModel): assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)} assert m4.to_dict(mode="json") == {"created_at": time_str} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): m.to_dict(warnings=False) @@ -556,7 +556,7 @@ class Model(BaseModel): assert m3.model_dump() == {"foo": None} assert m3.model_dump(exclude_none=True) == {} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): m.model_dump(round_trip=True) @@ -580,10 +580,10 @@ class Model(BaseModel): assert json.loads(m.to_json()) == {"FOO": "hello"} assert json.loads(m.to_json(use_api_names=False)) == {"foo": "hello"} - if PYDANTIC_V2: - assert m.to_json(indent=None) == '{"FOO":"hello"}' - else: + if PYDANTIC_V1: assert m.to_json(indent=None) == '{"FOO": "hello"}' + else: + assert m.to_json(indent=None) == '{"FOO":"hello"}' m2 = Model() assert json.loads(m2.to_json()) == {} @@ -595,7 +595,7 @@ class Model(BaseModel): assert json.loads(m3.to_json()) == {"FOO": None} assert json.loads(m3.to_json(exclude_none=True)) == {} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): m.to_json(warnings=False) @@ -622,7 +622,7 @@ class Model(BaseModel): assert json.loads(m3.model_dump_json()) == {"foo": None} assert json.loads(m3.model_dump_json(exclude_none=True)) == {} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): m.model_dump_json(round_trip=True) @@ -679,12 +679,12 @@ class B(BaseModel): ) assert isinstance(m, A) assert m.type == "a" - if PYDANTIC_V2: - assert m.data == 100 # type: ignore[comparison-overlap] - else: + if PYDANTIC_V1: # pydantic v1 automatically converts inputs to strings # if the expected type is a str assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] def test_discriminated_unions_unknown_variant() -> None: @@ -768,12 +768,12 @@ class B(BaseModel): ) assert isinstance(m, A) assert m.foo_type == "a" - if PYDANTIC_V2: - assert m.data == 100 # type: ignore[comparison-overlap] - else: + if PYDANTIC_V1: # pydantic v1 automatically converts inputs to strings # if the expected type is a str assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] def test_discriminated_unions_overlapping_discriminators_invalid_data() -> None: @@ -833,7 +833,7 @@ class B(BaseModel): assert UnionType.__discriminator__ is discriminator -@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") def test_type_alias_type() -> None: Alias = TypeAliasType("Alias", str) # pyright: ignore @@ -849,7 +849,7 @@ class Model(BaseModel): assert m.union == "bar" -@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") def test_field_named_cls() -> None: class Model(BaseModel): cls: str @@ -936,7 +936,7 @@ class Type2(BaseModel): assert isinstance(model.value, InnerType2) -@pytest.mark.skipif(not PYDANTIC_V2, reason="this is only supported in pydantic v2 for now") +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2 for now") def test_extra_properties() -> None: class Item(BaseModel): prop: int diff --git a/tests/test_transform.py b/tests/test_transform.py index ae2573f..b40e3d7 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -15,7 +15,7 @@ parse_datetime, async_transform as _async_transform, ) -from brand.dev._compat import PYDANTIC_V2 +from brand.dev._compat import PYDANTIC_V1 from brand.dev._models import BaseModel _T = TypeVar("_T") @@ -189,7 +189,7 @@ class DateModel(BaseModel): @pytest.mark.asyncio async def test_iso8601_format(use_async: bool) -> None: dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") - tz = "Z" if PYDANTIC_V2 else "+00:00" + tz = "+00:00" if PYDANTIC_V1 else "Z" assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692" + tz} # type: ignore[comparison-overlap] @@ -297,11 +297,11 @@ async def test_pydantic_unknown_field(use_async: bool) -> None: @pytest.mark.asyncio async def test_pydantic_mismatched_types(use_async: bool) -> None: model = MyModel.construct(foo=True) - if PYDANTIC_V2: + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: with pytest.warns(UserWarning): params = await transform(model, Any, use_async) - else: - params = await transform(model, Any, use_async) assert cast(Any, params) == {"foo": True} @@ -309,11 +309,11 @@ async def test_pydantic_mismatched_types(use_async: bool) -> None: @pytest.mark.asyncio async def test_pydantic_mismatched_object_type(use_async: bool) -> None: model = MyModel.construct(foo=MyModel.construct(hello="world")) - if PYDANTIC_V2: + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: with pytest.warns(UserWarning): params = await transform(model, Any, use_async) - else: - params = await transform(model, Any, use_async) assert cast(Any, params) == {"foo": {"hello": "world"}} diff --git a/tests/test_utils/test_datetime_parse.py b/tests/test_utils/test_datetime_parse.py new file mode 100644 index 0000000..b7e93c2 --- /dev/null +++ b/tests/test_utils/test_datetime_parse.py @@ -0,0 +1,110 @@ +""" +Copied from https://github.com/pydantic/pydantic/blob/v1.10.22/tests/test_datetime_parse.py +with modifications so it works without pydantic v1 imports. +""" + +from typing import Type, Union +from datetime import date, datetime, timezone, timedelta + +import pytest + +from brand.dev._utils import parse_date, parse_datetime + + +def create_tz(minutes: int) -> timezone: + return timezone(timedelta(minutes=minutes)) + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + ("1494012444.883309", date(2017, 5, 5)), + (b"1494012444.883309", date(2017, 5, 5)), + (1_494_012_444.883_309, date(2017, 5, 5)), + ("1494012444", date(2017, 5, 5)), + (1_494_012_444, date(2017, 5, 5)), + (0, date(1970, 1, 1)), + ("2012-04-23", date(2012, 4, 23)), + (b"2012-04-23", date(2012, 4, 23)), + ("2012-4-9", date(2012, 4, 9)), + (date(2012, 4, 9), date(2012, 4, 9)), + (datetime(2012, 4, 9, 12, 15), date(2012, 4, 9)), + # Invalid inputs + ("x20120423", ValueError), + ("2012-04-56", ValueError), + (19_999_999_999, date(2603, 10, 11)), # just before watershed + (20_000_000_001, date(1970, 8, 20)), # just after watershed + (1_549_316_052, date(2019, 2, 4)), # nowish in s + (1_549_316_052_104, date(2019, 2, 4)), # nowish in ms + (1_549_316_052_104_324, date(2019, 2, 4)), # nowish in μs + (1_549_316_052_104_324_096, date(2019, 2, 4)), # nowish in ns + ("infinity", date(9999, 12, 31)), + ("inf", date(9999, 12, 31)), + (float("inf"), date(9999, 12, 31)), + ("infinity ", date(9999, 12, 31)), + (int("1" + "0" * 100), date(9999, 12, 31)), + (1e1000, date(9999, 12, 31)), + ("-infinity", date(1, 1, 1)), + ("-inf", date(1, 1, 1)), + ("nan", ValueError), + ], +) +def test_date_parsing(value: Union[str, bytes, int, float], result: Union[date, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_date(value) + else: + assert parse_date(value) == result + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + # values in seconds + ("1494012444.883309", datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + (1_494_012_444.883_309, datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + ("1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (b"1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (1_494_012_444, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + # values in ms + ("1494012444000.883309", datetime(2017, 5, 5, 19, 27, 24, 883, tzinfo=timezone.utc)), + ("-1494012444000.883309", datetime(1922, 8, 29, 4, 32, 35, 999117, tzinfo=timezone.utc)), + (1_494_012_444_000, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + ("2012-04-23T09:15:00", datetime(2012, 4, 23, 9, 15)), + ("2012-4-9 4:8:16", datetime(2012, 4, 9, 4, 8, 16)), + ("2012-04-23T09:15:00Z", datetime(2012, 4, 23, 9, 15, 0, 0, timezone.utc)), + ("2012-4-9 4:8:16-0320", datetime(2012, 4, 9, 4, 8, 16, 0, create_tz(-200))), + ("2012-04-23T10:20:30.400+02:30", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(150))), + ("2012-04-23T10:20:30.400+02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(120))), + ("2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (b"2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (datetime(2017, 5, 5), datetime(2017, 5, 5)), + (0, datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc)), + # Invalid inputs + ("x20120423091500", ValueError), + ("2012-04-56T09:15:90", ValueError), + ("2012-04-23T11:05:00-25:00", ValueError), + (19_999_999_999, datetime(2603, 10, 11, 11, 33, 19, tzinfo=timezone.utc)), # just before watershed + (20_000_000_001, datetime(1970, 8, 20, 11, 33, 20, 1000, tzinfo=timezone.utc)), # just after watershed + (1_549_316_052, datetime(2019, 2, 4, 21, 34, 12, 0, tzinfo=timezone.utc)), # nowish in s + (1_549_316_052_104, datetime(2019, 2, 4, 21, 34, 12, 104_000, tzinfo=timezone.utc)), # nowish in ms + (1_549_316_052_104_324, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in μs + (1_549_316_052_104_324_096, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in ns + ("infinity", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf ", datetime(9999, 12, 31, 23, 59, 59, 999999)), + (1e50, datetime(9999, 12, 31, 23, 59, 59, 999999)), + (float("inf"), datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("-infinity", datetime(1, 1, 1, 0, 0)), + ("-inf", datetime(1, 1, 1, 0, 0)), + ("nan", ValueError), + ], +) +def test_datetime_parsing(value: Union[str, bytes, int, float], result: Union[datetime, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_datetime(value) + else: + assert parse_datetime(value) == result diff --git a/tests/utils.py b/tests/utils.py index bccf01c..9237ba2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -19,7 +19,7 @@ is_annotated_type, is_type_alias_type, ) -from brand.dev._compat import PYDANTIC_V2, field_outer_type, get_model_fields +from brand.dev._compat import PYDANTIC_V1, field_outer_type, get_model_fields from brand.dev._models import BaseModel BaseModelT = TypeVar("BaseModelT", bound=BaseModel) @@ -28,12 +28,12 @@ def assert_matches_model(model: type[BaseModelT], value: BaseModelT, *, path: list[str]) -> bool: for name, field in get_model_fields(model).items(): field_value = getattr(value, name) - if PYDANTIC_V2: - allow_none = False - else: + if PYDANTIC_V1: # in v1 nullability was structured differently # https://docs.pydantic.dev/2.0/migration/#required-optional-and-nullable-fields allow_none = getattr(field, "allow_none", False) + else: + allow_none = False assert_matches_type( field_outer_type(field), From 10707f22c7d98b6414c3e7fd9e701f7caa884a37 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 02:15:35 +0000 Subject: [PATCH 093/176] chore(internal): move mypy configurations to `pyproject.toml` file --- mypy.ini | 50 ------------------------------------------------ pyproject.toml | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 50 deletions(-) delete mode 100644 mypy.ini diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 37c197f..0000000 --- a/mypy.ini +++ /dev/null @@ -1,50 +0,0 @@ -[mypy] -pretty = True -show_error_codes = True - -# Exclude _files.py because mypy isn't smart enough to apply -# the correct type narrowing and as this is an internal module -# it's fine to just use Pyright. -# -# We also exclude our `tests` as mypy doesn't always infer -# types correctly and Pyright will still catch any type errors. -exclude = ^(src/brand/dev/_files\.py|_dev/.*\.py|tests/.*)$ - -strict_equality = True -implicit_reexport = True -check_untyped_defs = True -no_implicit_optional = True - -warn_return_any = True -warn_unreachable = True -warn_unused_configs = True - -# Turn these options off as it could cause conflicts -# with the Pyright options. -warn_unused_ignores = False -warn_redundant_casts = False - -disallow_any_generics = True -disallow_untyped_defs = True -disallow_untyped_calls = True -disallow_subclassing_any = True -disallow_incomplete_defs = True -disallow_untyped_decorators = True -cache_fine_grained = True - -# By default, mypy reports an error if you assign a value to the result -# of a function call that doesn't return anything. We do this in our test -# cases: -# ``` -# result = ... -# assert result is None -# ``` -# Changing this codegen to make mypy happy would increase complexity -# and would not be worth it. -disable_error_code = func-returns-value,overload-cannot-match - -# https://github.com/python/mypy/issues/12162 -[mypy.overrides] -module = "black.files.*" -ignore_errors = true -ignore_missing_imports = true diff --git a/pyproject.toml b/pyproject.toml index 260486e..40c809d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,6 +157,58 @@ reportOverlappingOverload = false reportImportCycles = false reportPrivateUsage = false +[tool.mypy] +pretty = true +show_error_codes = true + +# Exclude _files.py because mypy isn't smart enough to apply +# the correct type narrowing and as this is an internal module +# it's fine to just use Pyright. +# +# We also exclude our `tests` as mypy doesn't always infer +# types correctly and Pyright will still catch any type errors. +exclude = ['src/brand/dev/_files.py', '_dev/.*.py', 'tests/.*'] + +strict_equality = true +implicit_reexport = true +check_untyped_defs = true +no_implicit_optional = true + +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true + +# Turn these options off as it could cause conflicts +# with the Pyright options. +warn_unused_ignores = false +warn_redundant_casts = false + +disallow_any_generics = true +disallow_untyped_defs = true +disallow_untyped_calls = true +disallow_subclassing_any = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +cache_fine_grained = true + +# By default, mypy reports an error if you assign a value to the result +# of a function call that doesn't return anything. We do this in our test +# cases: +# ``` +# result = ... +# assert result is None +# ``` +# Changing this codegen to make mypy happy would increase complexity +# and would not be worth it. +disable_error_code = "func-returns-value,overload-cannot-match" + +# https://github.com/python/mypy/issues/12162 +[[tool.mypy.overrides]] +module = "black.files.*" +ignore_errors = true +ignore_missing_imports = true + + [tool.ruff] line-length = 120 output-format = "grouped" From ecc1209dcb6df12f36de975b897839f3ca44dda2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 6 Sep 2025 02:33:56 +0000 Subject: [PATCH 094/176] chore(internal): codegen related update --- pyproject.toml | 1 - requirements-dev.lock | 1 - tests/test_client.py | 53 +++++-------------------------------------- 3 files changed, 6 insertions(+), 49 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 40c809d..ff93db8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,6 @@ dev-dependencies = [ "dirty-equals>=0.6.0", "importlib-metadata>=6.7.0", "rich>=13.7.1", - "nest_asyncio==1.6.0", "pytest-xdist>=3.6.1", ] diff --git a/requirements-dev.lock b/requirements-dev.lock index 1c0d9f6..e0f5074 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -75,7 +75,6 @@ multidict==6.4.4 mypy==1.14.1 mypy-extensions==1.0.0 # via mypy -nest-asyncio==1.6.0 nodeenv==1.8.0 # via pyright nox==2023.4.22 diff --git a/tests/test_client.py b/tests/test_client.py index 7473b12..37d8e22 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,13 +6,10 @@ import os import sys import json -import time import asyncio import inspect -import subprocess import tracemalloc from typing import Any, Union, cast -from textwrap import dedent from unittest import mock from typing_extensions import Literal @@ -23,14 +20,17 @@ from brand.dev import BrandDev, AsyncBrandDev, APIResponseValidationError from brand.dev._types import Omit +from brand.dev._utils import asyncify from brand.dev._models import BaseModel, FinalRequestOptions from brand.dev._exceptions import BrandDevError, APIStatusError, APITimeoutError, APIResponseValidationError from brand.dev._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, + OtherPlatform, DefaultHttpxClient, DefaultAsyncHttpxClient, + get_platform, make_request_options, ) @@ -1629,50 +1629,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" - def test_get_platform(self) -> None: - # A previous implementation of asyncify could leave threads unterminated when - # used with nest_asyncio. - # - # Since nest_asyncio.apply() is global and cannot be un-applied, this - # test is run in a separate process to avoid affecting other tests. - test_code = dedent(""" - import asyncio - import nest_asyncio - import threading - - from brand.dev._utils import asyncify - from brand.dev._base_client import get_platform - - async def test_main() -> None: - result = await asyncify(get_platform)() - print(result) - for thread in threading.enumerate(): - print(thread.name) - - nest_asyncio.apply() - asyncio.run(test_main()) - """) - with subprocess.Popen( - [sys.executable, "-c", test_code], - text=True, - ) as process: - timeout = 10 # seconds - - start_time = time.monotonic() - while True: - return_code = process.poll() - if return_code is not None: - if return_code != 0: - raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") - - # success - break - - if time.monotonic() - start_time > timeout: - process.kill() - raise AssertionError("calling get_platform using asyncify resulted in a hung process") - - time.sleep(0.1) + async def test_get_platform(self) -> None: + platform = await asyncify(get_platform)() + assert isinstance(platform, (str, OtherPlatform)) async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly From 4dea14f6296e6186194ed1840c10e7822a7af283 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 7 Sep 2025 18:15:56 +0000 Subject: [PATCH 095/176] feat(api): api update --- .stats.yml | 4 ++-- ...rand_identify_from_transaction_response.py | 24 +++++++++++++++++++ .../dev/types/brand_retrieve_response.py | 24 +++++++++++++++++++ .../dev/types/brand_styleguide_response.py | 4 ++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 56c8a72..648d4c2 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-0758a435064bd016fe211989cb7f6a3441460664aead5870777f8a662aa69794.yml -openapi_spec_hash: d646572eb126d1d18327240959fc96af +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-de37a4d4350c9cbde36a0e712baa6bf4f00c7ce9b70f5b24d18558740bbfa35b.yml +openapi_spec_hash: 60eed6f8f984700475d589a871c73932 config_hash: a7a306a1c85eef178e7989e0ce5979c6 diff --git a/src/brand/dev/types/brand_identify_from_transaction_response.py b/src/brand/dev/types/brand_identify_from_transaction_response.py index d59f558..69d4707 100644 --- a/src/brand/dev/types/brand_identify_from_transaction_response.py +++ b/src/brand/dev/types/brand_identify_from_transaction_response.py @@ -15,6 +15,7 @@ "BrandColor", "BrandIndustries", "BrandIndustriesEic", + "BrandLinks", "BrandLogo", "BrandLogoColor", "BrandLogoResolution", @@ -343,6 +344,26 @@ class BrandIndustries(BaseModel): """Easy Industry Classification - array of industry and subindustry pairs""" +class BrandLinks(BaseModel): + blog: Optional[str] = None + """URL to the brand's blog or news page""" + + careers: Optional[str] = None + """URL to the brand's careers or job opportunities page""" + + contact: Optional[str] = None + """URL to the brand's contact or contact us page""" + + pricing: Optional[str] = None + """URL to the brand's pricing or plans page""" + + privacy: Optional[str] = None + """URL to the brand's privacy policy page""" + + terms: Optional[str] = None + """URL to the brand's terms of service or terms and conditions page""" + + class BrandLogoColor(BaseModel): hex: Optional[str] = None """Color in hexadecimal format""" @@ -424,6 +445,9 @@ class Brand(BaseModel): is_nsfw: Optional[bool] = None """Indicates whether the brand content is not safe for work (NSFW)""" + links: Optional[BrandLinks] = None + """Important website links for the brand""" + logos: Optional[List[BrandLogo]] = None """An array of logos associated with the brand""" diff --git a/src/brand/dev/types/brand_retrieve_response.py b/src/brand/dev/types/brand_retrieve_response.py index 6f927d4..b120889 100644 --- a/src/brand/dev/types/brand_retrieve_response.py +++ b/src/brand/dev/types/brand_retrieve_response.py @@ -15,6 +15,7 @@ "BrandColor", "BrandIndustries", "BrandIndustriesEic", + "BrandLinks", "BrandLogo", "BrandLogoColor", "BrandLogoResolution", @@ -343,6 +344,26 @@ class BrandIndustries(BaseModel): """Easy Industry Classification - array of industry and subindustry pairs""" +class BrandLinks(BaseModel): + blog: Optional[str] = None + """URL to the brand's blog or news page""" + + careers: Optional[str] = None + """URL to the brand's careers or job opportunities page""" + + contact: Optional[str] = None + """URL to the brand's contact or contact us page""" + + pricing: Optional[str] = None + """URL to the brand's pricing or plans page""" + + privacy: Optional[str] = None + """URL to the brand's privacy policy page""" + + terms: Optional[str] = None + """URL to the brand's terms of service or terms and conditions page""" + + class BrandLogoColor(BaseModel): hex: Optional[str] = None """Color in hexadecimal format""" @@ -424,6 +445,9 @@ class Brand(BaseModel): is_nsfw: Optional[bool] = None """Indicates whether the brand content is not safe for work (NSFW)""" + links: Optional[BrandLinks] = None + """Important website links for the brand""" + logos: Optional[List[BrandLogo]] = None """An array of logos associated with the brand""" diff --git a/src/brand/dev/types/brand_styleguide_response.py b/src/brand/dev/types/brand_styleguide_response.py index 37198f2..0cc5aa7 100644 --- a/src/brand/dev/types/brand_styleguide_response.py +++ b/src/brand/dev/types/brand_styleguide_response.py @@ -1,6 +1,7 @@ # 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 @@ -270,6 +271,9 @@ class Styleguide(BaseModel): element_spacing: Optional[StyleguideElementSpacing] = FieldInfo(alias="elementSpacing", default=None) """Spacing system used on the website""" + mode: Optional[Literal["light", "dark"]] = None + """The primary color mode of the website design""" + shadows: Optional[StyleguideShadows] = None """Shadow styles used on the website""" From a2b8839b917043d9af0f45d6f9d2ebbc42b6adcf Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 7 Sep 2025 20:18:43 +0000 Subject: [PATCH 096/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ffb929a..f94eeca 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.12.1" + ".": "1.13.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ff93db8..3e9469a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.12.1" +version = "1.13.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index c3c01f4..bac9583 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.12.1" # x-release-please-version +__version__ = "1.13.0" # x-release-please-version From 54c0baebcf4b1e013e008c676991742cfeaf7f2e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 7 Sep 2025 20:38:11 +0000 Subject: [PATCH 097/176] feat(api): manual updates --- .stats.yml | 2 +- README.md | 2 +- SECURITY.md | 4 ++++ pyproject.toml | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.stats.yml b/.stats.yml index 648d4c2..fb14704 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-de37a4d4350c9cbde36a0e712baa6bf4f00c7ce9b70f5b24d18558740bbfa35b.yml openapi_spec_hash: 60eed6f8f984700475d589a871c73932 -config_hash: a7a306a1c85eef178e7989e0ce5979c6 +config_hash: 4e76a07aea49753a61313dcd8c10fb0f diff --git a/README.md b/README.md index 40d897e..41e137d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ It is generated with [Stainless](https://www.stainless.com/). ## Documentation -The full API of this library can be found in [api.md](api.md). +The REST API documentation can be found on [docs.brand.dev](https://docs.brand.dev/). The full API of this library can be found in [api.md](api.md). ## Installation diff --git a/SECURITY.md b/SECURITY.md index 1c1a2e0..9e74cc9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,6 +18,10 @@ before making any information public. If you encounter security issues that are not directly related to SDKs but pertain to the services or products provided by Brand Dev, please follow the respective company's security reporting guidelines. +### Brand Dev Terms and Policies + +Please contact hello@brand.dev for any questions or concerns regarding the security of our services. + --- Thank you for helping us keep the SDKs and systems they interact with secure. diff --git a/pyproject.toml b/pyproject.toml index 3e9469a..026d057 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" authors = [ -{ name = "Brand Dev", email = "" }, +{ name = "Brand Dev", email = "hello@brand.dev" }, ] dependencies = [ "httpx>=0.23.0, <1", From 14e2a4d5159d320b7699dda095e3c9f7c15d73a5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 7 Sep 2025 20:43:28 +0000 Subject: [PATCH 098/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index fb14704..da1e5f4 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-de37a4d4350c9cbde36a0e712baa6bf4f00c7ce9b70f5b24d18558740bbfa35b.yml -openapi_spec_hash: 60eed6f8f984700475d589a871c73932 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-a38595b18a0fe26de444a0dd332f15f1e9e35667fca933d454c303676fac93e2.yml +openapi_spec_hash: 1c4882ef9df6782c91ee94c1f2908a90 config_hash: 4e76a07aea49753a61313dcd8c10fb0f From b3e33161d12e3898307813e8705b6b1b69d98aa1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 7 Sep 2025 20:52:58 +0000 Subject: [PATCH 099/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f94eeca..e72f113 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.13.0" + ".": "1.14.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 026d057..3558de3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.13.0" +version = "1.14.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index bac9583..8ce3075 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.13.0" # x-release-please-version +__version__ = "1.14.0" # x-release-please-version From ad2d88f3de26a75c425eb0208343a19d9066cf76 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 14 Sep 2025 23:27:12 +0000 Subject: [PATCH 100/176] feat(api): api update --- .stats.yml | 4 +- src/brand/dev/resources/brand.py | 52 +++++++++++++++++-- .../dev/types/brand_screenshot_params.py | 16 ++++++ .../dev/types/brand_styleguide_params.py | 10 +++- tests/api_resources/test_brand.py | 6 +++ 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/.stats.yml b/.stats.yml index da1e5f4..99da62d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-a38595b18a0fe26de444a0dd332f15f1e9e35667fca933d454c303676fac93e2.yml -openapi_spec_hash: 1c4882ef9df6782c91ee94c1f2908a90 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-558cea4d1e47f7f79e5a42b1113401d9488399f96ce57000488ba82eefc74119.yml +openapi_spec_hash: 576db82fb9e7648107687ee308db7eae config_hash: 4e76a07aea49753a61313dcd8c10fb0f diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 2cee6bb..8628087 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -445,6 +445,9 @@ def screenshot( *, domain: str, full_screenshot: Literal["true", "false"] | NotGiven = NOT_GIVEN, + page: Literal["login", "signup", "blog", "careers", "pricing", "terms", "privacy", "contact"] + | NotGiven = NOT_GIVEN, + prioritize: Literal["speed", "quality"] | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -455,8 +458,9 @@ def screenshot( """Beta feature: Capture a screenshot of a website. Supports both viewport - (standard browser view) and full-page screenshots. Returns a URL to the uploaded - screenshot image hosted on our CDN. + (standard browser view) and full-page screenshots. Can also screenshot specific + page types (login, pricing, etc.) by using heuristics to find the appropriate + URL. Returns a URL to the uploaded screenshot image hosted on our CDN. Args: domain: Domain name to take screenshot of (e.g., 'example.com', 'google.com'). The @@ -466,6 +470,15 @@ def screenshot( screenshot capturing all content. If 'false' or not provided, takes a viewport screenshot (standard browser view). + page: Optional parameter to specify which page type to screenshot. If provided, the + system will scrape the domain's links and use heuristics to find the most + appropriate URL for the specified page type (30 supported languages). If not + provided, screenshots the main domain landing page. + + prioritize: Optional parameter to prioritize screenshot capture. If 'speed', optimizes for + faster capture with basic quality. If 'quality', optimizes for higher quality + with longer wait times. Defaults to 'quality' if not provided. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -485,6 +498,8 @@ def screenshot( { "domain": domain, "full_screenshot": full_screenshot, + "page": page, + "prioritize": prioritize, }, brand_screenshot_params.BrandScreenshotParams, ), @@ -496,6 +511,7 @@ def styleguide( self, *, domain: str, + prioritize: Literal["speed", "quality"] | NotGiven = NOT_GIVEN, timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -513,6 +529,11 @@ def styleguide( domain: Domain name to extract styleguide from (e.g., 'example.com', 'google.com'). The domain will be automatically normalized and validated. + prioritize: Optional parameter to prioritize screenshot capture for styleguide extraction. + If 'speed', optimizes for faster capture with basic quality. If 'quality', + optimizes for higher quality with longer wait times. Defaults to 'speed' if not + provided. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -535,6 +556,7 @@ def styleguide( query=maybe_transform( { "domain": domain, + "prioritize": prioritize, "timeout_ms": timeout_ms, }, brand_styleguide_params.BrandStyleguideParams, @@ -949,6 +971,9 @@ async def screenshot( *, domain: str, full_screenshot: Literal["true", "false"] | NotGiven = NOT_GIVEN, + page: Literal["login", "signup", "blog", "careers", "pricing", "terms", "privacy", "contact"] + | NotGiven = NOT_GIVEN, + prioritize: Literal["speed", "quality"] | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -959,8 +984,9 @@ async def screenshot( """Beta feature: Capture a screenshot of a website. Supports both viewport - (standard browser view) and full-page screenshots. Returns a URL to the uploaded - screenshot image hosted on our CDN. + (standard browser view) and full-page screenshots. Can also screenshot specific + page types (login, pricing, etc.) by using heuristics to find the appropriate + URL. Returns a URL to the uploaded screenshot image hosted on our CDN. Args: domain: Domain name to take screenshot of (e.g., 'example.com', 'google.com'). The @@ -970,6 +996,15 @@ async def screenshot( screenshot capturing all content. If 'false' or not provided, takes a viewport screenshot (standard browser view). + page: Optional parameter to specify which page type to screenshot. If provided, the + system will scrape the domain's links and use heuristics to find the most + appropriate URL for the specified page type (30 supported languages). If not + provided, screenshots the main domain landing page. + + prioritize: Optional parameter to prioritize screenshot capture. If 'speed', optimizes for + faster capture with basic quality. If 'quality', optimizes for higher quality + with longer wait times. Defaults to 'quality' if not provided. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -989,6 +1024,8 @@ async def screenshot( { "domain": domain, "full_screenshot": full_screenshot, + "page": page, + "prioritize": prioritize, }, brand_screenshot_params.BrandScreenshotParams, ), @@ -1000,6 +1037,7 @@ async def styleguide( self, *, domain: str, + prioritize: Literal["speed", "quality"] | NotGiven = NOT_GIVEN, timeout_ms: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -1017,6 +1055,11 @@ async def styleguide( domain: Domain name to extract styleguide from (e.g., 'example.com', 'google.com'). The domain will be automatically normalized and validated. + prioritize: Optional parameter to prioritize screenshot capture for styleguide extraction. + If 'speed', optimizes for faster capture with basic quality. If 'quality', + optimizes for higher quality with longer wait times. Defaults to 'speed' if not + provided. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -1039,6 +1082,7 @@ async def styleguide( query=await async_maybe_transform( { "domain": domain, + "prioritize": prioritize, "timeout_ms": timeout_ms, }, brand_styleguide_params.BrandStyleguideParams, diff --git a/src/brand/dev/types/brand_screenshot_params.py b/src/brand/dev/types/brand_screenshot_params.py index 5027d90..4f26b1f 100644 --- a/src/brand/dev/types/brand_screenshot_params.py +++ b/src/brand/dev/types/brand_screenshot_params.py @@ -22,3 +22,19 @@ class BrandScreenshotParams(TypedDict, total=False): If 'true', takes a full page screenshot capturing all content. If 'false' or not provided, takes a viewport screenshot (standard browser view). """ + + page: Literal["login", "signup", "blog", "careers", "pricing", "terms", "privacy", "contact"] + """Optional parameter to specify which page type to screenshot. + + If provided, the system will scrape the domain's links and use heuristics to + find the most appropriate URL for the specified page type (30 supported + languages). If not provided, screenshots the main domain landing page. + """ + + prioritize: Literal["speed", "quality"] + """Optional parameter to prioritize screenshot capture. + + If 'speed', optimizes for faster capture with basic quality. If 'quality', + optimizes for higher quality with longer wait times. Defaults to 'quality' if + not provided. + """ diff --git a/src/brand/dev/types/brand_styleguide_params.py b/src/brand/dev/types/brand_styleguide_params.py index 42b3f38..3634bce 100644 --- a/src/brand/dev/types/brand_styleguide_params.py +++ b/src/brand/dev/types/brand_styleguide_params.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing_extensions import Required, Annotated, TypedDict +from typing_extensions import Literal, Required, Annotated, TypedDict from .._utils import PropertyInfo @@ -16,6 +16,14 @@ class BrandStyleguideParams(TypedDict, total=False): The domain will be automatically normalized and validated. """ + prioritize: Literal["speed", "quality"] + """Optional parameter to prioritize screenshot capture for styleguide extraction. + + If 'speed', optimizes for faster capture with basic quality. If 'quality', + optimizes for higher quality with longer wait times. Defaults to 'speed' if not + provided. + """ + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] """Optional timeout in milliseconds for the request. diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index d1dfb61..15e05ed 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -338,6 +338,8 @@ def test_method_screenshot_with_all_params(self, client: BrandDev) -> None: brand = client.brand.screenshot( domain="domain", full_screenshot="true", + page="login", + prioritize="speed", ) assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) @@ -380,6 +382,7 @@ def test_method_styleguide(self, client: BrandDev) -> None: def test_method_styleguide_with_all_params(self, client: BrandDev) -> None: brand = client.brand.styleguide( domain="domain", + prioritize="speed", timeout_ms=1, ) assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) @@ -728,6 +731,8 @@ async def test_method_screenshot_with_all_params(self, async_client: AsyncBrandD brand = await async_client.brand.screenshot( domain="domain", full_screenshot="true", + page="login", + prioritize="speed", ) assert_matches_type(BrandScreenshotResponse, brand, path=["response"]) @@ -770,6 +775,7 @@ async def test_method_styleguide(self, async_client: AsyncBrandDev) -> None: async def test_method_styleguide_with_all_params(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.styleguide( domain="domain", + prioritize="speed", timeout_ms=1, ) assert_matches_type(BrandStyleguideResponse, brand, path=["response"]) From b939949de0717b6f9bcde5a6ef18c1747ca85e31 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 14 Sep 2025 23:30:10 +0000 Subject: [PATCH 101/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e72f113..7ccfe12 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.14.0" + ".": "1.15.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 3558de3..5acb73e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.14.0" +version = "1.15.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 8ce3075..24ab3aa 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.14.0" # x-release-please-version +__version__ = "1.15.0" # x-release-please-version From 2ebcee04ccc7dd0b29496b030c1440e73bc7f33d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 03:10:03 +0000 Subject: [PATCH 102/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 99da62d..d586a9b 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-558cea4d1e47f7f79e5a42b1113401d9488399f96ce57000488ba82eefc74119.yml -openapi_spec_hash: 576db82fb9e7648107687ee308db7eae +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-5d8a3aa0989801327fd29a79aee2e644e57b6758feabcd3c95c7567cdc50e2b0.yml +openapi_spec_hash: dbef0b13af16ee28208dc660903a28d6 config_hash: 4e76a07aea49753a61313dcd8c10fb0f From 19d5d09dca059a82747ad7ffb9a9470d0b45c804 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 17 Sep 2025 02:10:11 +0000 Subject: [PATCH 103/176] chore(internal): update pydantic dependency --- requirements-dev.lock | 7 +++++-- requirements.lock | 7 +++++-- src/brand/dev/_models.py | 14 ++++++++++---- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/requirements-dev.lock b/requirements-dev.lock index e0f5074..b114a65 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -88,9 +88,9 @@ pluggy==1.5.0 propcache==0.3.1 # via aiohttp # via yarl -pydantic==2.10.3 +pydantic==2.11.9 # via brand-dev -pydantic-core==2.27.1 +pydantic-core==2.33.2 # via pydantic pygments==2.18.0 # via rich @@ -126,6 +126,9 @@ typing-extensions==4.12.2 # via pydantic # via pydantic-core # via pyright + # via typing-inspection +typing-inspection==0.4.1 + # via pydantic virtualenv==20.24.5 # via nox yarl==1.20.0 diff --git a/requirements.lock b/requirements.lock index 900e4ea..826d2bc 100644 --- a/requirements.lock +++ b/requirements.lock @@ -55,9 +55,9 @@ multidict==6.4.4 propcache==0.3.1 # via aiohttp # via yarl -pydantic==2.10.3 +pydantic==2.11.9 # via brand-dev -pydantic-core==2.27.1 +pydantic-core==2.33.2 # via pydantic sniffio==1.3.0 # via anyio @@ -68,5 +68,8 @@ typing-extensions==4.12.2 # via multidict # via pydantic # via pydantic-core + # via typing-inspection +typing-inspection==0.4.1 + # via pydantic yarl==1.20.0 # via aiohttp diff --git a/src/brand/dev/_models.py b/src/brand/dev/_models.py index 3a6017e..6a3cd1d 100644 --- a/src/brand/dev/_models.py +++ b/src/brand/dev/_models.py @@ -256,7 +256,7 @@ def model_dump( mode: Literal["json", "python"] | str = "python", include: IncEx | None = None, exclude: IncEx | None = None, - by_alias: bool = False, + by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, @@ -264,6 +264,7 @@ def model_dump( warnings: bool | Literal["none", "warn", "error"] = True, context: dict[str, Any] | None = None, serialize_as_any: bool = False, + fallback: Callable[[Any], Any] | None = None, ) -> dict[str, Any]: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump @@ -295,10 +296,12 @@ def model_dump( raise ValueError("context is only supported in Pydantic v2") if serialize_as_any != False: raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") dumped = super().dict( # pyright: ignore[reportDeprecated] include=include, exclude=exclude, - by_alias=by_alias, + by_alias=by_alias if by_alias is not None else False, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, @@ -313,13 +316,14 @@ def model_dump_json( indent: int | None = None, include: IncEx | None = None, exclude: IncEx | None = None, - by_alias: bool = False, + by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, context: dict[str, Any] | None = None, + fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, ) -> str: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json @@ -348,11 +352,13 @@ def model_dump_json( raise ValueError("context is only supported in Pydantic v2") if serialize_as_any != False: raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") return super().json( # type: ignore[reportDeprecated] indent=indent, include=include, exclude=exclude, - by_alias=by_alias, + by_alias=by_alias if by_alias is not None else False, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, From 1b192e26157a224ee95f3ec49808dec88d79fd1a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 02:11:21 +0000 Subject: [PATCH 104/176] chore(types): change optional parameter type from NotGiven to Omit --- src/brand/dev/__init__.py | 4 +- src/brand/dev/_base_client.py | 18 ++--- src/brand/dev/_client.py | 16 ++--- src/brand/dev/_qs.py | 14 ++-- src/brand/dev/_types.py | 29 +++++--- src/brand/dev/_utils/_transform.py | 4 +- src/brand/dev/_utils/_utils.py | 8 +-- src/brand/dev/resources/brand.py | 104 ++++++++++++++--------------- tests/test_transform.py | 11 ++- 9 files changed, 111 insertions(+), 97 deletions(-) diff --git a/src/brand/dev/__init__.py b/src/brand/dev/__init__.py index 3912da5..a451780 100644 --- a/src/brand/dev/__init__.py +++ b/src/brand/dev/__init__.py @@ -3,7 +3,7 @@ import typing as _t from . import types -from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes +from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given from ._utils import file_from_path from ._client import ( Client, @@ -48,7 +48,9 @@ "ProxiesTypes", "NotGiven", "NOT_GIVEN", + "not_given", "Omit", + "omit", "BrandDevError", "APIError", "APIStatusError", diff --git a/src/brand/dev/_base_client.py b/src/brand/dev/_base_client.py index 5428ea1..84914ad 100644 --- a/src/brand/dev/_base_client.py +++ b/src/brand/dev/_base_client.py @@ -42,7 +42,6 @@ from ._qs import Querystring from ._files import to_httpx_files, async_to_httpx_files from ._types import ( - NOT_GIVEN, Body, Omit, Query, @@ -57,6 +56,7 @@ RequestOptions, HttpxRequestFiles, ModelBuilderProtocol, + not_given, ) from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping from ._compat import PYDANTIC_V1, model_copy, model_dump @@ -145,9 +145,9 @@ def __init__( def __init__( self, *, - url: URL | NotGiven = NOT_GIVEN, - json: Body | NotGiven = NOT_GIVEN, - params: Query | NotGiven = NOT_GIVEN, + url: URL | NotGiven = not_given, + json: Body | NotGiven = not_given, + params: Query | NotGiven = not_given, ) -> None: self.url = url self.json = json @@ -595,7 +595,7 @@ def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalReques # we internally support defining a temporary header to override the # default `cast_to` type for use with `.with_raw_response` and `.with_streaming_response` # see _response.py for implementation details - override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, NOT_GIVEN) + override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, not_given) if is_given(override_cast_to): options.headers = headers return cast(Type[ResponseT], override_cast_to) @@ -825,7 +825,7 @@ def __init__( version: str, base_url: str | URL, max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.Client | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, @@ -1356,7 +1356,7 @@ def __init__( base_url: str | URL, _strict_response_validation: bool, max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.AsyncClient | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, @@ -1818,8 +1818,8 @@ def make_request_options( extra_query: Query | None = None, extra_body: Body | None = None, idempotency_key: str | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - post_parser: PostParser | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + post_parser: PostParser | NotGiven = not_given, ) -> RequestOptions: """Create a dict of type RequestOptions without keys of NotGiven values.""" options: RequestOptions = {} diff --git a/src/brand/dev/_client.py b/src/brand/dev/_client.py index ae48f13..26dcb25 100644 --- a/src/brand/dev/_client.py +++ b/src/brand/dev/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, Union, Mapping +from typing import Any, Mapping from typing_extensions import Self, override import httpx @@ -11,13 +11,13 @@ from . import _exceptions from ._qs import Querystring from ._types import ( - NOT_GIVEN, Omit, Timeout, NotGiven, Transport, ProxiesTypes, RequestOptions, + not_given, ) from ._utils import is_given, get_async_library from ._version import __version__ @@ -55,7 +55,7 @@ def __init__( *, api_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, @@ -130,9 +130,9 @@ def copy( *, api_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.Client | None = None, - max_retries: int | NotGiven = NOT_GIVEN, + max_retries: int | NotGiven = not_given, default_headers: Mapping[str, str] | None = None, set_default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, @@ -223,7 +223,7 @@ def __init__( *, api_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, @@ -298,9 +298,9 @@ def copy( *, api_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.AsyncClient | None = None, - max_retries: int | NotGiven = NOT_GIVEN, + max_retries: int | NotGiven = not_given, default_headers: Mapping[str, str] | None = None, set_default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, diff --git a/src/brand/dev/_qs.py b/src/brand/dev/_qs.py index 274320c..ada6fd3 100644 --- a/src/brand/dev/_qs.py +++ b/src/brand/dev/_qs.py @@ -4,7 +4,7 @@ from urllib.parse import parse_qs, urlencode from typing_extensions import Literal, get_args -from ._types import NOT_GIVEN, NotGiven, NotGivenOr +from ._types import NotGiven, not_given from ._utils import flatten _T = TypeVar("_T") @@ -41,8 +41,8 @@ def stringify( self, params: Params, *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, ) -> str: return urlencode( self.stringify_items( @@ -56,8 +56,8 @@ def stringify_items( self, params: Params, *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, ) -> list[tuple[str, str]]: opts = Options( qs=self, @@ -143,8 +143,8 @@ def __init__( self, qs: Querystring = _qs, *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, ) -> None: self.array_format = qs.array_format if isinstance(array_format, NotGiven) else array_format self.nested_format = qs.nested_format if isinstance(nested_format, NotGiven) else nested_format diff --git a/src/brand/dev/_types.py b/src/brand/dev/_types.py index 1077e99..b46a2ee 100644 --- a/src/brand/dev/_types.py +++ b/src/brand/dev/_types.py @@ -117,18 +117,21 @@ class RequestOptions(TypedDict, total=False): # Sentinel class used until PEP 0661 is accepted class NotGiven: """ - A sentinel singleton class used to distinguish omitted keyword arguments - from those passed in with the value None (which may have different behavior). + For parameters with a meaningful None value, we need to distinguish between + the user explicitly passing None, and the user not passing the parameter at + all. + + User code shouldn't need to use not_given directly. For example: ```py - def get(timeout: Union[int, NotGiven, None] = NotGiven()) -> Response: ... + def create(timeout: Timeout | None | NotGiven = not_given): ... - get(timeout=1) # 1s timeout - get(timeout=None) # No timeout - get() # Default timeout behavior, which may not be statically known at the method definition. + create(timeout=1) # 1s timeout + create(timeout=None) # No timeout + create() # Default timeout behavior ``` """ @@ -140,13 +143,14 @@ def __repr__(self) -> str: return "NOT_GIVEN" -NotGivenOr = Union[_T, NotGiven] +not_given = NotGiven() +# for backwards compatibility: NOT_GIVEN = NotGiven() class Omit: - """In certain situations you need to be able to represent a case where a default value has - to be explicitly removed and `None` is not an appropriate substitute, for example: + """ + To explicitly omit something from being sent in a request, use `omit`. ```py # as the default `Content-Type` header is `application/json` that will be sent @@ -156,8 +160,8 @@ class Omit: # to look something like: 'multipart/form-data; boundary=0d8382fcf5f8c3be01ca2e11002d2983' client.post(..., headers={"Content-Type": "multipart/form-data"}) - # instead you can remove the default `application/json` header by passing Omit - client.post(..., headers={"Content-Type": Omit()}) + # instead you can remove the default `application/json` header by passing omit + client.post(..., headers={"Content-Type": omit}) ``` """ @@ -165,6 +169,9 @@ def __bool__(self) -> Literal[False]: return False +omit = Omit() + + @runtime_checkable class ModelBuilderProtocol(Protocol): @classmethod diff --git a/src/brand/dev/_utils/_transform.py b/src/brand/dev/_utils/_transform.py index c19124f..5207549 100644 --- a/src/brand/dev/_utils/_transform.py +++ b/src/brand/dev/_utils/_transform.py @@ -268,7 +268,7 @@ def _transform_typeddict( annotations = get_type_hints(expected_type, include_extras=True) for key, value in data.items(): if not is_given(value): - # we don't need to include `NotGiven` values here as they'll + # we don't need to include omitted values here as they'll # be stripped out before the request is sent anyway continue @@ -434,7 +434,7 @@ async def _async_transform_typeddict( annotations = get_type_hints(expected_type, include_extras=True) for key, value in data.items(): if not is_given(value): - # we don't need to include `NotGiven` values here as they'll + # we don't need to include omitted values here as they'll # be stripped out before the request is sent anyway continue diff --git a/src/brand/dev/_utils/_utils.py b/src/brand/dev/_utils/_utils.py index f081859..50d5926 100644 --- a/src/brand/dev/_utils/_utils.py +++ b/src/brand/dev/_utils/_utils.py @@ -21,7 +21,7 @@ import sniffio -from .._types import NotGiven, FileTypes, NotGivenOr, HeadersLike +from .._types import Omit, NotGiven, FileTypes, HeadersLike _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) @@ -63,7 +63,7 @@ def _extract_items( try: key = path[index] except IndexError: - if isinstance(obj, NotGiven): + if not is_given(obj): # no value was provided - we can safely ignore return [] @@ -126,8 +126,8 @@ def _extract_items( return [] -def is_given(obj: NotGivenOr[_T]) -> TypeGuard[_T]: - return not isinstance(obj, NotGiven) +def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: + return not isinstance(obj, NotGiven) and not isinstance(obj, Omit) # Type safe methods for narrowing types with TypeVars. diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 8628087..bba6924 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -17,7 +17,7 @@ brand_retrieve_simplified_params, brand_identify_from_transaction_params, ) -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -63,7 +63,7 @@ def with_streaming_response(self) -> BrandResourceWithStreamingResponse: def retrieve( self, *, - domain: str | NotGiven = NOT_GIVEN, + domain: str | Omit = omit, force_language: Literal[ "albanian", "arabic", @@ -118,17 +118,17 @@ def retrieve( "vietnamese", "welsh", ] - | NotGiven = NOT_GIVEN, - max_speed: bool | NotGiven = NOT_GIVEN, - name: str | NotGiven = NOT_GIVEN, - ticker: str | NotGiven = NOT_GIVEN, - timeout_ms: int | NotGiven = NOT_GIVEN, + | Omit = omit, + max_speed: bool | Omit = omit, + name: str | Omit = omit, + ticker: str | Omit = omit, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandRetrieveResponse: """ Retrieve brand information using one of three methods: domain name, company @@ -192,14 +192,14 @@ def ai_query( *, data_to_extract: Iterable[brand_ai_query_params.DataToExtract], domain: str, - specific_pages: brand_ai_query_params.SpecificPages | NotGiven = NOT_GIVEN, - timeout_ms: int | NotGiven = NOT_GIVEN, + specific_pages: brand_ai_query_params.SpecificPages | Omit = omit, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandAIQueryResponse: """Beta feature: Use AI to extract specific data points from a brand's website. @@ -247,13 +247,13 @@ def identify_from_transaction( self, *, transaction_info: str, - timeout_ms: int | NotGiven = NOT_GIVEN, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandIdentifyFromTransactionResponse: """ Endpoint specially designed for platforms that want to identify transaction data @@ -296,13 +296,13 @@ def prefetch( self, *, domain: str, - timeout_ms: int | NotGiven = NOT_GIVEN, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandPrefetchResponse: """ Signal that you may fetch brand data for a particular domain soon to improve @@ -344,13 +344,13 @@ def retrieve_naics( self, *, input: str, - timeout_ms: int | NotGiven = NOT_GIVEN, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandRetrieveNaicsResponse: """ Endpoint to classify any brand into a 2022 NAICS code. @@ -394,13 +394,13 @@ def retrieve_simplified( self, *, domain: str, - timeout_ms: int | NotGiven = NOT_GIVEN, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandRetrieveSimplifiedResponse: """ Returns a simplified version of brand data containing only essential @@ -444,16 +444,15 @@ def screenshot( self, *, domain: str, - full_screenshot: Literal["true", "false"] | NotGiven = NOT_GIVEN, - page: Literal["login", "signup", "blog", "careers", "pricing", "terms", "privacy", "contact"] - | NotGiven = NOT_GIVEN, - prioritize: Literal["speed", "quality"] | NotGiven = NOT_GIVEN, + full_screenshot: Literal["true", "false"] | Omit = omit, + page: Literal["login", "signup", "blog", "careers", "pricing", "terms", "privacy", "contact"] | Omit = omit, + prioritize: Literal["speed", "quality"] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandScreenshotResponse: """Beta feature: Capture a screenshot of a website. @@ -511,14 +510,14 @@ def styleguide( self, *, domain: str, - prioritize: Literal["speed", "quality"] | NotGiven = NOT_GIVEN, - timeout_ms: int | NotGiven = NOT_GIVEN, + prioritize: Literal["speed", "quality"] | Omit = omit, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandStyleguideResponse: """ Beta feature: Automatically extract comprehensive design system information from @@ -589,7 +588,7 @@ def with_streaming_response(self) -> AsyncBrandResourceWithStreamingResponse: async def retrieve( self, *, - domain: str | NotGiven = NOT_GIVEN, + domain: str | Omit = omit, force_language: Literal[ "albanian", "arabic", @@ -644,17 +643,17 @@ async def retrieve( "vietnamese", "welsh", ] - | NotGiven = NOT_GIVEN, - max_speed: bool | NotGiven = NOT_GIVEN, - name: str | NotGiven = NOT_GIVEN, - ticker: str | NotGiven = NOT_GIVEN, - timeout_ms: int | NotGiven = NOT_GIVEN, + | Omit = omit, + max_speed: bool | Omit = omit, + name: str | Omit = omit, + ticker: str | Omit = omit, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandRetrieveResponse: """ Retrieve brand information using one of three methods: domain name, company @@ -718,14 +717,14 @@ async def ai_query( *, data_to_extract: Iterable[brand_ai_query_params.DataToExtract], domain: str, - specific_pages: brand_ai_query_params.SpecificPages | NotGiven = NOT_GIVEN, - timeout_ms: int | NotGiven = NOT_GIVEN, + specific_pages: brand_ai_query_params.SpecificPages | Omit = omit, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandAIQueryResponse: """Beta feature: Use AI to extract specific data points from a brand's website. @@ -773,13 +772,13 @@ async def identify_from_transaction( self, *, transaction_info: str, - timeout_ms: int | NotGiven = NOT_GIVEN, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandIdentifyFromTransactionResponse: """ Endpoint specially designed for platforms that want to identify transaction data @@ -822,13 +821,13 @@ async def prefetch( self, *, domain: str, - timeout_ms: int | NotGiven = NOT_GIVEN, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandPrefetchResponse: """ Signal that you may fetch brand data for a particular domain soon to improve @@ -870,13 +869,13 @@ async def retrieve_naics( self, *, input: str, - timeout_ms: int | NotGiven = NOT_GIVEN, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandRetrieveNaicsResponse: """ Endpoint to classify any brand into a 2022 NAICS code. @@ -920,13 +919,13 @@ async def retrieve_simplified( self, *, domain: str, - timeout_ms: int | NotGiven = NOT_GIVEN, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandRetrieveSimplifiedResponse: """ Returns a simplified version of brand data containing only essential @@ -970,16 +969,15 @@ async def screenshot( self, *, domain: str, - full_screenshot: Literal["true", "false"] | NotGiven = NOT_GIVEN, - page: Literal["login", "signup", "blog", "careers", "pricing", "terms", "privacy", "contact"] - | NotGiven = NOT_GIVEN, - prioritize: Literal["speed", "quality"] | NotGiven = NOT_GIVEN, + full_screenshot: Literal["true", "false"] | Omit = omit, + page: Literal["login", "signup", "blog", "careers", "pricing", "terms", "privacy", "contact"] | Omit = omit, + prioritize: Literal["speed", "quality"] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandScreenshotResponse: """Beta feature: Capture a screenshot of a website. @@ -1037,14 +1035,14 @@ async def styleguide( self, *, domain: str, - prioritize: Literal["speed", "quality"] | NotGiven = NOT_GIVEN, - timeout_ms: int | NotGiven = NOT_GIVEN, + prioritize: Literal["speed", "quality"] | Omit = omit, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandStyleguideResponse: """ Beta feature: Automatically extract comprehensive design system information from diff --git a/tests/test_transform.py b/tests/test_transform.py index b40e3d7..46bd913 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -8,7 +8,7 @@ import pytest -from brand.dev._types import NOT_GIVEN, Base64FileInput +from brand.dev._types import Base64FileInput, omit, not_given from brand.dev._utils import ( PropertyInfo, transform as _transform, @@ -450,4 +450,11 @@ async def test_transform_skipping(use_async: bool) -> None: @pytest.mark.asyncio async def test_strips_notgiven(use_async: bool) -> None: assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} - assert await transform({"foo_bar": NOT_GIVEN}, Foo1, use_async) == {} + assert await transform({"foo_bar": not_given}, Foo1, use_async) == {} + + +@parametrize +@pytest.mark.asyncio +async def test_strips_omit(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": omit}, Foo1, use_async) == {} From 482d16b8f35c348897cefb301dfb3e72a59da06a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 20 Sep 2025 02:13:16 +0000 Subject: [PATCH 105/176] chore: do not install brew dependencies in ./scripts/bootstrap by default --- scripts/bootstrap | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/bootstrap b/scripts/bootstrap index e84fe62..b430fee 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,10 +4,18 @@ set -e cd "$(dirname "$0")/.." -if ! command -v rye >/dev/null 2>&1 && [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { - echo "==> Installing Homebrew dependencies…" - brew bundle + echo -n "==> Install Homebrew dependencies? (y/N): " + read -r response + case "$response" in + [yY][eE][sS]|[yY]) + brew bundle + ;; + *) + ;; + esac + echo } fi From 00cb38906a70a61a701d68f2bb8e348b5004a4e3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 20:52:05 +0000 Subject: [PATCH 106/176] feat(api): api update --- .stats.yml | 4 ++-- src/brand/dev/resources/brand.py | 8 ++++---- src/brand/dev/types/brand_styleguide_params.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.stats.yml b/.stats.yml index d586a9b..52b81d2 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-5d8a3aa0989801327fd29a79aee2e644e57b6758feabcd3c95c7567cdc50e2b0.yml -openapi_spec_hash: dbef0b13af16ee28208dc660903a28d6 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-ace647670dd24a6335738fb26360272dd0764b0f402e0186df0e92ecd0881c13.yml +openapi_spec_hash: c21512e10bd012cd347bb4afff7da9ae config_hash: 4e76a07aea49753a61313dcd8c10fb0f diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index bba6924..b909522 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -530,8 +530,8 @@ def styleguide( prioritize: Optional parameter to prioritize screenshot capture for styleguide extraction. If 'speed', optimizes for faster capture with basic quality. If 'quality', - optimizes for higher quality with longer wait times. Defaults to 'speed' if not - provided. + optimizes for higher quality with longer wait times. Defaults to 'quality' if + not provided. timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed @@ -1055,8 +1055,8 @@ async def styleguide( prioritize: Optional parameter to prioritize screenshot capture for styleguide extraction. If 'speed', optimizes for faster capture with basic quality. If 'quality', - optimizes for higher quality with longer wait times. Defaults to 'speed' if not - provided. + optimizes for higher quality with longer wait times. Defaults to 'quality' if + not provided. timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed diff --git a/src/brand/dev/types/brand_styleguide_params.py b/src/brand/dev/types/brand_styleguide_params.py index 3634bce..e633693 100644 --- a/src/brand/dev/types/brand_styleguide_params.py +++ b/src/brand/dev/types/brand_styleguide_params.py @@ -20,8 +20,8 @@ class BrandStyleguideParams(TypedDict, total=False): """Optional parameter to prioritize screenshot capture for styleguide extraction. If 'speed', optimizes for faster capture with basic quality. If 'quality', - optimizes for higher quality with longer wait times. Defaults to 'speed' if not - provided. + optimizes for higher quality with longer wait times. Defaults to 'quality' if + not provided. """ timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] From 43fd3235310ea2c7a8f69722621a49ada4e8be7a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 28 Sep 2025 15:33:17 +0000 Subject: [PATCH 107/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7ccfe12..bc845f3 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.15.0" + ".": "1.16.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5acb73e..6caeaf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.15.0" +version = "1.16.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 24ab3aa..39eff27 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.15.0" # x-release-please-version +__version__ = "1.16.0" # x-release-please-version From 51aecdf5924ddb680b6d141f6c40d452fb34c93b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:02:34 +0000 Subject: [PATCH 108/176] feat(api): api update --- .stats.yml | 4 ++-- src/brand/dev/resources/brand.py | 8 ++++---- src/brand/dev/types/brand_retrieve_params.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.stats.yml b/.stats.yml index 52b81d2..27000c3 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-ace647670dd24a6335738fb26360272dd0764b0f402e0186df0e92ecd0881c13.yml -openapi_spec_hash: c21512e10bd012cd347bb4afff7da9ae +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-1cadf490ec9941e4b33dfb59ae0934be9d072a9f12349effcf7d8721c19061f5.yml +openapi_spec_hash: 03b8b8f0ea9eaadaa6f68bc8c738dcc0 config_hash: 4e76a07aea49753a61313dcd8c10fb0f diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index b909522..277b27b 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -150,8 +150,8 @@ def retrieve( parameters. ticker: Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). - Must be 1-6 characters, letters/numbers/dots only. Cannot be used with domain or - name parameters. + Must be 1-10 characters, letters/numbers/dots only. Cannot be used with domain + or name parameters. timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed @@ -675,8 +675,8 @@ async def retrieve( parameters. ticker: Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). - Must be 1-6 characters, letters/numbers/dots only. Cannot be used with domain or - name parameters. + Must be 1-10 characters, letters/numbers/dots only. Cannot be used with domain + or name parameters. timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed diff --git a/src/brand/dev/types/brand_retrieve_params.py b/src/brand/dev/types/brand_retrieve_params.py index 8092de7..37f4827 100644 --- a/src/brand/dev/types/brand_retrieve_params.py +++ b/src/brand/dev/types/brand_retrieve_params.py @@ -93,8 +93,8 @@ class BrandRetrieveParams(TypedDict, total=False): ticker: str """Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). - Must be 1-6 characters, letters/numbers/dots only. Cannot be used with domain or - name parameters. + Must be 1-10 characters, letters/numbers/dots only. Cannot be used with domain + or name parameters. """ timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] From 32a52004f5698c5983b42bc21253016c1ededf32 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:07:46 +0000 Subject: [PATCH 109/176] feat(api): api update --- .stats.yml | 4 ++-- src/brand/dev/resources/brand.py | 4 ++-- src/brand/dev/types/brand_retrieve_params.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.stats.yml b/.stats.yml index 27000c3..19a9ac4 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-1cadf490ec9941e4b33dfb59ae0934be9d072a9f12349effcf7d8721c19061f5.yml -openapi_spec_hash: 03b8b8f0ea9eaadaa6f68bc8c738dcc0 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-4c50b742d3a4fb884d4411dc14921a4a366a6312d028a3d832099d4058b8d372.yml +openapi_spec_hash: 07cf92cb374995584a08f3a6c19c3688 config_hash: 4e76a07aea49753a61313dcd8c10fb0f diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 277b27b..3cc60de 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -150,7 +150,7 @@ def retrieve( parameters. ticker: Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). - Must be 1-10 characters, letters/numbers/dots only. Cannot be used with domain + Must be 1-15 characters, letters/numbers/dots only. Cannot be used with domain or name parameters. timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer @@ -675,7 +675,7 @@ async def retrieve( parameters. ticker: Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). - Must be 1-10 characters, letters/numbers/dots only. Cannot be used with domain + Must be 1-15 characters, letters/numbers/dots only. Cannot be used with domain or name parameters. timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer diff --git a/src/brand/dev/types/brand_retrieve_params.py b/src/brand/dev/types/brand_retrieve_params.py index 37f4827..2693e54 100644 --- a/src/brand/dev/types/brand_retrieve_params.py +++ b/src/brand/dev/types/brand_retrieve_params.py @@ -93,7 +93,7 @@ class BrandRetrieveParams(TypedDict, total=False): ticker: str """Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). - Must be 1-10 characters, letters/numbers/dots only. Cannot be used with domain + Must be 1-15 characters, letters/numbers/dots only. Cannot be used with domain or name parameters. """ From 03861c860500886d84dfce4c9cf037619a89a39e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:23:27 +0000 Subject: [PATCH 110/176] feat(api): api update --- .stats.yml | 4 +- src/brand/dev/resources/brand.py | 158 +++++++++++++++++++ src/brand/dev/types/brand_retrieve_params.py | 80 ++++++++++ tests/api_resources/test_brand.py | 2 + 4 files changed, 242 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 19a9ac4..8f07be2 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-4c50b742d3a4fb884d4411dc14921a4a366a6312d028a3d832099d4058b8d372.yml -openapi_spec_hash: 07cf92cb374995584a08f3a6c19c3688 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-64dee37a21a809941330b048765b0eff4805b5c6959bb17f141da70e61ecb88d.yml +openapi_spec_hash: d0fc0e51311de52c3aaa5c411db0f92b config_hash: 4e76a07aea49753a61313dcd8c10fb0f diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 3cc60de..f306d27 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -122,6 +122,81 @@ def retrieve( max_speed: bool | Omit = omit, name: str | Omit = omit, ticker: str | Omit = omit, + ticker_exchange: Literal[ + "AMEX", + "AMS", + "AQS", + "ASX", + "ATH", + "BER", + "BME", + "BRU", + "BSE", + "BUD", + "BUE", + "BVC", + "CBOE", + "CNQ", + "CPH", + "DFM", + "DOH", + "DUB", + "DUS", + "DXE", + "EGX", + "FSX", + "HAM", + "HEL", + "HKSE", + "HOSE", + "ICE", + "IOB", + "IST", + "JKT", + "JNB", + "JPX", + "KLS", + "KOE", + "KSC", + "KUW", + "LIS", + "LSE", + "MCX", + "MEX", + "MIL", + "MUN", + "NASDAQ", + "NEO", + "NSE", + "NYSE", + "NZE", + "OSL", + "OTC", + "PAR", + "PNK", + "PRA", + "RIS", + "SAO", + "SAU", + "SES", + "SET", + "SGO", + "SHH", + "SHZ", + "SIX", + "STO", + "STU", + "TAI", + "TAL", + "TLV", + "TSX", + "TSXV", + "TWO", + "VIE", + "WSE", + "XETRA", + ] + | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -153,6 +228,9 @@ def retrieve( Must be 1-15 characters, letters/numbers/dots only. Cannot be used with domain or name parameters. + ticker_exchange: Optional stock exchange for the ticker. Only used when ticker parameter is + provided. Defaults to assume ticker is American if not specified. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -179,6 +257,7 @@ def retrieve( "max_speed": max_speed, "name": name, "ticker": ticker, + "ticker_exchange": ticker_exchange, "timeout_ms": timeout_ms, }, brand_retrieve_params.BrandRetrieveParams, @@ -647,6 +726,81 @@ async def retrieve( max_speed: bool | Omit = omit, name: str | Omit = omit, ticker: str | Omit = omit, + ticker_exchange: Literal[ + "AMEX", + "AMS", + "AQS", + "ASX", + "ATH", + "BER", + "BME", + "BRU", + "BSE", + "BUD", + "BUE", + "BVC", + "CBOE", + "CNQ", + "CPH", + "DFM", + "DOH", + "DUB", + "DUS", + "DXE", + "EGX", + "FSX", + "HAM", + "HEL", + "HKSE", + "HOSE", + "ICE", + "IOB", + "IST", + "JKT", + "JNB", + "JPX", + "KLS", + "KOE", + "KSC", + "KUW", + "LIS", + "LSE", + "MCX", + "MEX", + "MIL", + "MUN", + "NASDAQ", + "NEO", + "NSE", + "NYSE", + "NZE", + "OSL", + "OTC", + "PAR", + "PNK", + "PRA", + "RIS", + "SAO", + "SAU", + "SES", + "SET", + "SGO", + "SHH", + "SHZ", + "SIX", + "STO", + "STU", + "TAI", + "TAL", + "TLV", + "TSX", + "TSXV", + "TWO", + "VIE", + "WSE", + "XETRA", + ] + | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -678,6 +832,9 @@ async def retrieve( Must be 1-15 characters, letters/numbers/dots only. Cannot be used with domain or name parameters. + ticker_exchange: Optional stock exchange for the ticker. Only used when ticker parameter is + provided. Defaults to assume ticker is American if not specified. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -704,6 +861,7 @@ async def retrieve( "max_speed": max_speed, "name": name, "ticker": ticker, + "ticker_exchange": ticker_exchange, "timeout_ms": timeout_ms, }, brand_retrieve_params.BrandRetrieveParams, diff --git a/src/brand/dev/types/brand_retrieve_params.py b/src/brand/dev/types/brand_retrieve_params.py index 2693e54..474c7b9 100644 --- a/src/brand/dev/types/brand_retrieve_params.py +++ b/src/brand/dev/types/brand_retrieve_params.py @@ -97,6 +97,86 @@ class BrandRetrieveParams(TypedDict, total=False): or name parameters. """ + ticker_exchange: Literal[ + "AMEX", + "AMS", + "AQS", + "ASX", + "ATH", + "BER", + "BME", + "BRU", + "BSE", + "BUD", + "BUE", + "BVC", + "CBOE", + "CNQ", + "CPH", + "DFM", + "DOH", + "DUB", + "DUS", + "DXE", + "EGX", + "FSX", + "HAM", + "HEL", + "HKSE", + "HOSE", + "ICE", + "IOB", + "IST", + "JKT", + "JNB", + "JPX", + "KLS", + "KOE", + "KSC", + "KUW", + "LIS", + "LSE", + "MCX", + "MEX", + "MIL", + "MUN", + "NASDAQ", + "NEO", + "NSE", + "NYSE", + "NZE", + "OSL", + "OTC", + "PAR", + "PNK", + "PRA", + "RIS", + "SAO", + "SAU", + "SES", + "SET", + "SGO", + "SHH", + "SHZ", + "SIX", + "STO", + "STU", + "TAI", + "TAL", + "TLV", + "TSX", + "TSXV", + "TWO", + "VIE", + "WSE", + "XETRA", + ] + """Optional stock exchange for the ticker. + + Only used when ticker parameter is provided. Defaults to assume ticker is + American if not specified. + """ + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] """Optional timeout in milliseconds for the request. diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 15e05ed..b1085aa 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -41,6 +41,7 @@ def test_method_retrieve_with_all_params(self, client: BrandDev) -> None: max_speed=True, name="xxx", ticker="ticker", + ticker_exchange="AMEX", timeout_ms=1, ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) @@ -434,6 +435,7 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncBrandDev max_speed=True, name="xxx", ticker="ticker", + ticker_exchange="AMEX", timeout_ms=1, ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) From 18f745753d6f48dcf5de572e1a44fa431a339f28 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:29:04 +0000 Subject: [PATCH 111/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index bc845f3..6a197be 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.16.0" + ".": "1.17.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6caeaf6..e7c938f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.16.0" +version = "1.17.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 39eff27..77ec14c 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.16.0" # x-release-please-version +__version__ = "1.17.0" # x-release-please-version From ef23ec20bf3ff552ec281863a66d93609c59c60e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 11 Oct 2025 02:04:31 +0000 Subject: [PATCH 112/176] chore(internal): detect missing future annotations with ruff --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e7c938f..36b4843 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -224,6 +224,8 @@ select = [ "B", # remove unused imports "F401", + # check for missing future annotations + "FA102", # bare except statements "E722", # unused arguments @@ -246,6 +248,8 @@ unfixable = [ "T203", ] +extend-safe-fixes = ["FA102"] + [tool.ruff.lint.flake8-tidy-imports.banned-api] "functools.lru_cache".msg = "This function does not retain type information for the wrapped function's arguments; The `lru_cache` function from `_utils` should be used instead" From d6ed75fa0c2e6df7c08daca8f603de63cc48c7c0 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Oct 2025 02:02:11 +0000 Subject: [PATCH 113/176] chore: bump `httpx-aiohttp` version to 0.1.9 --- pyproject.toml | 2 +- requirements-dev.lock | 2 +- requirements.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 36b4843..d25c3b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Homepage = "https://github.com/brand-dot-dev/python-sdk" Repository = "https://github.com/brand-dot-dev/python-sdk" [project.optional-dependencies] -aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] [tool.rye] managed = true diff --git a/requirements-dev.lock b/requirements-dev.lock index b114a65..e8a6c7a 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -56,7 +56,7 @@ httpx==0.28.1 # via brand-dev # via httpx-aiohttp # via respx -httpx-aiohttp==0.1.8 +httpx-aiohttp==0.1.9 # via brand-dev idna==3.4 # via anyio diff --git a/requirements.lock b/requirements.lock index 826d2bc..de9aff9 100644 --- a/requirements.lock +++ b/requirements.lock @@ -43,7 +43,7 @@ httpcore==1.0.9 httpx==0.28.1 # via brand-dev # via httpx-aiohttp -httpx-aiohttp==0.1.8 +httpx-aiohttp==0.1.9 # via brand-dev idna==3.4 # via anyio From 3bde1c660f6661e58b194e97e45d5cd5a0420220 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:16:57 +0000 Subject: [PATCH 114/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6a197be..3741b31 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.17.0" + ".": "1.17.1" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d25c3b3..decdb75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.17.0" +version = "1.17.1" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 77ec14c..c43840e 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.17.0" # x-release-please-version +__version__ = "1.17.1" # x-release-please-version From ad34b9d2fca7c619829e89329c82f3ab7093af5e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 23:42:20 +0000 Subject: [PATCH 115/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 8f07be2..bc6f91e 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-64dee37a21a809941330b048765b0eff4805b5c6959bb17f141da70e61ecb88d.yml -openapi_spec_hash: d0fc0e51311de52c3aaa5c411db0f92b +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-b4634ce20698b51bceba24fe685badcd7e1aeb470d3562df8ad2240e1f771a83.yml +openapi_spec_hash: 505b9bfbc33f2330a2adea1a6f534e5f config_hash: 4e76a07aea49753a61313dcd8c10fb0f From bb8ce5f2746aa5a7fe597df68d5de580e92c9678 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 23:44:20 +0000 Subject: [PATCH 116/176] feat(api): manual updates --- .stats.yml | 4 +- api.md | 4 + src/brand/dev/resources/brand.py | 1222 +++++++++++++---- src/brand/dev/types/__init__.py | 4 + .../types/brand_retrieve_by_name_params.py | 87 ++ .../types/brand_retrieve_by_name_response.py | 481 +++++++ .../types/brand_retrieve_by_ticker_params.py | 163 +++ .../brand_retrieve_by_ticker_response.py | 481 +++++++ tests/api_resources/test_brand.py | 184 +++ 9 files changed, 2339 insertions(+), 291 deletions(-) create mode 100644 src/brand/dev/types/brand_retrieve_by_name_params.py create mode 100644 src/brand/dev/types/brand_retrieve_by_name_response.py create mode 100644 src/brand/dev/types/brand_retrieve_by_ticker_params.py create mode 100644 src/brand/dev/types/brand_retrieve_by_ticker_response.py diff --git a/.stats.yml b/.stats.yml index bc6f91e..1264983 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 8 +configured_endpoints: 10 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-b4634ce20698b51bceba24fe685badcd7e1aeb470d3562df8ad2240e1f771a83.yml openapi_spec_hash: 505b9bfbc33f2330a2adea1a6f534e5f -config_hash: 4e76a07aea49753a61313dcd8c10fb0f +config_hash: a1303564edd6276a63d584a02b2238b2 diff --git a/api.md b/api.md index 4c1102e..99766f8 100644 --- a/api.md +++ b/api.md @@ -8,6 +8,8 @@ from brand.dev.types import ( BrandAIQueryResponse, BrandIdentifyFromTransactionResponse, BrandPrefetchResponse, + BrandRetrieveByNameResponse, + BrandRetrieveByTickerResponse, BrandRetrieveNaicsResponse, BrandRetrieveSimplifiedResponse, BrandScreenshotResponse, @@ -21,6 +23,8 @@ Methods: - client.brand.ai_query(\*\*params) -> BrandAIQueryResponse - client.brand.identify_from_transaction(\*\*params) -> BrandIdentifyFromTransactionResponse - client.brand.prefetch(\*\*params) -> BrandPrefetchResponse +- client.brand.retrieve_by_name(\*\*params) -> BrandRetrieveByNameResponse +- client.brand.retrieve_by_ticker(\*\*params) -> BrandRetrieveByTickerResponse - client.brand.retrieve_naics(\*\*params) -> BrandRetrieveNaicsResponse - client.brand.retrieve_simplified(\*\*params) -> BrandRetrieveSimplifiedResponse - client.brand.screenshot(\*\*params) -> BrandScreenshotResponse diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index f306d27..573eb53 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -14,6 +14,8 @@ brand_screenshot_params, brand_styleguide_params, brand_retrieve_naics_params, + brand_retrieve_by_name_params, + brand_retrieve_by_ticker_params, brand_retrieve_simplified_params, brand_identify_from_transaction_params, ) @@ -34,6 +36,8 @@ from ..types.brand_screenshot_response import BrandScreenshotResponse from ..types.brand_styleguide_response import BrandStyleguideResponse from ..types.brand_retrieve_naics_response import BrandRetrieveNaicsResponse +from ..types.brand_retrieve_by_name_response import BrandRetrieveByNameResponse +from ..types.brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse from ..types.brand_retrieve_simplified_response import BrandRetrieveSimplifiedResponse from ..types.brand_identify_from_transaction_response import BrandIdentifyFromTransactionResponse @@ -419,10 +423,696 @@ def prefetch( cast_to=BrandPrefetchResponse, ) + def retrieve_by_name( + self, + *, + name: str, + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + | Omit = omit, + max_speed: bool | Omit = omit, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandRetrieveByNameResponse: + """Retrieve brand information using a company name. + + This endpoint searches for the + company by name and returns its brand data. + + Args: + name: Company name to retrieve brand data for (e.g., 'Apple Inc', 'Microsoft + Corporation'). Must be 3-30 characters. + + force_language: Optional parameter to force the language of the retrieved brand data. + + max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, + the API will skip time-consuming operations for faster response at the cost of + less comprehensive data. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/retrieve-by-name", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "name": name, + "force_language": force_language, + "max_speed": max_speed, + "timeout_ms": timeout_ms, + }, + brand_retrieve_by_name_params.BrandRetrieveByNameParams, + ), + ), + cast_to=BrandRetrieveByNameResponse, + ) + + def retrieve_by_ticker( + self, + *, + ticker: str, + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + | Omit = omit, + max_speed: bool | Omit = omit, + ticker_exchange: Literal[ + "AMEX", + "AMS", + "AQS", + "ASX", + "ATH", + "BER", + "BME", + "BRU", + "BSE", + "BUD", + "BUE", + "BVC", + "CBOE", + "CNQ", + "CPH", + "DFM", + "DOH", + "DUB", + "DUS", + "DXE", + "EGX", + "FSX", + "HAM", + "HEL", + "HKSE", + "HOSE", + "ICE", + "IOB", + "IST", + "JKT", + "JNB", + "JPX", + "KLS", + "KOE", + "KSC", + "KUW", + "LIS", + "LSE", + "MCX", + "MEX", + "MIL", + "MUN", + "NASDAQ", + "NEO", + "NSE", + "NYSE", + "NZE", + "OSL", + "OTC", + "PAR", + "PNK", + "PRA", + "RIS", + "SAO", + "SAU", + "SES", + "SET", + "SGO", + "SHH", + "SHZ", + "SIX", + "STO", + "STU", + "TAI", + "TAL", + "TLV", + "TSX", + "TSXV", + "TWO", + "VIE", + "WSE", + "XETRA", + ] + | Omit = omit, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandRetrieveByTickerResponse: + """Retrieve brand information using a stock ticker symbol. + + This endpoint looks up + the company associated with the ticker and returns its brand data. + + Args: + ticker: Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). + Must be 1-15 characters, letters/numbers/dots only. + + force_language: Optional parameter to force the language of the retrieved brand data. + + max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, + the API will skip time-consuming operations for faster response at the cost of + less comprehensive data. + + ticker_exchange: Optional stock exchange for the ticker. Defaults to NASDAQ if not specified. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/retrieve-by-ticker", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "ticker": ticker, + "force_language": force_language, + "max_speed": max_speed, + "ticker_exchange": ticker_exchange, + "timeout_ms": timeout_ms, + }, + brand_retrieve_by_ticker_params.BrandRetrieveByTickerParams, + ), + ), + cast_to=BrandRetrieveByTickerResponse, + ) + def retrieve_naics( self, *, - input: str, + input: str, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandRetrieveNaicsResponse: + """ + Endpoint to classify any brand into a 2022 NAICS code. + + Args: + input: Brand domain or title to retrieve NAICS code for. If a valid domain is provided + in `input`, it will be used for classification, otherwise, we will search for + the brand using the provided title. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/naics", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "input": input, + "timeout_ms": timeout_ms, + }, + brand_retrieve_naics_params.BrandRetrieveNaicsParams, + ), + ), + cast_to=BrandRetrieveNaicsResponse, + ) + + def retrieve_simplified( + self, + *, + domain: str, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandRetrieveSimplifiedResponse: + """ + Returns a simplified version of brand data containing only essential + information: domain, title, colors, logos, and backdrops. This endpoint is + optimized for faster responses and reduced data transfer. + + Args: + domain: Domain name to retrieve simplified brand data for + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/retrieve-simplified", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "domain": domain, + "timeout_ms": timeout_ms, + }, + brand_retrieve_simplified_params.BrandRetrieveSimplifiedParams, + ), + ), + cast_to=BrandRetrieveSimplifiedResponse, + ) + + def screenshot( + self, + *, + domain: str, + full_screenshot: Literal["true", "false"] | Omit = omit, + page: Literal["login", "signup", "blog", "careers", "pricing", "terms", "privacy", "contact"] | Omit = omit, + prioritize: Literal["speed", "quality"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandScreenshotResponse: + """Beta feature: Capture a screenshot of a website. + + Supports both viewport + (standard browser view) and full-page screenshots. Can also screenshot specific + page types (login, pricing, etc.) by using heuristics to find the appropriate + URL. Returns a URL to the uploaded screenshot image hosted on our CDN. + + Args: + domain: Domain name to take screenshot of (e.g., 'example.com', 'google.com'). The + domain will be automatically normalized and validated. + + full_screenshot: Optional parameter to determine screenshot type. If 'true', takes a full page + screenshot capturing all content. If 'false' or not provided, takes a viewport + screenshot (standard browser view). + + page: Optional parameter to specify which page type to screenshot. If provided, the + system will scrape the domain's links and use heuristics to find the most + appropriate URL for the specified page type (30 supported languages). If not + provided, screenshots the main domain landing page. + + prioritize: Optional parameter to prioritize screenshot capture. If 'speed', optimizes for + faster capture with basic quality. If 'quality', optimizes for higher quality + with longer wait times. Defaults to 'quality' if not provided. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/screenshot", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "domain": domain, + "full_screenshot": full_screenshot, + "page": page, + "prioritize": prioritize, + }, + brand_screenshot_params.BrandScreenshotParams, + ), + ), + cast_to=BrandScreenshotResponse, + ) + + def styleguide( + self, + *, + domain: str, + prioritize: Literal["speed", "quality"] | Omit = omit, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandStyleguideResponse: + """ + Beta feature: Automatically extract comprehensive design system information from + a brand's website including colors, typography, spacing, shadows, and UI + components. + + Args: + domain: Domain name to extract styleguide from (e.g., 'example.com', 'google.com'). The + domain will be automatically normalized and validated. + + prioritize: Optional parameter to prioritize screenshot capture for styleguide extraction. + If 'speed', optimizes for faster capture with basic quality. If 'quality', + optimizes for higher quality with longer wait times. Defaults to 'quality' if + not provided. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/styleguide", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "domain": domain, + "prioritize": prioritize, + "timeout_ms": timeout_ms, + }, + brand_styleguide_params.BrandStyleguideParams, + ), + ), + cast_to=BrandStyleguideResponse, + ) + + +class AsyncBrandResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncBrandResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/brand-dot-dev/python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncBrandResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncBrandResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/brand-dot-dev/python-sdk#with_streaming_response + """ + return AsyncBrandResourceWithStreamingResponse(self) + + async def retrieve( + self, + *, + domain: str | Omit = omit, + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + | Omit = omit, + max_speed: bool | Omit = omit, + name: str | Omit = omit, + ticker: str | Omit = omit, + ticker_exchange: Literal[ + "AMEX", + "AMS", + "AQS", + "ASX", + "ATH", + "BER", + "BME", + "BRU", + "BSE", + "BUD", + "BUE", + "BVC", + "CBOE", + "CNQ", + "CPH", + "DFM", + "DOH", + "DUB", + "DUS", + "DXE", + "EGX", + "FSX", + "HAM", + "HEL", + "HKSE", + "HOSE", + "ICE", + "IOB", + "IST", + "JKT", + "JNB", + "JPX", + "KLS", + "KOE", + "KSC", + "KUW", + "LIS", + "LSE", + "MCX", + "MEX", + "MIL", + "MUN", + "NASDAQ", + "NEO", + "NSE", + "NYSE", + "NZE", + "OSL", + "OTC", + "PAR", + "PNK", + "PRA", + "RIS", + "SAO", + "SAU", + "SES", + "SET", + "SGO", + "SHH", + "SHZ", + "SIX", + "STO", + "STU", + "TAI", + "TAL", + "TLV", + "TSX", + "TSXV", + "TWO", + "VIE", + "WSE", + "XETRA", + ] + | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -430,14 +1120,32 @@ def retrieve_naics( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BrandRetrieveNaicsResponse: + ) -> BrandRetrieveResponse: """ - Endpoint to classify any brand into a 2022 NAICS code. + Retrieve brand information using one of three methods: domain name, company + name, or stock ticker symbol. Exactly one of these parameters must be provided. Args: - input: Brand domain or title to retrieve NAICS code for. If a valid domain is provided - in `input`, it will be used for classification, otherwise, we will search for - the brand using the provided title. + domain: Domain name to retrieve brand data for (e.g., 'example.com', 'google.com'). + Cannot be used with name or ticker parameters. + + force_language: Optional parameter to force the language of the retrieved brand data. Works with + all three lookup methods. + + max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, + the API will skip time-consuming operations for faster response at the cost of + less comprehensive data. Works with all three lookup methods. + + name: Company name to retrieve brand data for (e.g., 'Apple Inc', 'Microsoft + Corporation'). Must be 3-30 characters. Cannot be used with domain or ticker + parameters. + + ticker: Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). + Must be 1-15 characters, letters/numbers/dots only. Cannot be used with domain + or name parameters. + + ticker_exchange: Optional stock exchange for the ticker. Only used when ticker parameter is + provided. Defaults to assume ticker is American if not specified. timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed @@ -451,28 +1159,35 @@ def retrieve_naics( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get( - "/brand/naics", + return await self._get( + "/brand/retrieve", options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform( + query=await async_maybe_transform( { - "input": input, + "domain": domain, + "force_language": force_language, + "max_speed": max_speed, + "name": name, + "ticker": ticker, + "ticker_exchange": ticker_exchange, "timeout_ms": timeout_ms, }, - brand_retrieve_naics_params.BrandRetrieveNaicsParams, + brand_retrieve_params.BrandRetrieveParams, ), ), - cast_to=BrandRetrieveNaicsResponse, + cast_to=BrandRetrieveResponse, ) - def retrieve_simplified( + async def ai_query( self, *, + data_to_extract: Iterable[brand_ai_query_params.DataToExtract], domain: str, + specific_pages: brand_ai_query_params.SpecificPages | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -480,14 +1195,67 @@ def retrieve_simplified( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BrandRetrieveSimplifiedResponse: + ) -> BrandAIQueryResponse: + """Beta feature: Use AI to extract specific data points from a brand's website. + + The + AI will crawl the website and extract the requested information based on the + provided data points. + + Args: + data_to_extract: Array of data points to extract from the website + + domain: The domain name to analyze + + specific_pages: Optional object specifying which pages to analyze + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds """ - Returns a simplified version of brand data containing only essential - information: domain, title, colors, logos, and backdrops. This endpoint is - optimized for faster responses and reduced data transfer. + return await self._post( + "/brand/ai/query", + body=await async_maybe_transform( + { + "data_to_extract": data_to_extract, + "domain": domain, + "specific_pages": specific_pages, + "timeout_ms": timeout_ms, + }, + brand_ai_query_params.BrandAIQueryParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrandAIQueryResponse, + ) + + async def identify_from_transaction( + self, + *, + transaction_info: str, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandIdentifyFromTransactionResponse: + """ + Endpoint specially designed for platforms that want to identify transaction data + by the transaction title. Args: - domain: Domain name to retrieve simplified brand data for + transaction_info: Transaction information to identify the brand timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed @@ -501,61 +1269,48 @@ def retrieve_simplified( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get( - "/brand/retrieve-simplified", + return await self._get( + "/brand/transaction_identifier", options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform( + query=await async_maybe_transform( { - "domain": domain, + "transaction_info": transaction_info, "timeout_ms": timeout_ms, }, - brand_retrieve_simplified_params.BrandRetrieveSimplifiedParams, + brand_identify_from_transaction_params.BrandIdentifyFromTransactionParams, ), ), - cast_to=BrandRetrieveSimplifiedResponse, + cast_to=BrandIdentifyFromTransactionResponse, ) - def screenshot( + async def prefetch( self, *, domain: str, - full_screenshot: Literal["true", "false"] | Omit = omit, - page: Literal["login", "signup", "blog", "careers", "pricing", "terms", "privacy", "contact"] | Omit = omit, - prioritize: Literal["speed", "quality"] | Omit = omit, + timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BrandScreenshotResponse: - """Beta feature: Capture a screenshot of a website. - - Supports both viewport - (standard browser view) and full-page screenshots. Can also screenshot specific - page types (login, pricing, etc.) by using heuristics to find the appropriate - URL. Returns a URL to the uploaded screenshot image hosted on our CDN. + ) -> BrandPrefetchResponse: + """ + Signal that you may fetch brand data for a particular domain soon to improve + latency. This endpoint does not charge credits and is available for paid + customers to optimize future requests. [You must be on a paid plan to use this + endpoint] Args: - domain: Domain name to take screenshot of (e.g., 'example.com', 'google.com'). The - domain will be automatically normalized and validated. - - full_screenshot: Optional parameter to determine screenshot type. If 'true', takes a full page - screenshot capturing all content. If 'false' or not provided, takes a viewport - screenshot (standard browser view). - - page: Optional parameter to specify which page type to screenshot. If provided, the - system will scrape the domain's links and use heuristics to find the most - appropriate URL for the specified page type (30 supported languages). If not - provided, screenshots the main domain landing page. + domain: Domain name to prefetch brand data for - prioritize: Optional parameter to prioritize screenshot capture. If 'speed', optimizes for - faster capture with basic quality. If 'quality', optimizes for higher quality - with longer wait times. Defaults to 'quality' if not provided. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). extra_headers: Send extra headers @@ -565,31 +1320,81 @@ def screenshot( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get( - "/brand/screenshot", + return await self._post( + "/brand/prefetch", + body=await async_maybe_transform( + { + "domain": domain, + "timeout_ms": timeout_ms, + }, + brand_prefetch_params.BrandPrefetchParams, + ), options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform( - { - "domain": domain, - "full_screenshot": full_screenshot, - "page": page, - "prioritize": prioritize, - }, - brand_screenshot_params.BrandScreenshotParams, - ), + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=BrandScreenshotResponse, + cast_to=BrandPrefetchResponse, ) - def styleguide( + async def retrieve_by_name( self, *, - domain: str, - prioritize: Literal["speed", "quality"] | Omit = omit, + name: str, + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + | Omit = omit, + max_speed: bool | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -597,20 +1402,21 @@ def styleguide( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BrandStyleguideResponse: - """ - Beta feature: Automatically extract comprehensive design system information from - a brand's website including colors, typography, spacing, shadows, and UI - components. + ) -> BrandRetrieveByNameResponse: + """Retrieve brand information using a company name. + + This endpoint searches for the + company by name and returns its brand data. Args: - domain: Domain name to extract styleguide from (e.g., 'example.com', 'google.com'). The - domain will be automatically normalized and validated. + name: Company name to retrieve brand data for (e.g., 'Apple Inc', 'Microsoft + Corporation'). Must be 3-30 characters. - prioritize: Optional parameter to prioritize screenshot capture for styleguide extraction. - If 'speed', optimizes for faster capture with basic quality. If 'quality', - optimizes for higher quality with longer wait times. Defaults to 'quality' if - not provided. + force_language: Optional parameter to force the language of the retrieved brand data. + + max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, + the API will skip time-consuming operations for faster response at the cost of + less comprehensive data. timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed @@ -624,50 +1430,30 @@ def styleguide( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get( - "/brand/styleguide", + return await self._get( + "/brand/retrieve-by-name", options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform( + query=await async_maybe_transform( { - "domain": domain, - "prioritize": prioritize, + "name": name, + "force_language": force_language, + "max_speed": max_speed, "timeout_ms": timeout_ms, }, - brand_styleguide_params.BrandStyleguideParams, + brand_retrieve_by_name_params.BrandRetrieveByNameParams, ), ), - cast_to=BrandStyleguideResponse, + cast_to=BrandRetrieveByNameResponse, ) - -class AsyncBrandResource(AsyncAPIResource): - @cached_property - def with_raw_response(self) -> AsyncBrandResourceWithRawResponse: - """ - This property can be used as a prefix for any HTTP method call to return - the raw response object instead of the parsed content. - - For more information, see https://www.github.com/brand-dot-dev/python-sdk#accessing-raw-response-data-eg-headers - """ - return AsyncBrandResourceWithRawResponse(self) - - @cached_property - def with_streaming_response(self) -> AsyncBrandResourceWithStreamingResponse: - """ - An alternative to `.with_raw_response` that doesn't eagerly read the response body. - - For more information, see https://www.github.com/brand-dot-dev/python-sdk#with_streaming_response - """ - return AsyncBrandResourceWithStreamingResponse(self) - - async def retrieve( + async def retrieve_by_ticker( self, *, - domain: str | Omit = omit, + ticker: str, force_language: Literal[ "albanian", "arabic", @@ -724,8 +1510,6 @@ async def retrieve( ] | Omit = omit, max_speed: bool | Omit = omit, - name: str | Omit = omit, - ticker: str | Omit = omit, ticker_exchange: Literal[ "AMEX", "AMS", @@ -808,32 +1592,23 @@ async def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BrandRetrieveResponse: - """ - Retrieve brand information using one of three methods: domain name, company - name, or stock ticker symbol. Exactly one of these parameters must be provided. + ) -> BrandRetrieveByTickerResponse: + """Retrieve brand information using a stock ticker symbol. + + This endpoint looks up + the company associated with the ticker and returns its brand data. Args: - domain: Domain name to retrieve brand data for (e.g., 'example.com', 'google.com'). - Cannot be used with name or ticker parameters. + ticker: Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). + Must be 1-15 characters, letters/numbers/dots only. - force_language: Optional parameter to force the language of the retrieved brand data. Works with - all three lookup methods. + force_language: Optional parameter to force the language of the retrieved brand data. max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, the API will skip time-consuming operations for faster response at the cost of - less comprehensive data. Works with all three lookup methods. - - name: Company name to retrieve brand data for (e.g., 'Apple Inc', 'Microsoft - Corporation'). Must be 3-30 characters. Cannot be used with domain or ticker - parameters. - - ticker: Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). - Must be 1-15 characters, letters/numbers/dots only. Cannot be used with domain - or name parameters. + less comprehensive data. - ticker_exchange: Optional stock exchange for the ticker. Only used when ticker parameter is - provided. Defaults to assume ticker is American if not specified. + ticker_exchange: Optional stock exchange for the ticker. Defaults to NASDAQ if not specified. timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed @@ -848,7 +1623,7 @@ async def retrieve( timeout: Override the client-level default timeout for this request, in seconds """ return await self._get( - "/brand/retrieve", + "/brand/retrieve-by-ticker", options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -856,171 +1631,16 @@ async def retrieve( timeout=timeout, query=await async_maybe_transform( { - "domain": domain, + "ticker": ticker, "force_language": force_language, "max_speed": max_speed, - "name": name, - "ticker": ticker, "ticker_exchange": ticker_exchange, "timeout_ms": timeout_ms, }, - brand_retrieve_params.BrandRetrieveParams, - ), - ), - cast_to=BrandRetrieveResponse, - ) - - async def ai_query( - self, - *, - data_to_extract: Iterable[brand_ai_query_params.DataToExtract], - domain: str, - specific_pages: brand_ai_query_params.SpecificPages | Omit = omit, - timeout_ms: int | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BrandAIQueryResponse: - """Beta feature: Use AI to extract specific data points from a brand's website. - - The - AI will crawl the website and extract the requested information based on the - provided data points. - - Args: - data_to_extract: Array of data points to extract from the website - - domain: The domain name to analyze - - specific_pages: Optional object specifying which pages to analyze - - timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer - than this value, it will be aborted with a 408 status code. Maximum allowed - value is 300000ms (5 minutes). - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._post( - "/brand/ai/query", - body=await async_maybe_transform( - { - "data_to_extract": data_to_extract, - "domain": domain, - "specific_pages": specific_pages, - "timeout_ms": timeout_ms, - }, - brand_ai_query_params.BrandAIQueryParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=BrandAIQueryResponse, - ) - - async def identify_from_transaction( - self, - *, - transaction_info: str, - timeout_ms: int | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BrandIdentifyFromTransactionResponse: - """ - Endpoint specially designed for platforms that want to identify transaction data - by the transaction title. - - Args: - transaction_info: Transaction information to identify the brand - - timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer - than this value, it will be aborted with a 408 status code. Maximum allowed - value is 300000ms (5 minutes). - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._get( - "/brand/transaction_identifier", - options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=await async_maybe_transform( - { - "transaction_info": transaction_info, - "timeout_ms": timeout_ms, - }, - brand_identify_from_transaction_params.BrandIdentifyFromTransactionParams, + brand_retrieve_by_ticker_params.BrandRetrieveByTickerParams, ), ), - cast_to=BrandIdentifyFromTransactionResponse, - ) - - async def prefetch( - self, - *, - domain: str, - timeout_ms: int | Omit = omit, - # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. - # The extra values given here take precedence over values defined on the client or passed to this method. - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BrandPrefetchResponse: - """ - Signal that you may fetch brand data for a particular domain soon to improve - latency. This endpoint does not charge credits and is available for paid - customers to optimize future requests. [You must be on a paid plan to use this - endpoint] - - Args: - domain: Domain name to prefetch brand data for - - timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer - than this value, it will be aborted with a 408 status code. Maximum allowed - value is 300000ms (5 minutes). - - extra_headers: Send extra headers - - extra_query: Add additional query parameters to the request - - extra_body: Add additional JSON properties to the request - - timeout: Override the client-level default timeout for this request, in seconds - """ - return await self._post( - "/brand/prefetch", - body=await async_maybe_transform( - { - "domain": domain, - "timeout_ms": timeout_ms, - }, - brand_prefetch_params.BrandPrefetchParams, - ), - options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout - ), - cast_to=BrandPrefetchResponse, + cast_to=BrandRetrieveByTickerResponse, ) async def retrieve_naics( @@ -1264,6 +1884,12 @@ def __init__(self, brand: BrandResource) -> None: self.prefetch = to_raw_response_wrapper( brand.prefetch, ) + self.retrieve_by_name = to_raw_response_wrapper( + brand.retrieve_by_name, + ) + self.retrieve_by_ticker = to_raw_response_wrapper( + brand.retrieve_by_ticker, + ) self.retrieve_naics = to_raw_response_wrapper( brand.retrieve_naics, ) @@ -1294,6 +1920,12 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.prefetch = async_to_raw_response_wrapper( brand.prefetch, ) + self.retrieve_by_name = async_to_raw_response_wrapper( + brand.retrieve_by_name, + ) + self.retrieve_by_ticker = async_to_raw_response_wrapper( + brand.retrieve_by_ticker, + ) self.retrieve_naics = async_to_raw_response_wrapper( brand.retrieve_naics, ) @@ -1324,6 +1956,12 @@ def __init__(self, brand: BrandResource) -> None: self.prefetch = to_streamed_response_wrapper( brand.prefetch, ) + self.retrieve_by_name = to_streamed_response_wrapper( + brand.retrieve_by_name, + ) + self.retrieve_by_ticker = to_streamed_response_wrapper( + brand.retrieve_by_ticker, + ) self.retrieve_naics = to_streamed_response_wrapper( brand.retrieve_naics, ) @@ -1354,6 +1992,12 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.prefetch = async_to_streamed_response_wrapper( brand.prefetch, ) + self.retrieve_by_name = async_to_streamed_response_wrapper( + brand.retrieve_by_name, + ) + self.retrieve_by_ticker = async_to_streamed_response_wrapper( + brand.retrieve_by_ticker, + ) self.retrieve_naics = async_to_streamed_response_wrapper( brand.retrieve_naics, ) diff --git a/src/brand/dev/types/__init__.py b/src/brand/dev/types/__init__.py index a4dff66..e038cbb 100644 --- a/src/brand/dev/types/__init__.py +++ b/src/brand/dev/types/__init__.py @@ -13,8 +13,12 @@ from .brand_screenshot_response import BrandScreenshotResponse as BrandScreenshotResponse from .brand_styleguide_response import BrandStyleguideResponse as BrandStyleguideResponse from .brand_retrieve_naics_params import BrandRetrieveNaicsParams as BrandRetrieveNaicsParams +from .brand_retrieve_by_name_params import BrandRetrieveByNameParams as BrandRetrieveByNameParams from .brand_retrieve_naics_response import BrandRetrieveNaicsResponse as BrandRetrieveNaicsResponse +from .brand_retrieve_by_name_response import BrandRetrieveByNameResponse as BrandRetrieveByNameResponse +from .brand_retrieve_by_ticker_params import BrandRetrieveByTickerParams as BrandRetrieveByTickerParams from .brand_retrieve_simplified_params import BrandRetrieveSimplifiedParams as BrandRetrieveSimplifiedParams +from .brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse as BrandRetrieveByTickerResponse from .brand_retrieve_simplified_response import BrandRetrieveSimplifiedResponse as BrandRetrieveSimplifiedResponse from .brand_identify_from_transaction_params import ( BrandIdentifyFromTransactionParams as BrandIdentifyFromTransactionParams, diff --git a/src/brand/dev/types/brand_retrieve_by_name_params.py b/src/brand/dev/types/brand_retrieve_by_name_params.py new file mode 100644 index 0000000..f8a7f76 --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_by_name_params.py @@ -0,0 +1,87 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["BrandRetrieveByNameParams"] + + +class BrandRetrieveByNameParams(TypedDict, total=False): + name: Required[str] + """ + Company name to retrieve brand data for (e.g., 'Apple Inc', 'Microsoft + Corporation'). Must be 3-30 characters. + """ + + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + """Optional parameter to force the language of the retrieved brand data.""" + + max_speed: Annotated[bool, PropertyInfo(alias="maxSpeed")] + """Optional parameter to optimize the API call for maximum speed. + + When set to true, the API will skip time-consuming operations for faster + response at the cost of less comprehensive data. + """ + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_retrieve_by_name_response.py b/src/brand/dev/types/brand_retrieve_by_name_response.py new file mode 100644 index 0000000..db66fac --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_by_name_response.py @@ -0,0 +1,481 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = [ + "BrandRetrieveByNameResponse", + "Brand", + "BrandAddress", + "BrandBackdrop", + "BrandBackdropColor", + "BrandBackdropResolution", + "BrandColor", + "BrandIndustries", + "BrandIndustriesEic", + "BrandLinks", + "BrandLogo", + "BrandLogoColor", + "BrandLogoResolution", + "BrandSocial", + "BrandStock", +] + + +class BrandAddress(BaseModel): + city: Optional[str] = None + """City name""" + + country: Optional[str] = None + """Country name""" + + country_code: Optional[str] = None + """Country code""" + + postal_code: Optional[str] = None + """Postal or ZIP code""" + + state_code: Optional[str] = None + """State or province code""" + + state_province: Optional[str] = None + """State or province name""" + + street: Optional[str] = None + """Street address""" + + +class BrandBackdropColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandBackdropResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandBackdrop(BaseModel): + colors: Optional[List[BrandBackdropColor]] = None + """Array of colors in the backdrop image""" + + resolution: Optional[BrandBackdropResolution] = None + """Resolution of the backdrop image""" + + url: Optional[str] = None + """URL of the backdrop image""" + + +class BrandColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandIndustriesEic(BaseModel): + industry: Literal[ + "Aerospace & Defense", + "Technology", + "Finance", + "Healthcare", + "Retail & E-commerce", + "Entertainment", + "Education", + "Government & Nonprofit", + "Industrial & Energy", + "Automotive & Transportation", + "Lifestyle & Leisure", + "Luxury & Fashion", + "News & Media", + "Sports", + "Real Estate & PropTech", + "Legal & Compliance", + "Telecommunications", + "Agriculture & Food", + "Professional Services & Agencies", + "Chemicals & Materials", + "Logistics & Supply Chain", + "Hospitality & Tourism", + "Construction & Built Environment", + "Consumer Packaged Goods (CPG)", + ] + """Industry classification enum""" + + subindustry: Literal[ + "Defense Systems & Military Hardware", + "Aerospace Manufacturing", + "Avionics & Navigation Technology", + "Subsea & Naval Defense Systems", + "Space & Satellite Technology", + "Defense IT & Systems Integration", + "Software (B2B)", + "Software (B2C)", + "Cloud Infrastructure & DevOps", + "Cybersecurity", + "Artificial Intelligence & Machine Learning", + "Data Infrastructure & Analytics", + "Hardware & Semiconductors", + "Fintech Infrastructure", + "eCommerce & Marketplace Platforms", + "Developer Tools & APIs", + "Web3 & Blockchain", + "XR & Spatial Computing", + "Banking & Lending", + "Investment Management & WealthTech", + "Insurance & InsurTech", + "Payments & Money Movement", + "Accounting, Tax & Financial Planning Tools", + "Capital Markets & Trading Platforms", + "Financial Infrastructure & APIs", + "Credit Scoring & Risk Management", + "Cryptocurrency & Digital Assets", + "BNPL & Alternative Financing", + "Healthcare Providers & Services", + "Pharmaceuticals & Drug Development", + "Medical Devices & Diagnostics", + "Biotechnology & Genomics", + "Digital Health & Telemedicine", + "Health Insurance & Benefits Tech", + "Clinical Trials & Research Platforms", + "Mental Health & Wellness", + "Healthcare IT & EHR Systems", + "Consumer Health & Wellness Products", + "Online Marketplaces", + "Direct-to-Consumer (DTC) Brands", + "Retail Tech & Point-of-Sale Systems", + "Omnichannel & In-Store Retail", + "E-commerce Enablement & Infrastructure", + "Subscription & Membership Commerce", + "Social Commerce & Influencer Platforms", + "Fashion & Apparel Retail", + "Food, Beverage & Grocery E-commerce", + "Streaming Platforms (Video, Music, Audio)", + "Gaming & Interactive Entertainment", + "Creator Economy & Influencer Platforms", + "Advertising, Adtech & Media Buying", + "Film, TV & Production Studios", + "Events, Venues & Live Entertainment", + "Virtual Worlds & Metaverse Experiences", + "K-12 Education Platforms & Tools", + "Higher Education & University Tech", + "Online Learning & MOOCs", + "Test Prep & Certification", + "Corporate Training & Upskilling", + "Tutoring & Supplemental Learning", + "Education Management Systems (LMS/SIS)", + "Language Learning", + "Creator-Led & Cohort-Based Courses", + "Special Education & Accessibility Tools", + "Government Technology & Digital Services", + "Civic Engagement & Policy Platforms", + "International Development & Humanitarian Aid", + "Philanthropy & Grantmaking", + "Nonprofit Operations & Fundraising Tools", + "Public Health & Social Services", + "Education & Youth Development Programs", + "Environmental & Climate Action Organizations", + "Legal Aid & Social Justice Advocacy", + "Municipal & Infrastructure Services", + "Manufacturing & Industrial Automation", + "Energy Production (Oil, Gas, Nuclear)", + "Renewable Energy & Cleantech", + "Utilities & Grid Infrastructure", + "Industrial IoT & Monitoring Systems", + "Construction & Heavy Equipment", + "Mining & Natural Resources", + "Environmental Engineering & Sustainability", + "Energy Storage & Battery Technology", + "Automotive OEMs & Vehicle Manufacturing", + "Electric Vehicles (EVs) & Charging Infrastructure", + "Mobility-as-a-Service (MaaS)", + "Fleet Management", + "Public Transit & Urban Mobility", + "Autonomous Vehicles & ADAS", + "Aftermarket Parts & Services", + "Telematics & Vehicle Connectivity", + "Aviation & Aerospace Transport", + "Maritime Shipping", + "Fitness & Wellness", + "Beauty & Personal Care", + "Home & Living", + "Dating & Relationships", + "Hobbies, Crafts & DIY", + "Outdoor & Recreational Gear", + "Events, Experiences & Ticketing Platforms", + "Designer & Luxury Apparel", + "Accessories, Jewelry & Watches", + "Footwear & Leather Goods", + "Beauty, Fragrance & Skincare", + "Fashion Marketplaces & Retail Platforms", + "Sustainable & Ethical Fashion", + "Resale, Vintage & Circular Fashion", + "Fashion Tech & Virtual Try-Ons", + "Streetwear & Emerging Luxury", + "Couture & Made-to-Measure", + "News Publishing & Journalism", + "Digital Media & Content Platforms", + "Broadcasting (TV & Radio)", + "Podcasting & Audio Media", + "News Aggregators & Curation Tools", + "Independent & Creator-Led Media", + "Newsletters & Substack-Style Platforms", + "Political & Investigative Media", + "Trade & Niche Publications", + "Media Monitoring & Analytics", + "Professional Teams & Leagues", + "Sports Media & Broadcasting", + "Sports Betting & Fantasy Sports", + "Fitness & Athletic Training Platforms", + "Sportswear & Equipment", + "Esports & Competitive Gaming", + "Sports Venues & Event Management", + "Athlete Management & Talent Agencies", + "Sports Tech & Performance Analytics", + "Youth, Amateur & Collegiate Sports", + "Real Estate Marketplaces", + "Property Management Software", + "Rental Platforms", + "Mortgage & Lending Tech", + "Real Estate Investment Platforms", + "Law Firms & Legal Services", + "Legal Tech & Automation", + "Regulatory Compliance", + "E-Discovery & Litigation Tools", + "Contract Management", + "Governance, Risk & Compliance (GRC)", + "IP & Trademark Management", + "Legal Research & Intelligence", + "Compliance Training & Certification", + "Whistleblower & Ethics Reporting", + "Mobile & Wireless Networks (3G/4G/5G)", + "Broadband & Fiber Internet", + "Satellite & Space-Based Communications", + "Network Equipment & Infrastructure", + "Telecom Billing & OSS/BSS Systems", + "VoIP & Unified Communications", + "Internet Service Providers (ISPs)", + "Edge Computing & Network Virtualization", + "IoT Connectivity Platforms", + "Precision Agriculture & AgTech", + "Crop & Livestock Production", + "Food & Beverage Manufacturing & Processing", + "Food Distribution", + "Restaurants & Food Service", + "Agricultural Inputs & Equipment", + "Sustainable & Regenerative Agriculture", + "Seafood & Aquaculture", + "Management Consulting", + "Marketing & Advertising Agencies", + "Design, Branding & Creative Studios", + "IT Services & Managed Services", + "Staffing, Recruiting & Talent", + "Accounting & Tax Firms", + "Public Relations & Communications", + "Business Process Outsourcing (BPO)", + "Professional Training & Coaching", + "Specialty Chemicals", + "Commodity & Petrochemicals", + "Polymers, Plastics & Rubber", + "Coatings, Adhesives & Sealants", + "Industrial Gases", + "Advanced Materials & Composites", + "Battery Materials & Energy Storage", + "Electronic Materials & Semiconductor Chemicals", + "Agrochemicals & Fertilizers", + "Freight & Transportation Tech", + "Last-Mile Delivery", + "Warehouse Automation", + "Supply Chain Visibility Platforms", + "Logistics Marketplaces", + "Shipping & Freight Forwarding", + "Cold Chain Logistics", + "Reverse Logistics & Returns", + "Cross-Border Trade Tech", + "Transportation Management Systems (TMS)", + "Hotels & Accommodation", + "Vacation Rentals & Short-Term Stays", + "Restaurant Tech & Management", + "Travel Booking Platforms", + "Tourism Experiences & Activities", + "Cruise Lines & Marine Tourism", + "Hospitality Management Systems", + "Event & Venue Management", + "Corporate Travel Management", + "Travel Insurance & Protection", + "Construction Management Software", + "BIM/CAD & Design Tools", + "Construction Marketplaces", + "Equipment Rental & Management", + "Building Materials & Procurement", + "Construction Workforce Management", + "Project Estimation & Bidding", + "Modular & Prefab Construction", + "Construction Safety & Compliance", + "Smart Building Technology", + "Food & Beverage CPG", + "Home & Personal Care CPG", + "CPG Analytics & Insights", + "Direct-to-Consumer CPG Brands", + "CPG Supply Chain & Distribution", + "Private Label Manufacturing", + "CPG Retail Intelligence", + "Sustainable CPG & Packaging", + "Beauty & Cosmetics CPG", + "Health & Wellness CPG", + ] + """Subindustry classification enum""" + + +class BrandIndustries(BaseModel): + eic: Optional[List[BrandIndustriesEic]] = None + """Easy Industry Classification - array of industry and subindustry pairs""" + + +class BrandLinks(BaseModel): + blog: Optional[str] = None + """URL to the brand's blog or news page""" + + careers: Optional[str] = None + """URL to the brand's careers or job opportunities page""" + + contact: Optional[str] = None + """URL to the brand's contact or contact us page""" + + pricing: Optional[str] = None + """URL to the brand's pricing or plans page""" + + privacy: Optional[str] = None + """URL to the brand's privacy policy page""" + + terms: Optional[str] = None + """URL to the brand's terms of service or terms and conditions page""" + + +class BrandLogoColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandLogoResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandLogo(BaseModel): + colors: Optional[List[BrandLogoColor]] = None + """Array of colors in the logo""" + + mode: Optional[Literal["light", "dark", "has_opaque_background"]] = None + """ + Indicates when this logo is best used: 'light' = best for light mode, 'dark' = + best for dark mode, 'has_opaque_background' = can be used for either as image + has its own background + """ + + resolution: Optional[BrandLogoResolution] = None + """Resolution of the logo image""" + + type: Optional[Literal["icon", "logo"]] = None + """Type of the logo based on resolution (e.g., 'icon', 'logo')""" + + url: Optional[str] = None + """CDN hosted url of the logo (ready for display)""" + + +class BrandSocial(BaseModel): + type: Optional[str] = None + """Type of social media, e.g., 'facebook', 'twitter'""" + + url: Optional[str] = None + """URL of the social media page""" + + +class BrandStock(BaseModel): + exchange: Optional[str] = None + """Stock exchange name""" + + ticker: Optional[str] = None + """Stock ticker symbol""" + + +class Brand(BaseModel): + address: Optional[BrandAddress] = None + """Physical address of the brand""" + + backdrops: Optional[List[BrandBackdrop]] = None + """An array of backdrop images for the brand""" + + colors: Optional[List[BrandColor]] = None + """An array of brand colors""" + + description: Optional[str] = None + """A brief description of the brand""" + + domain: Optional[str] = None + """The domain name of the brand""" + + email: Optional[str] = None + """Company email address""" + + industries: Optional[BrandIndustries] = None + """Industry classification information for the brand""" + + is_nsfw: Optional[bool] = None + """Indicates whether the brand content is not safe for work (NSFW)""" + + links: Optional[BrandLinks] = None + """Important website links for the brand""" + + logos: Optional[List[BrandLogo]] = None + """An array of logos associated with the brand""" + + phone: Optional[str] = None + """Company phone number""" + + slogan: Optional[str] = None + """The brand's slogan""" + + socials: Optional[List[BrandSocial]] = None + """An array of social media links for the brand""" + + stock: Optional[BrandStock] = None + """ + Stock market information for this brand (will be null if not a publicly traded + company) + """ + + title: Optional[str] = None + """The title or name of the brand""" + + +class BrandRetrieveByNameResponse(BaseModel): + brand: Optional[Brand] = None + """Detailed brand information""" + + code: Optional[int] = None + """HTTP status code""" + + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" diff --git a/src/brand/dev/types/brand_retrieve_by_ticker_params.py b/src/brand/dev/types/brand_retrieve_by_ticker_params.py new file mode 100644 index 0000000..4bbdb2f --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_by_ticker_params.py @@ -0,0 +1,163 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["BrandRetrieveByTickerParams"] + + +class BrandRetrieveByTickerParams(TypedDict, total=False): + ticker: Required[str] + """Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). + + Must be 1-15 characters, letters/numbers/dots only. + """ + + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + """Optional parameter to force the language of the retrieved brand data.""" + + max_speed: Annotated[bool, PropertyInfo(alias="maxSpeed")] + """Optional parameter to optimize the API call for maximum speed. + + When set to true, the API will skip time-consuming operations for faster + response at the cost of less comprehensive data. + """ + + ticker_exchange: Literal[ + "AMEX", + "AMS", + "AQS", + "ASX", + "ATH", + "BER", + "BME", + "BRU", + "BSE", + "BUD", + "BUE", + "BVC", + "CBOE", + "CNQ", + "CPH", + "DFM", + "DOH", + "DUB", + "DUS", + "DXE", + "EGX", + "FSX", + "HAM", + "HEL", + "HKSE", + "HOSE", + "ICE", + "IOB", + "IST", + "JKT", + "JNB", + "JPX", + "KLS", + "KOE", + "KSC", + "KUW", + "LIS", + "LSE", + "MCX", + "MEX", + "MIL", + "MUN", + "NASDAQ", + "NEO", + "NSE", + "NYSE", + "NZE", + "OSL", + "OTC", + "PAR", + "PNK", + "PRA", + "RIS", + "SAO", + "SAU", + "SES", + "SET", + "SGO", + "SHH", + "SHZ", + "SIX", + "STO", + "STU", + "TAI", + "TAL", + "TLV", + "TSX", + "TSXV", + "TWO", + "VIE", + "WSE", + "XETRA", + ] + """Optional stock exchange for the ticker. Defaults to NASDAQ if not specified.""" + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_retrieve_by_ticker_response.py b/src/brand/dev/types/brand_retrieve_by_ticker_response.py new file mode 100644 index 0000000..5a04a44 --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_by_ticker_response.py @@ -0,0 +1,481 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = [ + "BrandRetrieveByTickerResponse", + "Brand", + "BrandAddress", + "BrandBackdrop", + "BrandBackdropColor", + "BrandBackdropResolution", + "BrandColor", + "BrandIndustries", + "BrandIndustriesEic", + "BrandLinks", + "BrandLogo", + "BrandLogoColor", + "BrandLogoResolution", + "BrandSocial", + "BrandStock", +] + + +class BrandAddress(BaseModel): + city: Optional[str] = None + """City name""" + + country: Optional[str] = None + """Country name""" + + country_code: Optional[str] = None + """Country code""" + + postal_code: Optional[str] = None + """Postal or ZIP code""" + + state_code: Optional[str] = None + """State or province code""" + + state_province: Optional[str] = None + """State or province name""" + + street: Optional[str] = None + """Street address""" + + +class BrandBackdropColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandBackdropResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandBackdrop(BaseModel): + colors: Optional[List[BrandBackdropColor]] = None + """Array of colors in the backdrop image""" + + resolution: Optional[BrandBackdropResolution] = None + """Resolution of the backdrop image""" + + url: Optional[str] = None + """URL of the backdrop image""" + + +class BrandColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandIndustriesEic(BaseModel): + industry: Literal[ + "Aerospace & Defense", + "Technology", + "Finance", + "Healthcare", + "Retail & E-commerce", + "Entertainment", + "Education", + "Government & Nonprofit", + "Industrial & Energy", + "Automotive & Transportation", + "Lifestyle & Leisure", + "Luxury & Fashion", + "News & Media", + "Sports", + "Real Estate & PropTech", + "Legal & Compliance", + "Telecommunications", + "Agriculture & Food", + "Professional Services & Agencies", + "Chemicals & Materials", + "Logistics & Supply Chain", + "Hospitality & Tourism", + "Construction & Built Environment", + "Consumer Packaged Goods (CPG)", + ] + """Industry classification enum""" + + subindustry: Literal[ + "Defense Systems & Military Hardware", + "Aerospace Manufacturing", + "Avionics & Navigation Technology", + "Subsea & Naval Defense Systems", + "Space & Satellite Technology", + "Defense IT & Systems Integration", + "Software (B2B)", + "Software (B2C)", + "Cloud Infrastructure & DevOps", + "Cybersecurity", + "Artificial Intelligence & Machine Learning", + "Data Infrastructure & Analytics", + "Hardware & Semiconductors", + "Fintech Infrastructure", + "eCommerce & Marketplace Platforms", + "Developer Tools & APIs", + "Web3 & Blockchain", + "XR & Spatial Computing", + "Banking & Lending", + "Investment Management & WealthTech", + "Insurance & InsurTech", + "Payments & Money Movement", + "Accounting, Tax & Financial Planning Tools", + "Capital Markets & Trading Platforms", + "Financial Infrastructure & APIs", + "Credit Scoring & Risk Management", + "Cryptocurrency & Digital Assets", + "BNPL & Alternative Financing", + "Healthcare Providers & Services", + "Pharmaceuticals & Drug Development", + "Medical Devices & Diagnostics", + "Biotechnology & Genomics", + "Digital Health & Telemedicine", + "Health Insurance & Benefits Tech", + "Clinical Trials & Research Platforms", + "Mental Health & Wellness", + "Healthcare IT & EHR Systems", + "Consumer Health & Wellness Products", + "Online Marketplaces", + "Direct-to-Consumer (DTC) Brands", + "Retail Tech & Point-of-Sale Systems", + "Omnichannel & In-Store Retail", + "E-commerce Enablement & Infrastructure", + "Subscription & Membership Commerce", + "Social Commerce & Influencer Platforms", + "Fashion & Apparel Retail", + "Food, Beverage & Grocery E-commerce", + "Streaming Platforms (Video, Music, Audio)", + "Gaming & Interactive Entertainment", + "Creator Economy & Influencer Platforms", + "Advertising, Adtech & Media Buying", + "Film, TV & Production Studios", + "Events, Venues & Live Entertainment", + "Virtual Worlds & Metaverse Experiences", + "K-12 Education Platforms & Tools", + "Higher Education & University Tech", + "Online Learning & MOOCs", + "Test Prep & Certification", + "Corporate Training & Upskilling", + "Tutoring & Supplemental Learning", + "Education Management Systems (LMS/SIS)", + "Language Learning", + "Creator-Led & Cohort-Based Courses", + "Special Education & Accessibility Tools", + "Government Technology & Digital Services", + "Civic Engagement & Policy Platforms", + "International Development & Humanitarian Aid", + "Philanthropy & Grantmaking", + "Nonprofit Operations & Fundraising Tools", + "Public Health & Social Services", + "Education & Youth Development Programs", + "Environmental & Climate Action Organizations", + "Legal Aid & Social Justice Advocacy", + "Municipal & Infrastructure Services", + "Manufacturing & Industrial Automation", + "Energy Production (Oil, Gas, Nuclear)", + "Renewable Energy & Cleantech", + "Utilities & Grid Infrastructure", + "Industrial IoT & Monitoring Systems", + "Construction & Heavy Equipment", + "Mining & Natural Resources", + "Environmental Engineering & Sustainability", + "Energy Storage & Battery Technology", + "Automotive OEMs & Vehicle Manufacturing", + "Electric Vehicles (EVs) & Charging Infrastructure", + "Mobility-as-a-Service (MaaS)", + "Fleet Management", + "Public Transit & Urban Mobility", + "Autonomous Vehicles & ADAS", + "Aftermarket Parts & Services", + "Telematics & Vehicle Connectivity", + "Aviation & Aerospace Transport", + "Maritime Shipping", + "Fitness & Wellness", + "Beauty & Personal Care", + "Home & Living", + "Dating & Relationships", + "Hobbies, Crafts & DIY", + "Outdoor & Recreational Gear", + "Events, Experiences & Ticketing Platforms", + "Designer & Luxury Apparel", + "Accessories, Jewelry & Watches", + "Footwear & Leather Goods", + "Beauty, Fragrance & Skincare", + "Fashion Marketplaces & Retail Platforms", + "Sustainable & Ethical Fashion", + "Resale, Vintage & Circular Fashion", + "Fashion Tech & Virtual Try-Ons", + "Streetwear & Emerging Luxury", + "Couture & Made-to-Measure", + "News Publishing & Journalism", + "Digital Media & Content Platforms", + "Broadcasting (TV & Radio)", + "Podcasting & Audio Media", + "News Aggregators & Curation Tools", + "Independent & Creator-Led Media", + "Newsletters & Substack-Style Platforms", + "Political & Investigative Media", + "Trade & Niche Publications", + "Media Monitoring & Analytics", + "Professional Teams & Leagues", + "Sports Media & Broadcasting", + "Sports Betting & Fantasy Sports", + "Fitness & Athletic Training Platforms", + "Sportswear & Equipment", + "Esports & Competitive Gaming", + "Sports Venues & Event Management", + "Athlete Management & Talent Agencies", + "Sports Tech & Performance Analytics", + "Youth, Amateur & Collegiate Sports", + "Real Estate Marketplaces", + "Property Management Software", + "Rental Platforms", + "Mortgage & Lending Tech", + "Real Estate Investment Platforms", + "Law Firms & Legal Services", + "Legal Tech & Automation", + "Regulatory Compliance", + "E-Discovery & Litigation Tools", + "Contract Management", + "Governance, Risk & Compliance (GRC)", + "IP & Trademark Management", + "Legal Research & Intelligence", + "Compliance Training & Certification", + "Whistleblower & Ethics Reporting", + "Mobile & Wireless Networks (3G/4G/5G)", + "Broadband & Fiber Internet", + "Satellite & Space-Based Communications", + "Network Equipment & Infrastructure", + "Telecom Billing & OSS/BSS Systems", + "VoIP & Unified Communications", + "Internet Service Providers (ISPs)", + "Edge Computing & Network Virtualization", + "IoT Connectivity Platforms", + "Precision Agriculture & AgTech", + "Crop & Livestock Production", + "Food & Beverage Manufacturing & Processing", + "Food Distribution", + "Restaurants & Food Service", + "Agricultural Inputs & Equipment", + "Sustainable & Regenerative Agriculture", + "Seafood & Aquaculture", + "Management Consulting", + "Marketing & Advertising Agencies", + "Design, Branding & Creative Studios", + "IT Services & Managed Services", + "Staffing, Recruiting & Talent", + "Accounting & Tax Firms", + "Public Relations & Communications", + "Business Process Outsourcing (BPO)", + "Professional Training & Coaching", + "Specialty Chemicals", + "Commodity & Petrochemicals", + "Polymers, Plastics & Rubber", + "Coatings, Adhesives & Sealants", + "Industrial Gases", + "Advanced Materials & Composites", + "Battery Materials & Energy Storage", + "Electronic Materials & Semiconductor Chemicals", + "Agrochemicals & Fertilizers", + "Freight & Transportation Tech", + "Last-Mile Delivery", + "Warehouse Automation", + "Supply Chain Visibility Platforms", + "Logistics Marketplaces", + "Shipping & Freight Forwarding", + "Cold Chain Logistics", + "Reverse Logistics & Returns", + "Cross-Border Trade Tech", + "Transportation Management Systems (TMS)", + "Hotels & Accommodation", + "Vacation Rentals & Short-Term Stays", + "Restaurant Tech & Management", + "Travel Booking Platforms", + "Tourism Experiences & Activities", + "Cruise Lines & Marine Tourism", + "Hospitality Management Systems", + "Event & Venue Management", + "Corporate Travel Management", + "Travel Insurance & Protection", + "Construction Management Software", + "BIM/CAD & Design Tools", + "Construction Marketplaces", + "Equipment Rental & Management", + "Building Materials & Procurement", + "Construction Workforce Management", + "Project Estimation & Bidding", + "Modular & Prefab Construction", + "Construction Safety & Compliance", + "Smart Building Technology", + "Food & Beverage CPG", + "Home & Personal Care CPG", + "CPG Analytics & Insights", + "Direct-to-Consumer CPG Brands", + "CPG Supply Chain & Distribution", + "Private Label Manufacturing", + "CPG Retail Intelligence", + "Sustainable CPG & Packaging", + "Beauty & Cosmetics CPG", + "Health & Wellness CPG", + ] + """Subindustry classification enum""" + + +class BrandIndustries(BaseModel): + eic: Optional[List[BrandIndustriesEic]] = None + """Easy Industry Classification - array of industry and subindustry pairs""" + + +class BrandLinks(BaseModel): + blog: Optional[str] = None + """URL to the brand's blog or news page""" + + careers: Optional[str] = None + """URL to the brand's careers or job opportunities page""" + + contact: Optional[str] = None + """URL to the brand's contact or contact us page""" + + pricing: Optional[str] = None + """URL to the brand's pricing or plans page""" + + privacy: Optional[str] = None + """URL to the brand's privacy policy page""" + + terms: Optional[str] = None + """URL to the brand's terms of service or terms and conditions page""" + + +class BrandLogoColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandLogoResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandLogo(BaseModel): + colors: Optional[List[BrandLogoColor]] = None + """Array of colors in the logo""" + + mode: Optional[Literal["light", "dark", "has_opaque_background"]] = None + """ + Indicates when this logo is best used: 'light' = best for light mode, 'dark' = + best for dark mode, 'has_opaque_background' = can be used for either as image + has its own background + """ + + resolution: Optional[BrandLogoResolution] = None + """Resolution of the logo image""" + + type: Optional[Literal["icon", "logo"]] = None + """Type of the logo based on resolution (e.g., 'icon', 'logo')""" + + url: Optional[str] = None + """CDN hosted url of the logo (ready for display)""" + + +class BrandSocial(BaseModel): + type: Optional[str] = None + """Type of social media, e.g., 'facebook', 'twitter'""" + + url: Optional[str] = None + """URL of the social media page""" + + +class BrandStock(BaseModel): + exchange: Optional[str] = None + """Stock exchange name""" + + ticker: Optional[str] = None + """Stock ticker symbol""" + + +class Brand(BaseModel): + address: Optional[BrandAddress] = None + """Physical address of the brand""" + + backdrops: Optional[List[BrandBackdrop]] = None + """An array of backdrop images for the brand""" + + colors: Optional[List[BrandColor]] = None + """An array of brand colors""" + + description: Optional[str] = None + """A brief description of the brand""" + + domain: Optional[str] = None + """The domain name of the brand""" + + email: Optional[str] = None + """Company email address""" + + industries: Optional[BrandIndustries] = None + """Industry classification information for the brand""" + + is_nsfw: Optional[bool] = None + """Indicates whether the brand content is not safe for work (NSFW)""" + + links: Optional[BrandLinks] = None + """Important website links for the brand""" + + logos: Optional[List[BrandLogo]] = None + """An array of logos associated with the brand""" + + phone: Optional[str] = None + """Company phone number""" + + slogan: Optional[str] = None + """The brand's slogan""" + + socials: Optional[List[BrandSocial]] = None + """An array of social media links for the brand""" + + stock: Optional[BrandStock] = None + """ + Stock market information for this brand (will be null if not a publicly traded + company) + """ + + title: Optional[str] = None + """The title or name of the brand""" + + +class BrandRetrieveByTickerResponse(BaseModel): + brand: Optional[Brand] = None + """Detailed brand information""" + + code: Optional[int] = None + """HTTP status code""" + + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index b1085aa..3eb7e6b 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -16,6 +16,8 @@ BrandScreenshotResponse, BrandStyleguideResponse, BrandRetrieveNaicsResponse, + BrandRetrieveByNameResponse, + BrandRetrieveByTickerResponse, BrandRetrieveSimplifiedResponse, BrandIdentifyFromTransactionResponse, ) @@ -239,6 +241,97 @@ def test_streaming_response_prefetch(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_retrieve_by_name(self, client: BrandDev) -> None: + brand = client.brand.retrieve_by_name( + name="xxx", + ) + assert_matches_type(BrandRetrieveByNameResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_retrieve_by_name_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.retrieve_by_name( + name="xxx", + force_language="albanian", + max_speed=True, + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveByNameResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_retrieve_by_name(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.retrieve_by_name( + name="xxx", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandRetrieveByNameResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_retrieve_by_name(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.retrieve_by_name( + name="xxx", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandRetrieveByNameResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_retrieve_by_ticker(self, client: BrandDev) -> None: + brand = client.brand.retrieve_by_ticker( + ticker="ticker", + ) + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_retrieve_by_ticker_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.retrieve_by_ticker( + ticker="ticker", + force_language="albanian", + max_speed=True, + ticker_exchange="AMEX", + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_retrieve_by_ticker(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.retrieve_by_ticker( + ticker="ticker", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_retrieve_by_ticker(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.retrieve_by_ticker( + ticker="ticker", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve_naics(self, client: BrandDev) -> None: @@ -633,6 +726,97 @@ async def test_streaming_response_prefetch(self, async_client: AsyncBrandDev) -> assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_retrieve_by_name(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_by_name( + name="xxx", + ) + assert_matches_type(BrandRetrieveByNameResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_retrieve_by_name_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_by_name( + name="xxx", + force_language="albanian", + max_speed=True, + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveByNameResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_retrieve_by_name(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.retrieve_by_name( + name="xxx", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandRetrieveByNameResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_retrieve_by_name(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.retrieve_by_name( + name="xxx", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandRetrieveByNameResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_by_ticker( + ticker="ticker", + ) + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_retrieve_by_ticker_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_by_ticker( + ticker="ticker", + force_language="albanian", + max_speed=True, + ticker_exchange="AMEX", + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.retrieve_by_ticker( + ticker="ticker", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_retrieve_by_ticker(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.retrieve_by_ticker( + ticker="ticker", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandRetrieveByTickerResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve_naics(self, async_client: AsyncBrandDev) -> None: From 0d6546702ad256b31a11fdf79e6a8ab867225872 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 23:52:19 +0000 Subject: [PATCH 117/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 1264983..d6f900a 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 10 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-b4634ce20698b51bceba24fe685badcd7e1aeb470d3562df8ad2240e1f771a83.yml -openapi_spec_hash: 505b9bfbc33f2330a2adea1a6f534e5f +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-cd55f587a8aff1c17aa98d828486e82082ff408b1dc835632284a1a8f519a8e0.yml +openapi_spec_hash: 228af7a8532747ec67fc81a4e4f365aa config_hash: a1303564edd6276a63d584a02b2238b2 From f64bb1d7b9547228db2b73d917ec27e580359297 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 23:54:24 +0000 Subject: [PATCH 118/176] feat(api): api update --- .stats.yml | 4 +- src/brand/dev/resources/brand.py | 188 +------------------ src/brand/dev/types/brand_retrieve_params.py | 94 ---------- tests/api_resources/test_brand.py | 6 - 4 files changed, 4 insertions(+), 288 deletions(-) diff --git a/.stats.yml b/.stats.yml index d6f900a..a7470db 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 10 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-cd55f587a8aff1c17aa98d828486e82082ff408b1dc835632284a1a8f519a8e0.yml -openapi_spec_hash: 228af7a8532747ec67fc81a4e4f365aa +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-90a3350206f5abf24f9b8e29cbb467308ebdea82b4d7aaebd0845875810f5910.yml +openapi_spec_hash: bb5e54c40d38f82eab1d829b831b1671 config_hash: a1303564edd6276a63d584a02b2238b2 diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 573eb53..1655e2a 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -124,83 +124,6 @@ def retrieve( ] | Omit = omit, max_speed: bool | Omit = omit, - name: str | Omit = omit, - ticker: str | Omit = omit, - ticker_exchange: Literal[ - "AMEX", - "AMS", - "AQS", - "ASX", - "ATH", - "BER", - "BME", - "BRU", - "BSE", - "BUD", - "BUE", - "BVC", - "CBOE", - "CNQ", - "CPH", - "DFM", - "DOH", - "DUB", - "DUS", - "DXE", - "EGX", - "FSX", - "HAM", - "HEL", - "HKSE", - "HOSE", - "ICE", - "IOB", - "IST", - "JKT", - "JNB", - "JPX", - "KLS", - "KOE", - "KSC", - "KUW", - "LIS", - "LSE", - "MCX", - "MEX", - "MIL", - "MUN", - "NASDAQ", - "NEO", - "NSE", - "NYSE", - "NZE", - "OSL", - "OTC", - "PAR", - "PNK", - "PRA", - "RIS", - "SAO", - "SAU", - "SES", - "SET", - "SGO", - "SHH", - "SHZ", - "SIX", - "STO", - "STU", - "TAI", - "TAL", - "TLV", - "TSX", - "TSXV", - "TWO", - "VIE", - "WSE", - "XETRA", - ] - | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -210,8 +133,7 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandRetrieveResponse: """ - Retrieve brand information using one of three methods: domain name, company - name, or stock ticker symbol. Exactly one of these parameters must be provided. + Retrieve brand information from a domain name Args: domain: Domain name to retrieve brand data for (e.g., 'example.com', 'google.com'). @@ -224,17 +146,6 @@ def retrieve( the API will skip time-consuming operations for faster response at the cost of less comprehensive data. Works with all three lookup methods. - name: Company name to retrieve brand data for (e.g., 'Apple Inc', 'Microsoft - Corporation'). Must be 3-30 characters. Cannot be used with domain or ticker - parameters. - - ticker: Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). - Must be 1-15 characters, letters/numbers/dots only. Cannot be used with domain - or name parameters. - - ticker_exchange: Optional stock exchange for the ticker. Only used when ticker parameter is - provided. Defaults to assume ticker is American if not specified. - timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -259,9 +170,6 @@ def retrieve( "domain": domain, "force_language": force_language, "max_speed": max_speed, - "name": name, - "ticker": ticker, - "ticker_exchange": ticker_exchange, "timeout_ms": timeout_ms, }, brand_retrieve_params.BrandRetrieveParams, @@ -1036,83 +944,6 @@ async def retrieve( ] | Omit = omit, max_speed: bool | Omit = omit, - name: str | Omit = omit, - ticker: str | Omit = omit, - ticker_exchange: Literal[ - "AMEX", - "AMS", - "AQS", - "ASX", - "ATH", - "BER", - "BME", - "BRU", - "BSE", - "BUD", - "BUE", - "BVC", - "CBOE", - "CNQ", - "CPH", - "DFM", - "DOH", - "DUB", - "DUS", - "DXE", - "EGX", - "FSX", - "HAM", - "HEL", - "HKSE", - "HOSE", - "ICE", - "IOB", - "IST", - "JKT", - "JNB", - "JPX", - "KLS", - "KOE", - "KSC", - "KUW", - "LIS", - "LSE", - "MCX", - "MEX", - "MIL", - "MUN", - "NASDAQ", - "NEO", - "NSE", - "NYSE", - "NZE", - "OSL", - "OTC", - "PAR", - "PNK", - "PRA", - "RIS", - "SAO", - "SAU", - "SES", - "SET", - "SGO", - "SHH", - "SHZ", - "SIX", - "STO", - "STU", - "TAI", - "TAL", - "TLV", - "TSX", - "TSXV", - "TWO", - "VIE", - "WSE", - "XETRA", - ] - | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -1122,8 +953,7 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandRetrieveResponse: """ - Retrieve brand information using one of three methods: domain name, company - name, or stock ticker symbol. Exactly one of these parameters must be provided. + Retrieve brand information from a domain name Args: domain: Domain name to retrieve brand data for (e.g., 'example.com', 'google.com'). @@ -1136,17 +966,6 @@ async def retrieve( the API will skip time-consuming operations for faster response at the cost of less comprehensive data. Works with all three lookup methods. - name: Company name to retrieve brand data for (e.g., 'Apple Inc', 'Microsoft - Corporation'). Must be 3-30 characters. Cannot be used with domain or ticker - parameters. - - ticker: Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). - Must be 1-15 characters, letters/numbers/dots only. Cannot be used with domain - or name parameters. - - ticker_exchange: Optional stock exchange for the ticker. Only used when ticker parameter is - provided. Defaults to assume ticker is American if not specified. - timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -1171,9 +990,6 @@ async def retrieve( "domain": domain, "force_language": force_language, "max_speed": max_speed, - "name": name, - "ticker": ticker, - "ticker_exchange": ticker_exchange, "timeout_ms": timeout_ms, }, brand_retrieve_params.BrandRetrieveParams, diff --git a/src/brand/dev/types/brand_retrieve_params.py b/src/brand/dev/types/brand_retrieve_params.py index 474c7b9..0d8a8e3 100644 --- a/src/brand/dev/types/brand_retrieve_params.py +++ b/src/brand/dev/types/brand_retrieve_params.py @@ -83,100 +83,6 @@ class BrandRetrieveParams(TypedDict, total=False): methods. """ - name: str - """ - Company name to retrieve brand data for (e.g., 'Apple Inc', 'Microsoft - Corporation'). Must be 3-30 characters. Cannot be used with domain or ticker - parameters. - """ - - ticker: str - """Stock ticker symbol to retrieve brand data for (e.g., 'AAPL', 'GOOGL', 'BRK.A'). - - Must be 1-15 characters, letters/numbers/dots only. Cannot be used with domain - or name parameters. - """ - - ticker_exchange: Literal[ - "AMEX", - "AMS", - "AQS", - "ASX", - "ATH", - "BER", - "BME", - "BRU", - "BSE", - "BUD", - "BUE", - "BVC", - "CBOE", - "CNQ", - "CPH", - "DFM", - "DOH", - "DUB", - "DUS", - "DXE", - "EGX", - "FSX", - "HAM", - "HEL", - "HKSE", - "HOSE", - "ICE", - "IOB", - "IST", - "JKT", - "JNB", - "JPX", - "KLS", - "KOE", - "KSC", - "KUW", - "LIS", - "LSE", - "MCX", - "MEX", - "MIL", - "MUN", - "NASDAQ", - "NEO", - "NSE", - "NYSE", - "NZE", - "OSL", - "OTC", - "PAR", - "PNK", - "PRA", - "RIS", - "SAO", - "SAU", - "SES", - "SET", - "SGO", - "SHH", - "SHZ", - "SIX", - "STO", - "STU", - "TAI", - "TAL", - "TLV", - "TSX", - "TSXV", - "TWO", - "VIE", - "WSE", - "XETRA", - ] - """Optional stock exchange for the ticker. - - Only used when ticker parameter is provided. Defaults to assume ticker is - American if not specified. - """ - timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] """Optional timeout in milliseconds for the request. diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 3eb7e6b..bc1a30e 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -41,9 +41,6 @@ def test_method_retrieve_with_all_params(self, client: BrandDev) -> None: domain="domain", force_language="albanian", max_speed=True, - name="xxx", - ticker="ticker", - ticker_exchange="AMEX", timeout_ms=1, ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) @@ -526,9 +523,6 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncBrandDev domain="domain", force_language="albanian", max_speed=True, - name="xxx", - ticker="ticker", - ticker_exchange="AMEX", timeout_ms=1, ) assert_matches_type(BrandRetrieveResponse, brand, path=["response"]) From f4e19c0c00da8ca49dbf434b622f06f22e81f527 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 00:05:55 +0000 Subject: [PATCH 119/176] feat(api): api update --- .stats.yml | 4 +- src/brand/dev/resources/brand.py | 134 +++++++++++++++++- .../brand_identify_from_transaction_params.py | 65 ++++++++- tests/api_resources/test_brand.py | 4 + 4 files changed, 202 insertions(+), 5 deletions(-) diff --git a/.stats.yml b/.stats.yml index a7470db..b6baa96 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 10 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-90a3350206f5abf24f9b8e29cbb467308ebdea82b4d7aaebd0845875810f5910.yml -openapi_spec_hash: bb5e54c40d38f82eab1d829b831b1671 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-a634e2867f22f7485bf8ef51d18a25c010274dcbd60a420c8b35e68d017c8c95.yml +openapi_spec_hash: 8990e4b274d4563c77525b15a2723f63 config_hash: a1303564edd6276a63d584a02b2238b2 diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 1655e2a..76f275e 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -133,7 +133,8 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandRetrieveResponse: """ - Retrieve brand information from a domain name + Retrieve logos, backdrops, colors, industry, description, and more from any + domain Args: domain: Domain name to retrieve brand data for (e.g., 'example.com', 'google.com'). @@ -238,6 +239,62 @@ def identify_from_transaction( self, *, transaction_info: str, + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + | Omit = omit, + max_speed: bool | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -253,6 +310,12 @@ def identify_from_transaction( Args: transaction_info: Transaction information to identify the brand + force_language: Optional parameter to force the language of the retrieved brand data. + + max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, + the API will skip time-consuming operations for faster response at the cost of + less comprehensive data. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -275,6 +338,8 @@ def identify_from_transaction( query=maybe_transform( { "transaction_info": transaction_info, + "force_language": force_language, + "max_speed": max_speed, "timeout_ms": timeout_ms, }, brand_identify_from_transaction_params.BrandIdentifyFromTransactionParams, @@ -953,7 +1018,8 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandRetrieveResponse: """ - Retrieve brand information from a domain name + Retrieve logos, backdrops, colors, industry, description, and more from any + domain Args: domain: Domain name to retrieve brand data for (e.g., 'example.com', 'google.com'). @@ -1058,6 +1124,62 @@ async def identify_from_transaction( self, *, transaction_info: str, + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + | Omit = omit, + max_speed: bool | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -1073,6 +1195,12 @@ async def identify_from_transaction( Args: transaction_info: Transaction information to identify the brand + force_language: Optional parameter to force the language of the retrieved brand data. + + max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, + the API will skip time-consuming operations for faster response at the cost of + less comprehensive data. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -1095,6 +1223,8 @@ async def identify_from_transaction( query=await async_maybe_transform( { "transaction_info": transaction_info, + "force_language": force_language, + "max_speed": max_speed, "timeout_ms": timeout_ms, }, brand_identify_from_transaction_params.BrandIdentifyFromTransactionParams, diff --git a/src/brand/dev/types/brand_identify_from_transaction_params.py b/src/brand/dev/types/brand_identify_from_transaction_params.py index 186f08f..9878fd9 100644 --- a/src/brand/dev/types/brand_identify_from_transaction_params.py +++ b/src/brand/dev/types/brand_identify_from_transaction_params.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing_extensions import Required, Annotated, TypedDict +from typing_extensions import Literal, Required, Annotated, TypedDict from .._utils import PropertyInfo @@ -13,6 +13,69 @@ class BrandIdentifyFromTransactionParams(TypedDict, total=False): transaction_info: Required[str] """Transaction information to identify the brand""" + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + """Optional parameter to force the language of the retrieved brand data.""" + + max_speed: Annotated[bool, PropertyInfo(alias="maxSpeed")] + """Optional parameter to optimize the API call for maximum speed. + + When set to true, the API will skip time-consuming operations for faster + response at the cost of less comprehensive data. + """ + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] """Optional timeout in milliseconds for the request. diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index bc1a30e..7467a0a 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -165,6 +165,8 @@ def test_method_identify_from_transaction(self, client: BrandDev) -> None: def test_method_identify_from_transaction_with_all_params(self, client: BrandDev) -> None: brand = client.brand.identify_from_transaction( transaction_info="transaction_info", + force_language="albanian", + max_speed=True, timeout_ms=1, ) assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) @@ -647,6 +649,8 @@ async def test_method_identify_from_transaction(self, async_client: AsyncBrandDe async def test_method_identify_from_transaction_with_all_params(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.identify_from_transaction( transaction_info="transaction_info", + force_language="albanian", + max_speed=True, timeout_ms=1, ) assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) From bd64eeb4f698670c68a4065d139bae5af53ef2de Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 00:48:15 +0000 Subject: [PATCH 120/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3741b31..4ce109a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.17.1" + ".": "1.18.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index decdb75..9d6cdd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.17.1" +version = "1.18.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index c43840e..77f162f 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.17.1" # x-release-please-version +__version__ = "1.18.0" # x-release-please-version From 6f165c6a91e324b8e36f5d95be5c45ecd5740386 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 02:04:32 +0000 Subject: [PATCH 121/176] fix(client): close streams without requiring full consumption --- src/brand/dev/_streaming.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/brand/dev/_streaming.py b/src/brand/dev/_streaming.py index 0ecd094..a61de0d 100644 --- a/src/brand/dev/_streaming.py +++ b/src/brand/dev/_streaming.py @@ -57,9 +57,8 @@ def __stream__(self) -> Iterator[_T]: for sse in iterator: yield process_data(data=sse.json(), cast_to=cast_to, response=response) - # Ensure the entire stream is consumed - for _sse in iterator: - ... + # As we might not fully consume the response stream, we need to close it explicitly + response.close() def __enter__(self) -> Self: return self @@ -121,9 +120,8 @@ async def __stream__(self) -> AsyncIterator[_T]: async for sse in iterator: yield process_data(data=sse.json(), cast_to=cast_to, response=response) - # Ensure the entire stream is consumed - async for _sse in iterator: - ... + # As we might not fully consume the response stream, we need to close it explicitly + await response.aclose() async def __aenter__(self) -> Self: return self From 117a687375e212a7374d8e49393e2e49a349fa24 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 02:09:45 +0000 Subject: [PATCH 122/176] chore(internal/tests): avoid race condition with implicit client cleanup --- tests/test_client.py | 362 +++++++++++++++++++++++-------------------- 1 file changed, 198 insertions(+), 164 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 37d8e22..0e001a2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -59,51 +59,49 @@ def _get_open_connections(client: BrandDev | AsyncBrandDev) -> int: class TestBrandDev: - client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - def test_raw_response(self, respx_mock: MockRouter) -> None: + def test_raw_response(self, respx_mock: MockRouter, client: BrandDev) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + def test_raw_response_for_binary(self, respx_mock: MockRouter, client: BrandDev) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, client: BrandDev) -> None: + copied = client.copy() + assert id(copied) != id(client) - copied = self.client.copy(api_key="another My API Key") + copied = client.copy(api_key="another My API Key") assert copied.api_key == "another My API Key" - assert self.client.api_key == "My API Key" + assert client.api_key == "My API Key" - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, client: BrandDev) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(client.timeout, httpx.Timeout) + copied = client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(client.timeout, httpx.Timeout) def test_copy_default_headers(self) -> None: client = BrandDev( @@ -138,6 +136,7 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + client.close() def test_copy_default_query(self) -> None: client = BrandDev( @@ -175,13 +174,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + client.close() + + def test_copy_signature(self, client: BrandDev) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -192,12 +193,12 @@ def test_copy_signature(self) -> None: assert copy_param is not None, f"copy() signature is missing the {name} param" @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: + def test_copy_build_request(self, client: BrandDev) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -254,14 +255,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + def test_request_timeout(self, client: BrandDev) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( - FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) - ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(100.0) @@ -274,6 +273,8 @@ def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + client.close() + def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used with httpx.Client(timeout=None) as http_client: @@ -285,6 +286,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + client.close() + # no timeout given to the httpx client should not use the httpx default with httpx.Client() as http_client: client = BrandDev( @@ -295,6 +298,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + client.close() + # explicitly passing the default timeout currently results in it being ignored with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = BrandDev( @@ -305,6 +310,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + client.close() + async def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): async with httpx.AsyncClient() as http_client: @@ -316,14 +323,14 @@ async def test_invalid_http_client(self) -> None: ) def test_default_headers_option(self) -> None: - client = BrandDev( + test_client = BrandDev( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} ) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = BrandDev( + test_client2 = BrandDev( base_url=base_url, api_key=api_key, _strict_response_validation=True, @@ -332,10 +339,13 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" + test_client.close() + test_client2.close() + def test_validate_headers(self) -> None: client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) request = client._build_request(FinalRequestOptions(method="get", url="/foo")) @@ -364,8 +374,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + client.close() + + def test_request_extra_json(self, client: BrandDev) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -376,7 +388,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -387,7 +399,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -398,8 +410,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: BrandDev) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -409,7 +421,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -420,8 +432,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: BrandDev) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -434,7 +446,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -448,7 +460,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -491,7 +503,7 @@ def test_multipart_repeating_array(self, client: BrandDev) -> None: ] @pytest.mark.respx(base_url=base_url) - def test_basic_union_response(self, respx_mock: MockRouter) -> None: + def test_basic_union_response(self, respx_mock: MockRouter, client: BrandDev) -> None: class Model1(BaseModel): name: str @@ -500,12 +512,12 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + def test_union_response_different_types(self, respx_mock: MockRouter, client: BrandDev) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -516,18 +528,18 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter, client: BrandDev) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -543,7 +555,7 @@ class Model(BaseModel): ) ) - response = self.client.get("/foo", cast_to=Model) + response = client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 @@ -555,6 +567,8 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" + client.close() + def test_base_url_env(self) -> None: with update_env(BRAND_DEV_BASE_URL="http://localhost:5000/from/env"): client = BrandDev(api_key=api_key, _strict_response_validation=True) @@ -582,6 +596,7 @@ def test_base_url_trailing_slash(self, client: BrandDev) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -605,6 +620,7 @@ def test_base_url_no_trailing_slash(self, client: BrandDev) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -628,35 +644,36 @@ def test_absolute_request_url(self, client: BrandDev) -> None: ), ) assert request.url == "https://myapi.com/foo" + client.close() def test_copied_client_does_not_close_http(self) -> None: - client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) - assert not client.is_closed() + test_client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied - assert not client.is_closed() + assert not test_client.is_closed() def test_client_context_manager(self) -> None: - client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) - with client as c2: - assert c2 is client + test_client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + def test_client_response_validation_error(self, respx_mock: MockRouter, client: BrandDev) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - self.client.get("/foo", cast_to=Model) + client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -676,11 +693,14 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): strict_client.get("/foo", cast_to=Model) - client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=False) + non_strict_client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=False) - response = client.get("/foo", cast_to=Model) + response = non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + strict_client.close() + non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -703,9 +723,9 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = BrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) - + def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, client: BrandDev + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) calculated = client._calculate_retry_timeout(remaining_retries, options, headers) @@ -719,7 +739,7 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, clien with pytest.raises(APITimeoutError): client.brand.with_streaming_response.retrieve().__enter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(client) == 0 @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -728,7 +748,7 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client with pytest.raises(APIStatusError): client.brand.with_streaming_response.retrieve().__enter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -830,83 +850,77 @@ def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - def test_follow_redirects(self, respx_mock: MockRouter) -> None: + def test_follow_redirects(self, respx_mock: MockRouter, client: BrandDev) -> None: # Test that the default follow_redirects=True allows following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + response = client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) assert response.status_code == 200 assert response.json() == {"status": "ok"} @pytest.mark.respx(base_url=base_url) - def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + def test_follow_redirects_disabled(self, respx_mock: MockRouter, client: BrandDev) -> None: # Test that follow_redirects=False prevents following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) with pytest.raises(APIStatusError) as exc_info: - self.client.post( - "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response - ) + client.post("/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response) assert exc_info.value.response.status_code == 302 assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" class TestAsyncBrandDev: - client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response(self, respx_mock: MockRouter) -> None: + async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncBrandDev) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_client: AsyncBrandDev) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, async_client: AsyncBrandDev) -> None: + copied = async_client.copy() + assert id(copied) != id(async_client) - copied = self.client.copy(api_key="another My API Key") + copied = async_client.copy(api_key="another My API Key") assert copied.api_key == "another My API Key" - assert self.client.api_key == "My API Key" + assert async_client.api_key == "My API Key" - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, async_client: AsyncBrandDev) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = async_client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert async_client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(async_client.timeout, httpx.Timeout) + copied = async_client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(async_client.timeout, httpx.Timeout) - def test_copy_default_headers(self) -> None: + async def test_copy_default_headers(self) -> None: client = AsyncBrandDev( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} ) @@ -939,8 +953,9 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + await client.close() - def test_copy_default_query(self) -> None: + async def test_copy_default_query(self) -> None: client = AsyncBrandDev( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"} ) @@ -976,13 +991,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + await client.close() + + def test_copy_signature(self, async_client: AsyncBrandDev) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + async_client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(async_client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -993,12 +1010,12 @@ def test_copy_signature(self) -> None: assert copy_param is not None, f"copy() signature is missing the {name} param" @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: + def test_copy_build_request(self, async_client: AsyncBrandDev) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = async_client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -1055,12 +1072,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - async def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + async def test_request_timeout(self, async_client: AsyncBrandDev) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( + request = async_client._build_request( FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) ) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore @@ -1075,6 +1092,8 @@ async def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + await client.close() + async def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used async with httpx.AsyncClient(timeout=None) as http_client: @@ -1086,6 +1105,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + await client.close() + # no timeout given to the httpx client should not use the httpx default async with httpx.AsyncClient() as http_client: client = AsyncBrandDev( @@ -1096,6 +1117,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + await client.close() + # explicitly passing the default timeout currently results in it being ignored async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = AsyncBrandDev( @@ -1106,6 +1129,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + await client.close() + def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): with httpx.Client() as http_client: @@ -1116,15 +1141,15 @@ def test_invalid_http_client(self) -> None: http_client=cast(Any, http_client), ) - def test_default_headers_option(self) -> None: - client = AsyncBrandDev( + async def test_default_headers_option(self) -> None: + test_client = AsyncBrandDev( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} ) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = AsyncBrandDev( + test_client2 = AsyncBrandDev( base_url=base_url, api_key=api_key, _strict_response_validation=True, @@ -1133,10 +1158,13 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" + await test_client.close() + await test_client2.close() + def test_validate_headers(self) -> None: client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) request = client._build_request(FinalRequestOptions(method="get", url="/foo")) @@ -1147,7 +1175,7 @@ def test_validate_headers(self) -> None: client2 = AsyncBrandDev(base_url=base_url, api_key=None, _strict_response_validation=True) _ = client2 - def test_default_query_option(self) -> None: + async def test_default_query_option(self) -> None: client = AsyncBrandDev( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"} ) @@ -1165,8 +1193,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + await client.close() + + def test_request_extra_json(self, client: BrandDev) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1177,7 +1207,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1188,7 +1218,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1199,8 +1229,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: BrandDev) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1210,7 +1240,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1221,8 +1251,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: BrandDev) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1235,7 +1265,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1249,7 +1279,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1292,7 +1322,7 @@ def test_multipart_repeating_array(self, async_client: AsyncBrandDev) -> None: ] @pytest.mark.respx(base_url=base_url) - async def test_basic_union_response(self, respx_mock: MockRouter) -> None: + async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncBrandDev) -> None: class Model1(BaseModel): name: str @@ -1301,12 +1331,12 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - async def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + async def test_union_response_different_types(self, respx_mock: MockRouter, async_client: AsyncBrandDev) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -1317,18 +1347,20 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + async def test_non_application_json_content_type_for_json_data( + self, respx_mock: MockRouter, async_client: AsyncBrandDev + ) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -1344,11 +1376,11 @@ class Model(BaseModel): ) ) - response = await self.client.get("/foo", cast_to=Model) + response = await async_client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 - def test_base_url_setter(self) -> None: + async def test_base_url_setter(self) -> None: client = AsyncBrandDev( base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True ) @@ -1358,7 +1390,9 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" - def test_base_url_env(self) -> None: + await client.close() + + async def test_base_url_env(self) -> None: with update_env(BRAND_DEV_BASE_URL="http://localhost:5000/from/env"): client = AsyncBrandDev(api_key=api_key, _strict_response_validation=True) assert client.base_url == "http://localhost:5000/from/env/" @@ -1378,7 +1412,7 @@ def test_base_url_env(self) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_trailing_slash(self, client: AsyncBrandDev) -> None: + async def test_base_url_trailing_slash(self, client: AsyncBrandDev) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1387,6 +1421,7 @@ def test_base_url_trailing_slash(self, client: AsyncBrandDev) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1403,7 +1438,7 @@ def test_base_url_trailing_slash(self, client: AsyncBrandDev) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_no_trailing_slash(self, client: AsyncBrandDev) -> None: + async def test_base_url_no_trailing_slash(self, client: AsyncBrandDev) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1412,6 +1447,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncBrandDev) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1428,7 +1464,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncBrandDev) -> None: ], ids=["standard", "custom http client"], ) - def test_absolute_request_url(self, client: AsyncBrandDev) -> None: + async def test_absolute_request_url(self, client: AsyncBrandDev) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1437,37 +1473,37 @@ def test_absolute_request_url(self, client: AsyncBrandDev) -> None: ), ) assert request.url == "https://myapi.com/foo" + await client.close() async def test_copied_client_does_not_close_http(self) -> None: - client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) - assert not client.is_closed() + test_client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied await asyncio.sleep(0.2) - assert not client.is_closed() + assert not test_client.is_closed() async def test_client_context_manager(self) -> None: - client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) - async with client as c2: - assert c2 is client + test_client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) + async with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + async def test_client_response_validation_error(self, respx_mock: MockRouter, async_client: AsyncBrandDev) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - await self.client.get("/foo", cast_to=Model) + await async_client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -1478,7 +1514,6 @@ async def test_client_max_retries_validation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: class Model(BaseModel): name: str @@ -1490,11 +1525,14 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): await strict_client.get("/foo", cast_to=Model) - client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=False) + non_strict_client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=False) - response = await client.get("/foo", cast_to=Model) + response = await non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + await strict_client.close() + await non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -1517,13 +1555,12 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - @pytest.mark.asyncio - async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = AsyncBrandDev(base_url=base_url, api_key=api_key, _strict_response_validation=True) - + async def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncBrandDev + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) - calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers) assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -1536,7 +1573,7 @@ async def test_retrying_timeout_errors_doesnt_leak( with pytest.raises(APITimeoutError): await async_client.brand.with_streaming_response.retrieve().__aenter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(async_client) == 0 @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -1547,12 +1584,11 @@ async def test_retrying_status_errors_doesnt_leak( with pytest.raises(APIStatusError): await async_client.brand.with_streaming_response.retrieve().__aenter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(async_client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio @pytest.mark.parametrize("failure_mode", ["status", "exception"]) async def test_retries_taken( self, @@ -1584,7 +1620,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_omit_retry_count_header( self, async_client: AsyncBrandDev, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1608,7 +1643,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("brand.dev._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_overwrite_retry_count_header( self, async_client: AsyncBrandDev, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1656,26 +1690,26 @@ async def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + async def test_follow_redirects(self, respx_mock: MockRouter, async_client: AsyncBrandDev) -> None: # Test that the default follow_redirects=True allows following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + response = await async_client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) assert response.status_code == 200 assert response.json() == {"status": "ok"} @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + async def test_follow_redirects_disabled(self, respx_mock: MockRouter, async_client: AsyncBrandDev) -> None: # Test that follow_redirects=False prevents following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) with pytest.raises(APIStatusError) as exc_info: - await self.client.post( + await async_client.post( "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response ) From 086ed7d8d71293004503cdf8dcf3d83dd2087a6f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:22:06 +0000 Subject: [PATCH 123/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index b6baa96..1123c29 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 10 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-a634e2867f22f7485bf8ef51d18a25c010274dcbd60a420c8b35e68d017c8c95.yml -openapi_spec_hash: 8990e4b274d4563c77525b15a2723f63 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-03d0d129690a7e6178f207b283001e66c7ef4a74d340647f18ce21ca3ccd8a1a.yml +openapi_spec_hash: f64086044d57f32cba3ead2151fb4d61 config_hash: a1303564edd6276a63d584a02b2238b2 From ee4b0eb0800548eeffc28827161d1890d80d18db Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:22:48 +0000 Subject: [PATCH 124/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 1123c29..5d8534e 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 10 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-03d0d129690a7e6178f207b283001e66c7ef4a74d340647f18ce21ca3ccd8a1a.yml -openapi_spec_hash: f64086044d57f32cba3ead2151fb4d61 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-6620330945de41f1c453692af40842f08fe1fd281ff6ba4e79d447c941ebd783.yml +openapi_spec_hash: 861a43669d27d942d4bd3e36a398e95b config_hash: a1303564edd6276a63d584a02b2238b2 From 789d0fa765333faa2ef792700a5bed03a37f0a1b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:23:10 +0000 Subject: [PATCH 125/176] feat(api): manual updates --- .stats.yml | 8 +- api.md | 2 + src/brand/dev/resources/brand.py | 248 +++++++++ src/brand/dev/types/__init__.py | 2 + .../types/brand_retrieve_by_email_params.py | 88 ++++ .../types/brand_retrieve_by_email_response.py | 481 ++++++++++++++++++ tests/api_resources/test_brand.py | 91 ++++ 7 files changed, 916 insertions(+), 4 deletions(-) create mode 100644 src/brand/dev/types/brand_retrieve_by_email_params.py create mode 100644 src/brand/dev/types/brand_retrieve_by_email_response.py diff --git a/.stats.yml b/.stats.yml index 5d8534e..aeecb0c 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 10 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-6620330945de41f1c453692af40842f08fe1fd281ff6ba4e79d447c941ebd783.yml -openapi_spec_hash: 861a43669d27d942d4bd3e36a398e95b -config_hash: a1303564edd6276a63d584a02b2238b2 +configured_endpoints: 11 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-03d0d129690a7e6178f207b283001e66c7ef4a74d340647f18ce21ca3ccd8a1a.yml +openapi_spec_hash: f64086044d57f32cba3ead2151fb4d61 +config_hash: 083e432ea397a9018371145493400188 diff --git a/api.md b/api.md index 99766f8..cec128a 100644 --- a/api.md +++ b/api.md @@ -8,6 +8,7 @@ from brand.dev.types import ( BrandAIQueryResponse, BrandIdentifyFromTransactionResponse, BrandPrefetchResponse, + BrandRetrieveByEmailResponse, BrandRetrieveByNameResponse, BrandRetrieveByTickerResponse, BrandRetrieveNaicsResponse, @@ -23,6 +24,7 @@ Methods: - client.brand.ai_query(\*\*params) -> BrandAIQueryResponse - client.brand.identify_from_transaction(\*\*params) -> BrandIdentifyFromTransactionResponse - client.brand.prefetch(\*\*params) -> BrandPrefetchResponse +- client.brand.retrieve_by_email(\*\*params) -> BrandRetrieveByEmailResponse - client.brand.retrieve_by_name(\*\*params) -> BrandRetrieveByNameResponse - client.brand.retrieve_by_ticker(\*\*params) -> BrandRetrieveByTickerResponse - client.brand.retrieve_naics(\*\*params) -> BrandRetrieveNaicsResponse diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 76f275e..8749438 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -15,6 +15,7 @@ brand_styleguide_params, brand_retrieve_naics_params, brand_retrieve_by_name_params, + brand_retrieve_by_email_params, brand_retrieve_by_ticker_params, brand_retrieve_simplified_params, brand_identify_from_transaction_params, @@ -37,6 +38,7 @@ from ..types.brand_styleguide_response import BrandStyleguideResponse from ..types.brand_retrieve_naics_response import BrandRetrieveNaicsResponse from ..types.brand_retrieve_by_name_response import BrandRetrieveByNameResponse +from ..types.brand_retrieve_by_email_response import BrandRetrieveByEmailResponse from ..types.brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse from ..types.brand_retrieve_simplified_response import BrandRetrieveSimplifiedResponse from ..types.brand_identify_from_transaction_response import BrandIdentifyFromTransactionResponse @@ -396,6 +398,123 @@ def prefetch( cast_to=BrandPrefetchResponse, ) + def retrieve_by_email( + self, + *, + email: str, + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + | Omit = omit, + max_speed: bool | Omit = omit, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandRetrieveByEmailResponse: + """ + Retrieve brand information using an email address while detecting disposable and + free email addresses. This endpoint extracts the domain from the email address + and returns brand data for that domain. Disposable and free email addresses + (like gmail.com, yahoo.com) will throw a 422 error. + + Args: + email: Email address to retrieve brand data for (e.g., 'contact@example.com'). The + domain will be extracted from the email. Free email providers (gmail.com, + yahoo.com, etc.) and disposable email addresses are not allowed. + + force_language: Optional parameter to force the language of the retrieved brand data. + + max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, + the API will skip time-consuming operations for faster response at the cost of + less comprehensive data. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/retrieve-by-email", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "email": email, + "force_language": force_language, + "max_speed": max_speed, + "timeout_ms": timeout_ms, + }, + brand_retrieve_by_email_params.BrandRetrieveByEmailParams, + ), + ), + cast_to=BrandRetrieveByEmailResponse, + ) + def retrieve_by_name( self, *, @@ -1281,6 +1400,123 @@ async def prefetch( cast_to=BrandPrefetchResponse, ) + async def retrieve_by_email( + self, + *, + email: str, + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + | Omit = omit, + max_speed: bool | Omit = omit, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandRetrieveByEmailResponse: + """ + Retrieve brand information using an email address while detecting disposable and + free email addresses. This endpoint extracts the domain from the email address + and returns brand data for that domain. Disposable and free email addresses + (like gmail.com, yahoo.com) will throw a 422 error. + + Args: + email: Email address to retrieve brand data for (e.g., 'contact@example.com'). The + domain will be extracted from the email. Free email providers (gmail.com, + yahoo.com, etc.) and disposable email addresses are not allowed. + + force_language: Optional parameter to force the language of the retrieved brand data. + + max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, + the API will skip time-consuming operations for faster response at the cost of + less comprehensive data. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/brand/retrieve-by-email", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "email": email, + "force_language": force_language, + "max_speed": max_speed, + "timeout_ms": timeout_ms, + }, + brand_retrieve_by_email_params.BrandRetrieveByEmailParams, + ), + ), + cast_to=BrandRetrieveByEmailResponse, + ) + async def retrieve_by_name( self, *, @@ -1830,6 +2066,9 @@ def __init__(self, brand: BrandResource) -> None: self.prefetch = to_raw_response_wrapper( brand.prefetch, ) + self.retrieve_by_email = to_raw_response_wrapper( + brand.retrieve_by_email, + ) self.retrieve_by_name = to_raw_response_wrapper( brand.retrieve_by_name, ) @@ -1866,6 +2105,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.prefetch = async_to_raw_response_wrapper( brand.prefetch, ) + self.retrieve_by_email = async_to_raw_response_wrapper( + brand.retrieve_by_email, + ) self.retrieve_by_name = async_to_raw_response_wrapper( brand.retrieve_by_name, ) @@ -1902,6 +2144,9 @@ def __init__(self, brand: BrandResource) -> None: self.prefetch = to_streamed_response_wrapper( brand.prefetch, ) + self.retrieve_by_email = to_streamed_response_wrapper( + brand.retrieve_by_email, + ) self.retrieve_by_name = to_streamed_response_wrapper( brand.retrieve_by_name, ) @@ -1938,6 +2183,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.prefetch = async_to_streamed_response_wrapper( brand.prefetch, ) + self.retrieve_by_email = async_to_streamed_response_wrapper( + brand.retrieve_by_email, + ) self.retrieve_by_name = async_to_streamed_response_wrapper( brand.retrieve_by_name, ) diff --git a/src/brand/dev/types/__init__.py b/src/brand/dev/types/__init__.py index e038cbb..bd36eb4 100644 --- a/src/brand/dev/types/__init__.py +++ b/src/brand/dev/types/__init__.py @@ -15,8 +15,10 @@ from .brand_retrieve_naics_params import BrandRetrieveNaicsParams as BrandRetrieveNaicsParams from .brand_retrieve_by_name_params import BrandRetrieveByNameParams as BrandRetrieveByNameParams from .brand_retrieve_naics_response import BrandRetrieveNaicsResponse as BrandRetrieveNaicsResponse +from .brand_retrieve_by_email_params import BrandRetrieveByEmailParams as BrandRetrieveByEmailParams from .brand_retrieve_by_name_response import BrandRetrieveByNameResponse as BrandRetrieveByNameResponse from .brand_retrieve_by_ticker_params import BrandRetrieveByTickerParams as BrandRetrieveByTickerParams +from .brand_retrieve_by_email_response import BrandRetrieveByEmailResponse as BrandRetrieveByEmailResponse from .brand_retrieve_simplified_params import BrandRetrieveSimplifiedParams as BrandRetrieveSimplifiedParams from .brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse as BrandRetrieveByTickerResponse from .brand_retrieve_simplified_response import BrandRetrieveSimplifiedResponse as BrandRetrieveSimplifiedResponse diff --git a/src/brand/dev/types/brand_retrieve_by_email_params.py b/src/brand/dev/types/brand_retrieve_by_email_params.py new file mode 100644 index 0000000..da361c6 --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_by_email_params.py @@ -0,0 +1,88 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["BrandRetrieveByEmailParams"] + + +class BrandRetrieveByEmailParams(TypedDict, total=False): + email: Required[str] + """Email address to retrieve brand data for (e.g., 'contact@example.com'). + + The domain will be extracted from the email. Free email providers (gmail.com, + yahoo.com, etc.) and disposable email addresses are not allowed. + """ + + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + """Optional parameter to force the language of the retrieved brand data.""" + + max_speed: Annotated[bool, PropertyInfo(alias="maxSpeed")] + """Optional parameter to optimize the API call for maximum speed. + + When set to true, the API will skip time-consuming operations for faster + response at the cost of less comprehensive data. + """ + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_retrieve_by_email_response.py b/src/brand/dev/types/brand_retrieve_by_email_response.py new file mode 100644 index 0000000..47411cd --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_by_email_response.py @@ -0,0 +1,481 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = [ + "BrandRetrieveByEmailResponse", + "Brand", + "BrandAddress", + "BrandBackdrop", + "BrandBackdropColor", + "BrandBackdropResolution", + "BrandColor", + "BrandIndustries", + "BrandIndustriesEic", + "BrandLinks", + "BrandLogo", + "BrandLogoColor", + "BrandLogoResolution", + "BrandSocial", + "BrandStock", +] + + +class BrandAddress(BaseModel): + city: Optional[str] = None + """City name""" + + country: Optional[str] = None + """Country name""" + + country_code: Optional[str] = None + """Country code""" + + postal_code: Optional[str] = None + """Postal or ZIP code""" + + state_code: Optional[str] = None + """State or province code""" + + state_province: Optional[str] = None + """State or province name""" + + street: Optional[str] = None + """Street address""" + + +class BrandBackdropColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandBackdropResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandBackdrop(BaseModel): + colors: Optional[List[BrandBackdropColor]] = None + """Array of colors in the backdrop image""" + + resolution: Optional[BrandBackdropResolution] = None + """Resolution of the backdrop image""" + + url: Optional[str] = None + """URL of the backdrop image""" + + +class BrandColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandIndustriesEic(BaseModel): + industry: Literal[ + "Aerospace & Defense", + "Technology", + "Finance", + "Healthcare", + "Retail & E-commerce", + "Entertainment", + "Education", + "Government & Nonprofit", + "Industrial & Energy", + "Automotive & Transportation", + "Lifestyle & Leisure", + "Luxury & Fashion", + "News & Media", + "Sports", + "Real Estate & PropTech", + "Legal & Compliance", + "Telecommunications", + "Agriculture & Food", + "Professional Services & Agencies", + "Chemicals & Materials", + "Logistics & Supply Chain", + "Hospitality & Tourism", + "Construction & Built Environment", + "Consumer Packaged Goods (CPG)", + ] + """Industry classification enum""" + + subindustry: Literal[ + "Defense Systems & Military Hardware", + "Aerospace Manufacturing", + "Avionics & Navigation Technology", + "Subsea & Naval Defense Systems", + "Space & Satellite Technology", + "Defense IT & Systems Integration", + "Software (B2B)", + "Software (B2C)", + "Cloud Infrastructure & DevOps", + "Cybersecurity", + "Artificial Intelligence & Machine Learning", + "Data Infrastructure & Analytics", + "Hardware & Semiconductors", + "Fintech Infrastructure", + "eCommerce & Marketplace Platforms", + "Developer Tools & APIs", + "Web3 & Blockchain", + "XR & Spatial Computing", + "Banking & Lending", + "Investment Management & WealthTech", + "Insurance & InsurTech", + "Payments & Money Movement", + "Accounting, Tax & Financial Planning Tools", + "Capital Markets & Trading Platforms", + "Financial Infrastructure & APIs", + "Credit Scoring & Risk Management", + "Cryptocurrency & Digital Assets", + "BNPL & Alternative Financing", + "Healthcare Providers & Services", + "Pharmaceuticals & Drug Development", + "Medical Devices & Diagnostics", + "Biotechnology & Genomics", + "Digital Health & Telemedicine", + "Health Insurance & Benefits Tech", + "Clinical Trials & Research Platforms", + "Mental Health & Wellness", + "Healthcare IT & EHR Systems", + "Consumer Health & Wellness Products", + "Online Marketplaces", + "Direct-to-Consumer (DTC) Brands", + "Retail Tech & Point-of-Sale Systems", + "Omnichannel & In-Store Retail", + "E-commerce Enablement & Infrastructure", + "Subscription & Membership Commerce", + "Social Commerce & Influencer Platforms", + "Fashion & Apparel Retail", + "Food, Beverage & Grocery E-commerce", + "Streaming Platforms (Video, Music, Audio)", + "Gaming & Interactive Entertainment", + "Creator Economy & Influencer Platforms", + "Advertising, Adtech & Media Buying", + "Film, TV & Production Studios", + "Events, Venues & Live Entertainment", + "Virtual Worlds & Metaverse Experiences", + "K-12 Education Platforms & Tools", + "Higher Education & University Tech", + "Online Learning & MOOCs", + "Test Prep & Certification", + "Corporate Training & Upskilling", + "Tutoring & Supplemental Learning", + "Education Management Systems (LMS/SIS)", + "Language Learning", + "Creator-Led & Cohort-Based Courses", + "Special Education & Accessibility Tools", + "Government Technology & Digital Services", + "Civic Engagement & Policy Platforms", + "International Development & Humanitarian Aid", + "Philanthropy & Grantmaking", + "Nonprofit Operations & Fundraising Tools", + "Public Health & Social Services", + "Education & Youth Development Programs", + "Environmental & Climate Action Organizations", + "Legal Aid & Social Justice Advocacy", + "Municipal & Infrastructure Services", + "Manufacturing & Industrial Automation", + "Energy Production (Oil, Gas, Nuclear)", + "Renewable Energy & Cleantech", + "Utilities & Grid Infrastructure", + "Industrial IoT & Monitoring Systems", + "Construction & Heavy Equipment", + "Mining & Natural Resources", + "Environmental Engineering & Sustainability", + "Energy Storage & Battery Technology", + "Automotive OEMs & Vehicle Manufacturing", + "Electric Vehicles (EVs) & Charging Infrastructure", + "Mobility-as-a-Service (MaaS)", + "Fleet Management", + "Public Transit & Urban Mobility", + "Autonomous Vehicles & ADAS", + "Aftermarket Parts & Services", + "Telematics & Vehicle Connectivity", + "Aviation & Aerospace Transport", + "Maritime Shipping", + "Fitness & Wellness", + "Beauty & Personal Care", + "Home & Living", + "Dating & Relationships", + "Hobbies, Crafts & DIY", + "Outdoor & Recreational Gear", + "Events, Experiences & Ticketing Platforms", + "Designer & Luxury Apparel", + "Accessories, Jewelry & Watches", + "Footwear & Leather Goods", + "Beauty, Fragrance & Skincare", + "Fashion Marketplaces & Retail Platforms", + "Sustainable & Ethical Fashion", + "Resale, Vintage & Circular Fashion", + "Fashion Tech & Virtual Try-Ons", + "Streetwear & Emerging Luxury", + "Couture & Made-to-Measure", + "News Publishing & Journalism", + "Digital Media & Content Platforms", + "Broadcasting (TV & Radio)", + "Podcasting & Audio Media", + "News Aggregators & Curation Tools", + "Independent & Creator-Led Media", + "Newsletters & Substack-Style Platforms", + "Political & Investigative Media", + "Trade & Niche Publications", + "Media Monitoring & Analytics", + "Professional Teams & Leagues", + "Sports Media & Broadcasting", + "Sports Betting & Fantasy Sports", + "Fitness & Athletic Training Platforms", + "Sportswear & Equipment", + "Esports & Competitive Gaming", + "Sports Venues & Event Management", + "Athlete Management & Talent Agencies", + "Sports Tech & Performance Analytics", + "Youth, Amateur & Collegiate Sports", + "Real Estate Marketplaces", + "Property Management Software", + "Rental Platforms", + "Mortgage & Lending Tech", + "Real Estate Investment Platforms", + "Law Firms & Legal Services", + "Legal Tech & Automation", + "Regulatory Compliance", + "E-Discovery & Litigation Tools", + "Contract Management", + "Governance, Risk & Compliance (GRC)", + "IP & Trademark Management", + "Legal Research & Intelligence", + "Compliance Training & Certification", + "Whistleblower & Ethics Reporting", + "Mobile & Wireless Networks (3G/4G/5G)", + "Broadband & Fiber Internet", + "Satellite & Space-Based Communications", + "Network Equipment & Infrastructure", + "Telecom Billing & OSS/BSS Systems", + "VoIP & Unified Communications", + "Internet Service Providers (ISPs)", + "Edge Computing & Network Virtualization", + "IoT Connectivity Platforms", + "Precision Agriculture & AgTech", + "Crop & Livestock Production", + "Food & Beverage Manufacturing & Processing", + "Food Distribution", + "Restaurants & Food Service", + "Agricultural Inputs & Equipment", + "Sustainable & Regenerative Agriculture", + "Seafood & Aquaculture", + "Management Consulting", + "Marketing & Advertising Agencies", + "Design, Branding & Creative Studios", + "IT Services & Managed Services", + "Staffing, Recruiting & Talent", + "Accounting & Tax Firms", + "Public Relations & Communications", + "Business Process Outsourcing (BPO)", + "Professional Training & Coaching", + "Specialty Chemicals", + "Commodity & Petrochemicals", + "Polymers, Plastics & Rubber", + "Coatings, Adhesives & Sealants", + "Industrial Gases", + "Advanced Materials & Composites", + "Battery Materials & Energy Storage", + "Electronic Materials & Semiconductor Chemicals", + "Agrochemicals & Fertilizers", + "Freight & Transportation Tech", + "Last-Mile Delivery", + "Warehouse Automation", + "Supply Chain Visibility Platforms", + "Logistics Marketplaces", + "Shipping & Freight Forwarding", + "Cold Chain Logistics", + "Reverse Logistics & Returns", + "Cross-Border Trade Tech", + "Transportation Management Systems (TMS)", + "Hotels & Accommodation", + "Vacation Rentals & Short-Term Stays", + "Restaurant Tech & Management", + "Travel Booking Platforms", + "Tourism Experiences & Activities", + "Cruise Lines & Marine Tourism", + "Hospitality Management Systems", + "Event & Venue Management", + "Corporate Travel Management", + "Travel Insurance & Protection", + "Construction Management Software", + "BIM/CAD & Design Tools", + "Construction Marketplaces", + "Equipment Rental & Management", + "Building Materials & Procurement", + "Construction Workforce Management", + "Project Estimation & Bidding", + "Modular & Prefab Construction", + "Construction Safety & Compliance", + "Smart Building Technology", + "Food & Beverage CPG", + "Home & Personal Care CPG", + "CPG Analytics & Insights", + "Direct-to-Consumer CPG Brands", + "CPG Supply Chain & Distribution", + "Private Label Manufacturing", + "CPG Retail Intelligence", + "Sustainable CPG & Packaging", + "Beauty & Cosmetics CPG", + "Health & Wellness CPG", + ] + """Subindustry classification enum""" + + +class BrandIndustries(BaseModel): + eic: Optional[List[BrandIndustriesEic]] = None + """Easy Industry Classification - array of industry and subindustry pairs""" + + +class BrandLinks(BaseModel): + blog: Optional[str] = None + """URL to the brand's blog or news page""" + + careers: Optional[str] = None + """URL to the brand's careers or job opportunities page""" + + contact: Optional[str] = None + """URL to the brand's contact or contact us page""" + + pricing: Optional[str] = None + """URL to the brand's pricing or plans page""" + + privacy: Optional[str] = None + """URL to the brand's privacy policy page""" + + terms: Optional[str] = None + """URL to the brand's terms of service or terms and conditions page""" + + +class BrandLogoColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandLogoResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandLogo(BaseModel): + colors: Optional[List[BrandLogoColor]] = None + """Array of colors in the logo""" + + mode: Optional[Literal["light", "dark", "has_opaque_background"]] = None + """ + Indicates when this logo is best used: 'light' = best for light mode, 'dark' = + best for dark mode, 'has_opaque_background' = can be used for either as image + has its own background + """ + + resolution: Optional[BrandLogoResolution] = None + """Resolution of the logo image""" + + type: Optional[Literal["icon", "logo"]] = None + """Type of the logo based on resolution (e.g., 'icon', 'logo')""" + + url: Optional[str] = None + """CDN hosted url of the logo (ready for display)""" + + +class BrandSocial(BaseModel): + type: Optional[str] = None + """Type of social media, e.g., 'facebook', 'twitter'""" + + url: Optional[str] = None + """URL of the social media page""" + + +class BrandStock(BaseModel): + exchange: Optional[str] = None + """Stock exchange name""" + + ticker: Optional[str] = None + """Stock ticker symbol""" + + +class Brand(BaseModel): + address: Optional[BrandAddress] = None + """Physical address of the brand""" + + backdrops: Optional[List[BrandBackdrop]] = None + """An array of backdrop images for the brand""" + + colors: Optional[List[BrandColor]] = None + """An array of brand colors""" + + description: Optional[str] = None + """A brief description of the brand""" + + domain: Optional[str] = None + """The domain name of the brand""" + + email: Optional[str] = None + """Company email address""" + + industries: Optional[BrandIndustries] = None + """Industry classification information for the brand""" + + is_nsfw: Optional[bool] = None + """Indicates whether the brand content is not safe for work (NSFW)""" + + links: Optional[BrandLinks] = None + """Important website links for the brand""" + + logos: Optional[List[BrandLogo]] = None + """An array of logos associated with the brand""" + + phone: Optional[str] = None + """Company phone number""" + + slogan: Optional[str] = None + """The brand's slogan""" + + socials: Optional[List[BrandSocial]] = None + """An array of social media links for the brand""" + + stock: Optional[BrandStock] = None + """ + Stock market information for this brand (will be null if not a publicly traded + company) + """ + + title: Optional[str] = None + """The title or name of the brand""" + + +class BrandRetrieveByEmailResponse(BaseModel): + brand: Optional[Brand] = None + """Detailed brand information""" + + code: Optional[int] = None + """HTTP status code""" + + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 7467a0a..7dfc647 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -17,6 +17,7 @@ BrandStyleguideResponse, BrandRetrieveNaicsResponse, BrandRetrieveByNameResponse, + BrandRetrieveByEmailResponse, BrandRetrieveByTickerResponse, BrandRetrieveSimplifiedResponse, BrandIdentifyFromTransactionResponse, @@ -240,6 +241,51 @@ def test_streaming_response_prefetch(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_retrieve_by_email(self, client: BrandDev) -> None: + brand = client.brand.retrieve_by_email( + email="dev@stainless.com", + ) + assert_matches_type(BrandRetrieveByEmailResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_retrieve_by_email_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.retrieve_by_email( + email="dev@stainless.com", + force_language="albanian", + max_speed=True, + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveByEmailResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_retrieve_by_email(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.retrieve_by_email( + email="dev@stainless.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandRetrieveByEmailResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_retrieve_by_email(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.retrieve_by_email( + email="dev@stainless.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandRetrieveByEmailResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve_by_name(self, client: BrandDev) -> None: @@ -724,6 +770,51 @@ async def test_streaming_response_prefetch(self, async_client: AsyncBrandDev) -> assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_retrieve_by_email(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_by_email( + email="dev@stainless.com", + ) + assert_matches_type(BrandRetrieveByEmailResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_retrieve_by_email_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_by_email( + email="dev@stainless.com", + force_language="albanian", + max_speed=True, + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveByEmailResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_retrieve_by_email(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.retrieve_by_email( + email="dev@stainless.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandRetrieveByEmailResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_retrieve_by_email(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.retrieve_by_email( + email="dev@stainless.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandRetrieveByEmailResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve_by_name(self, async_client: AsyncBrandDev) -> None: From 180b44da9240288e451105cd0ecd3df24dcfdf74 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:29:14 +0000 Subject: [PATCH 126/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index aeecb0c..26179c7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 11 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-03d0d129690a7e6178f207b283001e66c7ef4a74d340647f18ce21ca3ccd8a1a.yml -openapi_spec_hash: f64086044d57f32cba3ead2151fb4d61 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-6620330945de41f1c453692af40842f08fe1fd281ff6ba4e79d447c941ebd783.yml +openapi_spec_hash: 861a43669d27d942d4bd3e36a398e95b config_hash: 083e432ea397a9018371145493400188 From 0cf859e30ec5e46954e8eec63620981253e8bf86 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:41:05 +0000 Subject: [PATCH 127/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4ce109a..de44c40 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.18.0" + ".": "1.19.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9d6cdd2..019eb48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.18.0" +version = "1.19.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 77f162f..0fa6eb6 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.18.0" # x-release-please-version +__version__ = "1.19.0" # x-release-please-version From 3a338bde3e67ff3c23b7ca3c2cf39e87f7edc039 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 03:13:38 +0000 Subject: [PATCH 128/176] chore(internal): grammar fix (it's -> its) --- src/brand/dev/_utils/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/brand/dev/_utils/_utils.py b/src/brand/dev/_utils/_utils.py index 50d5926..eec7f4a 100644 --- a/src/brand/dev/_utils/_utils.py +++ b/src/brand/dev/_utils/_utils.py @@ -133,7 +133,7 @@ def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: # Type safe methods for narrowing types with TypeVars. # The default narrowing for isinstance(obj, dict) is dict[unknown, unknown], # however this cause Pyright to rightfully report errors. As we know we don't -# care about the contained types we can safely use `object` in it's place. +# care about the contained types we can safely use `object` in its place. # # There are two separate functions defined, `is_*` and `is_*_t` for different use cases. # `is_*` is for when you're dealing with an unknown input From 095860ff8fc2705a8c79ef8dbfac74ba2a3b17fc Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:55:29 +0000 Subject: [PATCH 129/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index de44c40..8255acf 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.19.0" + ".": "1.19.1" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 019eb48..8541931 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.19.0" +version = "1.19.1" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 0fa6eb6..1730d0d 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.19.0" # x-release-please-version +__version__ = "1.19.1" # x-release-please-version From 71f8e3bb4547f081dec8ada795035d7604d9ad85 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 03:12:01 +0000 Subject: [PATCH 130/176] chore(package): drop Python 3.8 support --- README.md | 4 ++-- pyproject.toml | 5 ++--- src/brand/dev/_utils/_sync.py | 34 +++------------------------------- 3 files changed, 7 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 41e137d..5ee938a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![PyPI version](https://img.shields.io/pypi/v/brand.dev.svg?label=pypi%20(stable))](https://pypi.org/project/brand.dev/) -The Brand Dev Python library provides convenient access to the Brand Dev REST API from any Python 3.8+ +The Brand Dev Python library provides convenient access to the Brand Dev REST API from any Python 3.9+ application. The library includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). @@ -396,7 +396,7 @@ print(brand.dev.__version__) ## Requirements -Python 3.8 or higher. +Python 3.9 or higher. ## Contributing diff --git a/pyproject.toml b/pyproject.toml index 8541931..8fea809 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,11 +15,10 @@ dependencies = [ "distro>=1.7.0, <2", "sniffio", ] -requires-python = ">= 3.8" +requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", "Intended Audience :: Developers", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -141,7 +140,7 @@ filterwarnings = [ # there are a couple of flags that are still disabled by # default in strict mode as they are experimental and niche. typeCheckingMode = "strict" -pythonVersion = "3.8" +pythonVersion = "3.9" exclude = [ "_dev", diff --git a/src/brand/dev/_utils/_sync.py b/src/brand/dev/_utils/_sync.py index ad7ec71..f6027c1 100644 --- a/src/brand/dev/_utils/_sync.py +++ b/src/brand/dev/_utils/_sync.py @@ -1,10 +1,8 @@ from __future__ import annotations -import sys import asyncio import functools -import contextvars -from typing import Any, TypeVar, Callable, Awaitable +from typing import TypeVar, Callable, Awaitable from typing_extensions import ParamSpec import anyio @@ -15,34 +13,11 @@ T_ParamSpec = ParamSpec("T_ParamSpec") -if sys.version_info >= (3, 9): - _asyncio_to_thread = asyncio.to_thread -else: - # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread - # for Python 3.8 support - async def _asyncio_to_thread( - func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs - ) -> Any: - """Asynchronously run function *func* in a separate thread. - - Any *args and **kwargs supplied for this function are directly passed - to *func*. Also, the current :class:`contextvars.Context` is propagated, - allowing context variables from the main thread to be accessed in the - separate thread. - - Returns a coroutine that can be awaited to get the eventual result of *func*. - """ - loop = asyncio.events.get_running_loop() - ctx = contextvars.copy_context() - func_call = functools.partial(ctx.run, func, *args, **kwargs) - return await loop.run_in_executor(None, func_call) - - async def to_thread( func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs ) -> T_Retval: if sniffio.current_async_library() == "asyncio": - return await _asyncio_to_thread(func, *args, **kwargs) + return await asyncio.to_thread(func, *args, **kwargs) return await anyio.to_thread.run_sync( functools.partial(func, *args, **kwargs), @@ -53,10 +28,7 @@ async def to_thread( def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: """ Take a blocking function and create an async one that receives the same - positional and keyword arguments. For python version 3.9 and above, it uses - asyncio.to_thread to run the function in a separate thread. For python version - 3.8, it uses locally defined copy of the asyncio.to_thread function which was - introduced in python 3.9. + positional and keyword arguments. Usage: From aa087f63b1ab7958cca8f499aed0486a43694a14 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 03:12:36 +0000 Subject: [PATCH 131/176] fix: compat with Python 3.14 --- src/brand/dev/_models.py | 11 ++++++++--- tests/test_models.py | 8 ++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/brand/dev/_models.py b/src/brand/dev/_models.py index 6a3cd1d..fcec2cf 100644 --- a/src/brand/dev/_models.py +++ b/src/brand/dev/_models.py @@ -2,6 +2,7 @@ import os import inspect +import weakref from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast from datetime import date, datetime from typing_extensions import ( @@ -573,6 +574,9 @@ class CachedDiscriminatorType(Protocol): __discriminator__: DiscriminatorDetails +DISCRIMINATOR_CACHE: weakref.WeakKeyDictionary[type, DiscriminatorDetails] = weakref.WeakKeyDictionary() + + class DiscriminatorDetails: field_name: str """The name of the discriminator field in the variant class, e.g. @@ -615,8 +619,9 @@ def __init__( def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None: - if isinstance(union, CachedDiscriminatorType): - return union.__discriminator__ + cached = DISCRIMINATOR_CACHE.get(union) + if cached is not None: + return cached discriminator_field_name: str | None = None @@ -669,7 +674,7 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, discriminator_field=discriminator_field_name, discriminator_alias=discriminator_alias, ) - cast(CachedDiscriminatorType, union).__discriminator__ = details + DISCRIMINATOR_CACHE.setdefault(union, details) return details diff --git a/tests/test_models.py b/tests/test_models.py index 44e5d4c..66e3565 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,7 +9,7 @@ from brand.dev._utils import PropertyInfo from brand.dev._compat import PYDANTIC_V1, parse_obj, model_dump, model_json -from brand.dev._models import BaseModel, construct_type +from brand.dev._models import DISCRIMINATOR_CACHE, BaseModel, construct_type class BasicModel(BaseModel): @@ -809,7 +809,7 @@ class B(BaseModel): UnionType = cast(Any, Union[A, B]) - assert not hasattr(UnionType, "__discriminator__") + assert not DISCRIMINATOR_CACHE.get(UnionType) m = construct_type( value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) @@ -818,7 +818,7 @@ class B(BaseModel): assert m.type == "b" assert m.data == "foo" # type: ignore[comparison-overlap] - discriminator = UnionType.__discriminator__ + discriminator = DISCRIMINATOR_CACHE.get(UnionType) assert discriminator is not None m = construct_type( @@ -830,7 +830,7 @@ class B(BaseModel): # if the discriminator details object stays the same between invocations then # we hit the cache - assert UnionType.__discriminator__ is discriminator + assert DISCRIMINATOR_CACHE.get(UnionType) is discriminator @pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") From 4c1c27d4b05c094f19d19465d83e122d7ce153c1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 03:10:08 +0000 Subject: [PATCH 132/176] fix(compat): update signatures of `model_dump` and `model_dump_json` for Pydantic v1 --- src/brand/dev/_models.py | 41 ++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/brand/dev/_models.py b/src/brand/dev/_models.py index fcec2cf..ca9500b 100644 --- a/src/brand/dev/_models.py +++ b/src/brand/dev/_models.py @@ -257,15 +257,16 @@ def model_dump( mode: Literal["json", "python"] | str = "python", include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, - serialize_as_any: bool = False, fallback: Callable[[Any], Any] | None = None, + serialize_as_any: bool = False, ) -> dict[str, Any]: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump @@ -273,16 +274,24 @@ def model_dump( Args: mode: The mode in which `to_python` should run. - If mode is 'json', the dictionary will only contain JSON serializable types. - If mode is 'python', the dictionary may contain any Python objects. - include: A list of fields to include in the output. - exclude: A list of fields to exclude from the output. + If mode is 'json', the output will only contain JSON serializable types. + If mode is 'python', the output may contain non-JSON-serializable Python objects. + include: A set of fields to include in the output. + exclude: A set of fields to exclude from the output. + context: Additional context to pass to the serializer. by_alias: Whether to use the field's alias in the dictionary key if defined. - exclude_unset: Whether to exclude fields that are unset or None from the output. - exclude_defaults: Whether to exclude fields that are set to their default value from the output. - exclude_none: Whether to exclude fields that have a value of `None` from the output. - round_trip: Whether to enable serialization and deserialization round-trip support. - warnings: Whether to log warnings when invalid fields are encountered. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value. + exclude_none: Whether to exclude fields that have a value of `None`. + exclude_computed_fields: Whether to exclude computed fields. + While this can be useful for round-tripping, it is usually recommended to use the dedicated + `round_trip` parameter instead. + round_trip: If True, dumped values should be valid as input for non-idempotent types such as Json[T]. + warnings: How to handle serialization errors. False/"none" ignores them, True/"warn" logs errors, + "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError]. + fallback: A function to call when an unknown value is encountered. If not provided, + a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised. + serialize_as_any: Whether to serialize fields with duck-typing serialization behavior. Returns: A dictionary representation of the model. @@ -299,6 +308,8 @@ def model_dump( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") dumped = super().dict( # pyright: ignore[reportDeprecated] include=include, exclude=exclude, @@ -315,15 +326,17 @@ def model_dump_json( self, *, indent: int | None = None, + ensure_ascii: bool = False, include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, ) -> str: @@ -355,6 +368,10 @@ def model_dump_json( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if ensure_ascii != False: + raise ValueError("ensure_ascii is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") return super().json( # type: ignore[reportDeprecated] indent=indent, include=include, From b19d6a0e6d6fa01612a5ffde29778153d46f030e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:53:18 +0000 Subject: [PATCH 133/176] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 26179c7..4fd8ad5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 11 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-6620330945de41f1c453692af40842f08fe1fd281ff6ba4e79d447c941ebd783.yml openapi_spec_hash: 861a43669d27d942d4bd3e36a398e95b -config_hash: 083e432ea397a9018371145493400188 +config_hash: b842002500dc1c97db29148912365e84 From f7dbe18708c892c8f8dff6a147beea5a39e5d83c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:21:44 +0000 Subject: [PATCH 134/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 4fd8ad5..9149116 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 11 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-6620330945de41f1c453692af40842f08fe1fd281ff6ba4e79d447c941ebd783.yml -openapi_spec_hash: 861a43669d27d942d4bd3e36a398e95b +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-070cac50467cd07e8601e948fc97e3c82ea0630d6a0b0f24164335e396893e6a.yml +openapi_spec_hash: b887bcfe688f72b3a34ee24246d12955 config_hash: b842002500dc1c97db29148912365e84 From 1289ec2064288da731a78c01f886572f52542c9e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 01:40:16 +0000 Subject: [PATCH 135/176] feat(api): manual updates --- .stats.yml | 4 +- api.md | 2 + src/brand/dev/resources/brand.py | 246 +++++++++ src/brand/dev/types/__init__.py | 2 + .../types/brand_retrieve_by_isin_params.py | 88 ++++ .../types/brand_retrieve_by_isin_response.py | 481 ++++++++++++++++++ tests/api_resources/test_brand.py | 91 ++++ 7 files changed, 912 insertions(+), 2 deletions(-) create mode 100644 src/brand/dev/types/brand_retrieve_by_isin_params.py create mode 100644 src/brand/dev/types/brand_retrieve_by_isin_response.py diff --git a/.stats.yml b/.stats.yml index 9149116..561a7a7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 11 +configured_endpoints: 12 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-070cac50467cd07e8601e948fc97e3c82ea0630d6a0b0f24164335e396893e6a.yml openapi_spec_hash: b887bcfe688f72b3a34ee24246d12955 -config_hash: b842002500dc1c97db29148912365e84 +config_hash: 86160e220c81f47769a71c9343e486d8 diff --git a/api.md b/api.md index cec128a..cd8c7b2 100644 --- a/api.md +++ b/api.md @@ -9,6 +9,7 @@ from brand.dev.types import ( BrandIdentifyFromTransactionResponse, BrandPrefetchResponse, BrandRetrieveByEmailResponse, + BrandRetrieveByIsinResponse, BrandRetrieveByNameResponse, BrandRetrieveByTickerResponse, BrandRetrieveNaicsResponse, @@ -25,6 +26,7 @@ Methods: - client.brand.identify_from_transaction(\*\*params) -> BrandIdentifyFromTransactionResponse - client.brand.prefetch(\*\*params) -> BrandPrefetchResponse - client.brand.retrieve_by_email(\*\*params) -> BrandRetrieveByEmailResponse +- client.brand.retrieve_by_isin(\*\*params) -> BrandRetrieveByIsinResponse - client.brand.retrieve_by_name(\*\*params) -> BrandRetrieveByNameResponse - client.brand.retrieve_by_ticker(\*\*params) -> BrandRetrieveByTickerResponse - client.brand.retrieve_naics(\*\*params) -> BrandRetrieveNaicsResponse diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 8749438..5574c02 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -14,6 +14,7 @@ brand_screenshot_params, brand_styleguide_params, brand_retrieve_naics_params, + brand_retrieve_by_isin_params, brand_retrieve_by_name_params, brand_retrieve_by_email_params, brand_retrieve_by_ticker_params, @@ -37,6 +38,7 @@ from ..types.brand_screenshot_response import BrandScreenshotResponse from ..types.brand_styleguide_response import BrandStyleguideResponse from ..types.brand_retrieve_naics_response import BrandRetrieveNaicsResponse +from ..types.brand_retrieve_by_isin_response import BrandRetrieveByIsinResponse from ..types.brand_retrieve_by_name_response import BrandRetrieveByNameResponse from ..types.brand_retrieve_by_email_response import BrandRetrieveByEmailResponse from ..types.brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse @@ -515,6 +517,122 @@ def retrieve_by_email( cast_to=BrandRetrieveByEmailResponse, ) + def retrieve_by_isin( + self, + *, + isin: str, + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + | Omit = omit, + max_speed: bool | Omit = omit, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandRetrieveByIsinResponse: + """ + Retrieve brand information using an ISIN (International Securities + Identification Number). This endpoint looks up the company associated with the + ISIN and returns its brand data. + + Args: + isin: ISIN (International Securities Identification Number) to retrieve brand data for + (e.g., 'AU000000IMD5', 'US0378331005'). Must be exactly 12 characters: 2 letters + followed by 9 alphanumeric characters and ending with a digit. + + force_language: Optional parameter to force the language of the retrieved brand data. + + max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, + the API will skip time-consuming operations for faster response at the cost of + less comprehensive data. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/retrieve-by-isin", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "isin": isin, + "force_language": force_language, + "max_speed": max_speed, + "timeout_ms": timeout_ms, + }, + brand_retrieve_by_isin_params.BrandRetrieveByIsinParams, + ), + ), + cast_to=BrandRetrieveByIsinResponse, + ) + def retrieve_by_name( self, *, @@ -1517,6 +1635,122 @@ async def retrieve_by_email( cast_to=BrandRetrieveByEmailResponse, ) + async def retrieve_by_isin( + self, + *, + isin: str, + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + | Omit = omit, + max_speed: bool | Omit = omit, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandRetrieveByIsinResponse: + """ + Retrieve brand information using an ISIN (International Securities + Identification Number). This endpoint looks up the company associated with the + ISIN and returns its brand data. + + Args: + isin: ISIN (International Securities Identification Number) to retrieve brand data for + (e.g., 'AU000000IMD5', 'US0378331005'). Must be exactly 12 characters: 2 letters + followed by 9 alphanumeric characters and ending with a digit. + + force_language: Optional parameter to force the language of the retrieved brand data. + + max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, + the API will skip time-consuming operations for faster response at the cost of + less comprehensive data. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/brand/retrieve-by-isin", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "isin": isin, + "force_language": force_language, + "max_speed": max_speed, + "timeout_ms": timeout_ms, + }, + brand_retrieve_by_isin_params.BrandRetrieveByIsinParams, + ), + ), + cast_to=BrandRetrieveByIsinResponse, + ) + async def retrieve_by_name( self, *, @@ -2069,6 +2303,9 @@ def __init__(self, brand: BrandResource) -> None: self.retrieve_by_email = to_raw_response_wrapper( brand.retrieve_by_email, ) + self.retrieve_by_isin = to_raw_response_wrapper( + brand.retrieve_by_isin, + ) self.retrieve_by_name = to_raw_response_wrapper( brand.retrieve_by_name, ) @@ -2108,6 +2345,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.retrieve_by_email = async_to_raw_response_wrapper( brand.retrieve_by_email, ) + self.retrieve_by_isin = async_to_raw_response_wrapper( + brand.retrieve_by_isin, + ) self.retrieve_by_name = async_to_raw_response_wrapper( brand.retrieve_by_name, ) @@ -2147,6 +2387,9 @@ def __init__(self, brand: BrandResource) -> None: self.retrieve_by_email = to_streamed_response_wrapper( brand.retrieve_by_email, ) + self.retrieve_by_isin = to_streamed_response_wrapper( + brand.retrieve_by_isin, + ) self.retrieve_by_name = to_streamed_response_wrapper( brand.retrieve_by_name, ) @@ -2186,6 +2429,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.retrieve_by_email = async_to_streamed_response_wrapper( brand.retrieve_by_email, ) + self.retrieve_by_isin = async_to_streamed_response_wrapper( + brand.retrieve_by_isin, + ) self.retrieve_by_name = async_to_streamed_response_wrapper( brand.retrieve_by_name, ) diff --git a/src/brand/dev/types/__init__.py b/src/brand/dev/types/__init__.py index bd36eb4..eae55c9 100644 --- a/src/brand/dev/types/__init__.py +++ b/src/brand/dev/types/__init__.py @@ -13,9 +13,11 @@ from .brand_screenshot_response import BrandScreenshotResponse as BrandScreenshotResponse from .brand_styleguide_response import BrandStyleguideResponse as BrandStyleguideResponse from .brand_retrieve_naics_params import BrandRetrieveNaicsParams as BrandRetrieveNaicsParams +from .brand_retrieve_by_isin_params import BrandRetrieveByIsinParams as BrandRetrieveByIsinParams from .brand_retrieve_by_name_params import BrandRetrieveByNameParams as BrandRetrieveByNameParams from .brand_retrieve_naics_response import BrandRetrieveNaicsResponse as BrandRetrieveNaicsResponse from .brand_retrieve_by_email_params import BrandRetrieveByEmailParams as BrandRetrieveByEmailParams +from .brand_retrieve_by_isin_response import BrandRetrieveByIsinResponse as BrandRetrieveByIsinResponse from .brand_retrieve_by_name_response import BrandRetrieveByNameResponse as BrandRetrieveByNameResponse from .brand_retrieve_by_ticker_params import BrandRetrieveByTickerParams as BrandRetrieveByTickerParams from .brand_retrieve_by_email_response import BrandRetrieveByEmailResponse as BrandRetrieveByEmailResponse diff --git a/src/brand/dev/types/brand_retrieve_by_isin_params.py b/src/brand/dev/types/brand_retrieve_by_isin_params.py new file mode 100644 index 0000000..db2731b --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_by_isin_params.py @@ -0,0 +1,88 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["BrandRetrieveByIsinParams"] + + +class BrandRetrieveByIsinParams(TypedDict, total=False): + isin: Required[str] + """ + ISIN (International Securities Identification Number) to retrieve brand data for + (e.g., 'AU000000IMD5', 'US0378331005'). Must be exactly 12 characters: 2 letters + followed by 9 alphanumeric characters and ending with a digit. + """ + + force_language: Literal[ + "albanian", + "arabic", + "azeri", + "bengali", + "bulgarian", + "cebuano", + "croatian", + "czech", + "danish", + "dutch", + "english", + "estonian", + "farsi", + "finnish", + "french", + "german", + "hausa", + "hawaiian", + "hindi", + "hungarian", + "icelandic", + "indonesian", + "italian", + "kazakh", + "kyrgyz", + "latin", + "latvian", + "lithuanian", + "macedonian", + "mongolian", + "nepali", + "norwegian", + "pashto", + "pidgin", + "polish", + "portuguese", + "romanian", + "russian", + "serbian", + "slovak", + "slovene", + "somali", + "spanish", + "swahili", + "swedish", + "tagalog", + "turkish", + "ukrainian", + "urdu", + "uzbek", + "vietnamese", + "welsh", + ] + """Optional parameter to force the language of the retrieved brand data.""" + + max_speed: Annotated[bool, PropertyInfo(alias="maxSpeed")] + """Optional parameter to optimize the API call for maximum speed. + + When set to true, the API will skip time-consuming operations for faster + response at the cost of less comprehensive data. + """ + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_retrieve_by_isin_response.py b/src/brand/dev/types/brand_retrieve_by_isin_response.py new file mode 100644 index 0000000..a9bbe4d --- /dev/null +++ b/src/brand/dev/types/brand_retrieve_by_isin_response.py @@ -0,0 +1,481 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = [ + "BrandRetrieveByIsinResponse", + "Brand", + "BrandAddress", + "BrandBackdrop", + "BrandBackdropColor", + "BrandBackdropResolution", + "BrandColor", + "BrandIndustries", + "BrandIndustriesEic", + "BrandLinks", + "BrandLogo", + "BrandLogoColor", + "BrandLogoResolution", + "BrandSocial", + "BrandStock", +] + + +class BrandAddress(BaseModel): + city: Optional[str] = None + """City name""" + + country: Optional[str] = None + """Country name""" + + country_code: Optional[str] = None + """Country code""" + + postal_code: Optional[str] = None + """Postal or ZIP code""" + + state_code: Optional[str] = None + """State or province code""" + + state_province: Optional[str] = None + """State or province name""" + + street: Optional[str] = None + """Street address""" + + +class BrandBackdropColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandBackdropResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandBackdrop(BaseModel): + colors: Optional[List[BrandBackdropColor]] = None + """Array of colors in the backdrop image""" + + resolution: Optional[BrandBackdropResolution] = None + """Resolution of the backdrop image""" + + url: Optional[str] = None + """URL of the backdrop image""" + + +class BrandColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandIndustriesEic(BaseModel): + industry: Literal[ + "Aerospace & Defense", + "Technology", + "Finance", + "Healthcare", + "Retail & E-commerce", + "Entertainment", + "Education", + "Government & Nonprofit", + "Industrial & Energy", + "Automotive & Transportation", + "Lifestyle & Leisure", + "Luxury & Fashion", + "News & Media", + "Sports", + "Real Estate & PropTech", + "Legal & Compliance", + "Telecommunications", + "Agriculture & Food", + "Professional Services & Agencies", + "Chemicals & Materials", + "Logistics & Supply Chain", + "Hospitality & Tourism", + "Construction & Built Environment", + "Consumer Packaged Goods (CPG)", + ] + """Industry classification enum""" + + subindustry: Literal[ + "Defense Systems & Military Hardware", + "Aerospace Manufacturing", + "Avionics & Navigation Technology", + "Subsea & Naval Defense Systems", + "Space & Satellite Technology", + "Defense IT & Systems Integration", + "Software (B2B)", + "Software (B2C)", + "Cloud Infrastructure & DevOps", + "Cybersecurity", + "Artificial Intelligence & Machine Learning", + "Data Infrastructure & Analytics", + "Hardware & Semiconductors", + "Fintech Infrastructure", + "eCommerce & Marketplace Platforms", + "Developer Tools & APIs", + "Web3 & Blockchain", + "XR & Spatial Computing", + "Banking & Lending", + "Investment Management & WealthTech", + "Insurance & InsurTech", + "Payments & Money Movement", + "Accounting, Tax & Financial Planning Tools", + "Capital Markets & Trading Platforms", + "Financial Infrastructure & APIs", + "Credit Scoring & Risk Management", + "Cryptocurrency & Digital Assets", + "BNPL & Alternative Financing", + "Healthcare Providers & Services", + "Pharmaceuticals & Drug Development", + "Medical Devices & Diagnostics", + "Biotechnology & Genomics", + "Digital Health & Telemedicine", + "Health Insurance & Benefits Tech", + "Clinical Trials & Research Platforms", + "Mental Health & Wellness", + "Healthcare IT & EHR Systems", + "Consumer Health & Wellness Products", + "Online Marketplaces", + "Direct-to-Consumer (DTC) Brands", + "Retail Tech & Point-of-Sale Systems", + "Omnichannel & In-Store Retail", + "E-commerce Enablement & Infrastructure", + "Subscription & Membership Commerce", + "Social Commerce & Influencer Platforms", + "Fashion & Apparel Retail", + "Food, Beverage & Grocery E-commerce", + "Streaming Platforms (Video, Music, Audio)", + "Gaming & Interactive Entertainment", + "Creator Economy & Influencer Platforms", + "Advertising, Adtech & Media Buying", + "Film, TV & Production Studios", + "Events, Venues & Live Entertainment", + "Virtual Worlds & Metaverse Experiences", + "K-12 Education Platforms & Tools", + "Higher Education & University Tech", + "Online Learning & MOOCs", + "Test Prep & Certification", + "Corporate Training & Upskilling", + "Tutoring & Supplemental Learning", + "Education Management Systems (LMS/SIS)", + "Language Learning", + "Creator-Led & Cohort-Based Courses", + "Special Education & Accessibility Tools", + "Government Technology & Digital Services", + "Civic Engagement & Policy Platforms", + "International Development & Humanitarian Aid", + "Philanthropy & Grantmaking", + "Nonprofit Operations & Fundraising Tools", + "Public Health & Social Services", + "Education & Youth Development Programs", + "Environmental & Climate Action Organizations", + "Legal Aid & Social Justice Advocacy", + "Municipal & Infrastructure Services", + "Manufacturing & Industrial Automation", + "Energy Production (Oil, Gas, Nuclear)", + "Renewable Energy & Cleantech", + "Utilities & Grid Infrastructure", + "Industrial IoT & Monitoring Systems", + "Construction & Heavy Equipment", + "Mining & Natural Resources", + "Environmental Engineering & Sustainability", + "Energy Storage & Battery Technology", + "Automotive OEMs & Vehicle Manufacturing", + "Electric Vehicles (EVs) & Charging Infrastructure", + "Mobility-as-a-Service (MaaS)", + "Fleet Management", + "Public Transit & Urban Mobility", + "Autonomous Vehicles & ADAS", + "Aftermarket Parts & Services", + "Telematics & Vehicle Connectivity", + "Aviation & Aerospace Transport", + "Maritime Shipping", + "Fitness & Wellness", + "Beauty & Personal Care", + "Home & Living", + "Dating & Relationships", + "Hobbies, Crafts & DIY", + "Outdoor & Recreational Gear", + "Events, Experiences & Ticketing Platforms", + "Designer & Luxury Apparel", + "Accessories, Jewelry & Watches", + "Footwear & Leather Goods", + "Beauty, Fragrance & Skincare", + "Fashion Marketplaces & Retail Platforms", + "Sustainable & Ethical Fashion", + "Resale, Vintage & Circular Fashion", + "Fashion Tech & Virtual Try-Ons", + "Streetwear & Emerging Luxury", + "Couture & Made-to-Measure", + "News Publishing & Journalism", + "Digital Media & Content Platforms", + "Broadcasting (TV & Radio)", + "Podcasting & Audio Media", + "News Aggregators & Curation Tools", + "Independent & Creator-Led Media", + "Newsletters & Substack-Style Platforms", + "Political & Investigative Media", + "Trade & Niche Publications", + "Media Monitoring & Analytics", + "Professional Teams & Leagues", + "Sports Media & Broadcasting", + "Sports Betting & Fantasy Sports", + "Fitness & Athletic Training Platforms", + "Sportswear & Equipment", + "Esports & Competitive Gaming", + "Sports Venues & Event Management", + "Athlete Management & Talent Agencies", + "Sports Tech & Performance Analytics", + "Youth, Amateur & Collegiate Sports", + "Real Estate Marketplaces", + "Property Management Software", + "Rental Platforms", + "Mortgage & Lending Tech", + "Real Estate Investment Platforms", + "Law Firms & Legal Services", + "Legal Tech & Automation", + "Regulatory Compliance", + "E-Discovery & Litigation Tools", + "Contract Management", + "Governance, Risk & Compliance (GRC)", + "IP & Trademark Management", + "Legal Research & Intelligence", + "Compliance Training & Certification", + "Whistleblower & Ethics Reporting", + "Mobile & Wireless Networks (3G/4G/5G)", + "Broadband & Fiber Internet", + "Satellite & Space-Based Communications", + "Network Equipment & Infrastructure", + "Telecom Billing & OSS/BSS Systems", + "VoIP & Unified Communications", + "Internet Service Providers (ISPs)", + "Edge Computing & Network Virtualization", + "IoT Connectivity Platforms", + "Precision Agriculture & AgTech", + "Crop & Livestock Production", + "Food & Beverage Manufacturing & Processing", + "Food Distribution", + "Restaurants & Food Service", + "Agricultural Inputs & Equipment", + "Sustainable & Regenerative Agriculture", + "Seafood & Aquaculture", + "Management Consulting", + "Marketing & Advertising Agencies", + "Design, Branding & Creative Studios", + "IT Services & Managed Services", + "Staffing, Recruiting & Talent", + "Accounting & Tax Firms", + "Public Relations & Communications", + "Business Process Outsourcing (BPO)", + "Professional Training & Coaching", + "Specialty Chemicals", + "Commodity & Petrochemicals", + "Polymers, Plastics & Rubber", + "Coatings, Adhesives & Sealants", + "Industrial Gases", + "Advanced Materials & Composites", + "Battery Materials & Energy Storage", + "Electronic Materials & Semiconductor Chemicals", + "Agrochemicals & Fertilizers", + "Freight & Transportation Tech", + "Last-Mile Delivery", + "Warehouse Automation", + "Supply Chain Visibility Platforms", + "Logistics Marketplaces", + "Shipping & Freight Forwarding", + "Cold Chain Logistics", + "Reverse Logistics & Returns", + "Cross-Border Trade Tech", + "Transportation Management Systems (TMS)", + "Hotels & Accommodation", + "Vacation Rentals & Short-Term Stays", + "Restaurant Tech & Management", + "Travel Booking Platforms", + "Tourism Experiences & Activities", + "Cruise Lines & Marine Tourism", + "Hospitality Management Systems", + "Event & Venue Management", + "Corporate Travel Management", + "Travel Insurance & Protection", + "Construction Management Software", + "BIM/CAD & Design Tools", + "Construction Marketplaces", + "Equipment Rental & Management", + "Building Materials & Procurement", + "Construction Workforce Management", + "Project Estimation & Bidding", + "Modular & Prefab Construction", + "Construction Safety & Compliance", + "Smart Building Technology", + "Food & Beverage CPG", + "Home & Personal Care CPG", + "CPG Analytics & Insights", + "Direct-to-Consumer CPG Brands", + "CPG Supply Chain & Distribution", + "Private Label Manufacturing", + "CPG Retail Intelligence", + "Sustainable CPG & Packaging", + "Beauty & Cosmetics CPG", + "Health & Wellness CPG", + ] + """Subindustry classification enum""" + + +class BrandIndustries(BaseModel): + eic: Optional[List[BrandIndustriesEic]] = None + """Easy Industry Classification - array of industry and subindustry pairs""" + + +class BrandLinks(BaseModel): + blog: Optional[str] = None + """URL to the brand's blog or news page""" + + careers: Optional[str] = None + """URL to the brand's careers or job opportunities page""" + + contact: Optional[str] = None + """URL to the brand's contact or contact us page""" + + pricing: Optional[str] = None + """URL to the brand's pricing or plans page""" + + privacy: Optional[str] = None + """URL to the brand's privacy policy page""" + + terms: Optional[str] = None + """URL to the brand's terms of service or terms and conditions page""" + + +class BrandLogoColor(BaseModel): + hex: Optional[str] = None + """Color in hexadecimal format""" + + name: Optional[str] = None + """Name of the color""" + + +class BrandLogoResolution(BaseModel): + aspect_ratio: Optional[float] = None + """Aspect ratio of the image (width/height)""" + + height: Optional[int] = None + """Height of the image in pixels""" + + width: Optional[int] = None + """Width of the image in pixels""" + + +class BrandLogo(BaseModel): + colors: Optional[List[BrandLogoColor]] = None + """Array of colors in the logo""" + + mode: Optional[Literal["light", "dark", "has_opaque_background"]] = None + """ + Indicates when this logo is best used: 'light' = best for light mode, 'dark' = + best for dark mode, 'has_opaque_background' = can be used for either as image + has its own background + """ + + resolution: Optional[BrandLogoResolution] = None + """Resolution of the logo image""" + + type: Optional[Literal["icon", "logo"]] = None + """Type of the logo based on resolution (e.g., 'icon', 'logo')""" + + url: Optional[str] = None + """CDN hosted url of the logo (ready for display)""" + + +class BrandSocial(BaseModel): + type: Optional[str] = None + """Type of social media, e.g., 'facebook', 'twitter'""" + + url: Optional[str] = None + """URL of the social media page""" + + +class BrandStock(BaseModel): + exchange: Optional[str] = None + """Stock exchange name""" + + ticker: Optional[str] = None + """Stock ticker symbol""" + + +class Brand(BaseModel): + address: Optional[BrandAddress] = None + """Physical address of the brand""" + + backdrops: Optional[List[BrandBackdrop]] = None + """An array of backdrop images for the brand""" + + colors: Optional[List[BrandColor]] = None + """An array of brand colors""" + + description: Optional[str] = None + """A brief description of the brand""" + + domain: Optional[str] = None + """The domain name of the brand""" + + email: Optional[str] = None + """Company email address""" + + industries: Optional[BrandIndustries] = None + """Industry classification information for the brand""" + + is_nsfw: Optional[bool] = None + """Indicates whether the brand content is not safe for work (NSFW)""" + + links: Optional[BrandLinks] = None + """Important website links for the brand""" + + logos: Optional[List[BrandLogo]] = None + """An array of logos associated with the brand""" + + phone: Optional[str] = None + """Company phone number""" + + slogan: Optional[str] = None + """The brand's slogan""" + + socials: Optional[List[BrandSocial]] = None + """An array of social media links for the brand""" + + stock: Optional[BrandStock] = None + """ + Stock market information for this brand (will be null if not a publicly traded + company) + """ + + title: Optional[str] = None + """The title or name of the brand""" + + +class BrandRetrieveByIsinResponse(BaseModel): + brand: Optional[Brand] = None + """Detailed brand information""" + + code: Optional[int] = None + """HTTP status code""" + + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 7dfc647..2963c49 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -16,6 +16,7 @@ BrandScreenshotResponse, BrandStyleguideResponse, BrandRetrieveNaicsResponse, + BrandRetrieveByIsinResponse, BrandRetrieveByNameResponse, BrandRetrieveByEmailResponse, BrandRetrieveByTickerResponse, @@ -286,6 +287,51 @@ def test_streaming_response_retrieve_by_email(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_retrieve_by_isin(self, client: BrandDev) -> None: + brand = client.brand.retrieve_by_isin( + isin="SE60513A9993", + ) + assert_matches_type(BrandRetrieveByIsinResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_retrieve_by_isin_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.retrieve_by_isin( + isin="SE60513A9993", + force_language="albanian", + max_speed=True, + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveByIsinResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_retrieve_by_isin(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.retrieve_by_isin( + isin="SE60513A9993", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandRetrieveByIsinResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_retrieve_by_isin(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.retrieve_by_isin( + isin="SE60513A9993", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandRetrieveByIsinResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve_by_name(self, client: BrandDev) -> None: @@ -815,6 +861,51 @@ async def test_streaming_response_retrieve_by_email(self, async_client: AsyncBra assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_retrieve_by_isin(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_by_isin( + isin="SE60513A9993", + ) + assert_matches_type(BrandRetrieveByIsinResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_retrieve_by_isin_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.retrieve_by_isin( + isin="SE60513A9993", + force_language="albanian", + max_speed=True, + timeout_ms=1, + ) + assert_matches_type(BrandRetrieveByIsinResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_retrieve_by_isin(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.retrieve_by_isin( + isin="SE60513A9993", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandRetrieveByIsinResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_retrieve_by_isin(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.retrieve_by_isin( + isin="SE60513A9993", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandRetrieveByIsinResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve_by_name(self, async_client: AsyncBrandDev) -> None: From af810b7ec9e1fa5add095ffc2acd407d2ad4f970 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 02:05:01 +0000 Subject: [PATCH 136/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 8255acf..69eb19a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.19.1" + ".": "1.20.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8fea809..5667b40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.19.1" +version = "1.20.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 1730d0d..ab7552d 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.19.1" # x-release-please-version +__version__ = "1.20.0" # x-release-please-version From 9f9a0466b1fe8d0c7a018a9e2a8318ebcc52afad Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 22 Nov 2025 03:09:44 +0000 Subject: [PATCH 137/176] chore: add Python 3.14 classifier and testing --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 5667b40..23bc5c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", From a90c74272962b9596c50ed05bb52ab97360365f7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:42:19 +0000 Subject: [PATCH 138/176] feat(api): api update --- .stats.yml | 4 +- src/brand/dev/resources/brand.py | 510 ++++++++++++++++++ .../brand_identify_from_transaction_params.py | 255 +++++++++ tests/api_resources/test_brand.py | 6 + 4 files changed, 773 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 561a7a7..9c441da 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 12 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-070cac50467cd07e8601e948fc97e3c82ea0630d6a0b0f24164335e396893e6a.yml -openapi_spec_hash: b887bcfe688f72b3a34ee24246d12955 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-4eb121d211177902ec0685d8829af778dbc02a21df03362e8675a8bcb4ca10a8.yml +openapi_spec_hash: b10e8626e812050d8d0664b5373d7fe4 config_hash: 86160e220c81f47769a71c9343e486d8 diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 5574c02..6ba3f47 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -243,6 +243,249 @@ def identify_from_transaction( self, *, transaction_info: str, + city: str | Omit = omit, + country_gl: Literal[ + "ad", + "ae", + "af", + "ag", + "ai", + "al", + "am", + "an", + "ao", + "aq", + "ar", + "as", + "at", + "au", + "aw", + "az", + "ba", + "bb", + "bd", + "be", + "bf", + "bg", + "bh", + "bi", + "bj", + "bm", + "bn", + "bo", + "br", + "bs", + "bt", + "bv", + "bw", + "by", + "bz", + "ca", + "cc", + "cd", + "cf", + "cg", + "ch", + "ci", + "ck", + "cl", + "cm", + "cn", + "co", + "cr", + "cu", + "cv", + "cx", + "cy", + "cz", + "de", + "dj", + "dk", + "dm", + "do", + "dz", + "ec", + "ee", + "eg", + "eh", + "er", + "es", + "et", + "fi", + "fj", + "fk", + "fm", + "fo", + "fr", + "ga", + "gb", + "gd", + "ge", + "gf", + "gh", + "gi", + "gl", + "gm", + "gn", + "gp", + "gq", + "gr", + "gs", + "gt", + "gu", + "gw", + "gy", + "hk", + "hm", + "hn", + "hr", + "ht", + "hu", + "id", + "ie", + "il", + "in", + "io", + "iq", + "ir", + "is", + "it", + "jm", + "jo", + "jp", + "ke", + "kg", + "kh", + "ki", + "km", + "kn", + "kp", + "kr", + "kw", + "ky", + "kz", + "la", + "lb", + "lc", + "li", + "lk", + "lr", + "ls", + "lt", + "lu", + "lv", + "ly", + "ma", + "mc", + "md", + "mg", + "mh", + "mk", + "ml", + "mm", + "mn", + "mo", + "mp", + "mq", + "mr", + "ms", + "mt", + "mu", + "mv", + "mw", + "mx", + "my", + "mz", + "na", + "nc", + "ne", + "nf", + "ng", + "ni", + "nl", + "no", + "np", + "nr", + "nu", + "nz", + "om", + "pa", + "pe", + "pf", + "pg", + "ph", + "pk", + "pl", + "pm", + "pn", + "pr", + "ps", + "pt", + "pw", + "py", + "qa", + "re", + "ro", + "rs", + "ru", + "rw", + "sa", + "sb", + "sc", + "sd", + "se", + "sg", + "sh", + "si", + "sj", + "sk", + "sl", + "sm", + "sn", + "so", + "sr", + "st", + "sv", + "sy", + "sz", + "tc", + "td", + "tf", + "tg", + "th", + "tj", + "tk", + "tl", + "tm", + "tn", + "to", + "tr", + "tt", + "tv", + "tw", + "tz", + "ua", + "ug", + "um", + "us", + "uy", + "uz", + "va", + "vc", + "ve", + "vg", + "vi", + "vn", + "vu", + "wf", + "ws", + "ye", + "yt", + "za", + "zm", + "zw", + ] + | Omit = omit, force_language: Literal[ "albanian", "arabic", @@ -299,6 +542,7 @@ def identify_from_transaction( ] | Omit = omit, max_speed: bool | Omit = omit, + mcc: str | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -314,12 +558,20 @@ def identify_from_transaction( Args: transaction_info: Transaction information to identify the brand + city: Optional city name to prioritize when searching for the brand. + + country_gl: Optional country code (GL parameter) to specify the country for Google search + results. This affects the geographic location used for search queries. + force_language: Optional parameter to force the language of the retrieved brand data. max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, the API will skip time-consuming operations for faster response at the cost of less comprehensive data. + mcc: Optional Merchant Category Code (MCC) to help identify the business + category/industry. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -342,8 +594,11 @@ def identify_from_transaction( query=maybe_transform( { "transaction_info": transaction_info, + "city": city, + "country_gl": country_gl, "force_language": force_language, "max_speed": max_speed, + "mcc": mcc, "timeout_ms": timeout_ms, }, brand_identify_from_transaction_params.BrandIdentifyFromTransactionParams, @@ -1361,6 +1616,249 @@ async def identify_from_transaction( self, *, transaction_info: str, + city: str | Omit = omit, + country_gl: Literal[ + "ad", + "ae", + "af", + "ag", + "ai", + "al", + "am", + "an", + "ao", + "aq", + "ar", + "as", + "at", + "au", + "aw", + "az", + "ba", + "bb", + "bd", + "be", + "bf", + "bg", + "bh", + "bi", + "bj", + "bm", + "bn", + "bo", + "br", + "bs", + "bt", + "bv", + "bw", + "by", + "bz", + "ca", + "cc", + "cd", + "cf", + "cg", + "ch", + "ci", + "ck", + "cl", + "cm", + "cn", + "co", + "cr", + "cu", + "cv", + "cx", + "cy", + "cz", + "de", + "dj", + "dk", + "dm", + "do", + "dz", + "ec", + "ee", + "eg", + "eh", + "er", + "es", + "et", + "fi", + "fj", + "fk", + "fm", + "fo", + "fr", + "ga", + "gb", + "gd", + "ge", + "gf", + "gh", + "gi", + "gl", + "gm", + "gn", + "gp", + "gq", + "gr", + "gs", + "gt", + "gu", + "gw", + "gy", + "hk", + "hm", + "hn", + "hr", + "ht", + "hu", + "id", + "ie", + "il", + "in", + "io", + "iq", + "ir", + "is", + "it", + "jm", + "jo", + "jp", + "ke", + "kg", + "kh", + "ki", + "km", + "kn", + "kp", + "kr", + "kw", + "ky", + "kz", + "la", + "lb", + "lc", + "li", + "lk", + "lr", + "ls", + "lt", + "lu", + "lv", + "ly", + "ma", + "mc", + "md", + "mg", + "mh", + "mk", + "ml", + "mm", + "mn", + "mo", + "mp", + "mq", + "mr", + "ms", + "mt", + "mu", + "mv", + "mw", + "mx", + "my", + "mz", + "na", + "nc", + "ne", + "nf", + "ng", + "ni", + "nl", + "no", + "np", + "nr", + "nu", + "nz", + "om", + "pa", + "pe", + "pf", + "pg", + "ph", + "pk", + "pl", + "pm", + "pn", + "pr", + "ps", + "pt", + "pw", + "py", + "qa", + "re", + "ro", + "rs", + "ru", + "rw", + "sa", + "sb", + "sc", + "sd", + "se", + "sg", + "sh", + "si", + "sj", + "sk", + "sl", + "sm", + "sn", + "so", + "sr", + "st", + "sv", + "sy", + "sz", + "tc", + "td", + "tf", + "tg", + "th", + "tj", + "tk", + "tl", + "tm", + "tn", + "to", + "tr", + "tt", + "tv", + "tw", + "tz", + "ua", + "ug", + "um", + "us", + "uy", + "uz", + "va", + "vc", + "ve", + "vg", + "vi", + "vn", + "vu", + "wf", + "ws", + "ye", + "yt", + "za", + "zm", + "zw", + ] + | Omit = omit, force_language: Literal[ "albanian", "arabic", @@ -1417,6 +1915,7 @@ async def identify_from_transaction( ] | Omit = omit, max_speed: bool | Omit = omit, + mcc: str | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -1432,12 +1931,20 @@ async def identify_from_transaction( Args: transaction_info: Transaction information to identify the brand + city: Optional city name to prioritize when searching for the brand. + + country_gl: Optional country code (GL parameter) to specify the country for Google search + results. This affects the geographic location used for search queries. + force_language: Optional parameter to force the language of the retrieved brand data. max_speed: Optional parameter to optimize the API call for maximum speed. When set to true, the API will skip time-consuming operations for faster response at the cost of less comprehensive data. + mcc: Optional Merchant Category Code (MCC) to help identify the business + category/industry. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -1460,8 +1967,11 @@ async def identify_from_transaction( query=await async_maybe_transform( { "transaction_info": transaction_info, + "city": city, + "country_gl": country_gl, "force_language": force_language, "max_speed": max_speed, + "mcc": mcc, "timeout_ms": timeout_ms, }, brand_identify_from_transaction_params.BrandIdentifyFromTransactionParams, diff --git a/src/brand/dev/types/brand_identify_from_transaction_params.py b/src/brand/dev/types/brand_identify_from_transaction_params.py index 9878fd9..21f1b78 100644 --- a/src/brand/dev/types/brand_identify_from_transaction_params.py +++ b/src/brand/dev/types/brand_identify_from_transaction_params.py @@ -13,6 +13,255 @@ class BrandIdentifyFromTransactionParams(TypedDict, total=False): transaction_info: Required[str] """Transaction information to identify the brand""" + city: str + """Optional city name to prioritize when searching for the brand.""" + + country_gl: Literal[ + "ad", + "ae", + "af", + "ag", + "ai", + "al", + "am", + "an", + "ao", + "aq", + "ar", + "as", + "at", + "au", + "aw", + "az", + "ba", + "bb", + "bd", + "be", + "bf", + "bg", + "bh", + "bi", + "bj", + "bm", + "bn", + "bo", + "br", + "bs", + "bt", + "bv", + "bw", + "by", + "bz", + "ca", + "cc", + "cd", + "cf", + "cg", + "ch", + "ci", + "ck", + "cl", + "cm", + "cn", + "co", + "cr", + "cu", + "cv", + "cx", + "cy", + "cz", + "de", + "dj", + "dk", + "dm", + "do", + "dz", + "ec", + "ee", + "eg", + "eh", + "er", + "es", + "et", + "fi", + "fj", + "fk", + "fm", + "fo", + "fr", + "ga", + "gb", + "gd", + "ge", + "gf", + "gh", + "gi", + "gl", + "gm", + "gn", + "gp", + "gq", + "gr", + "gs", + "gt", + "gu", + "gw", + "gy", + "hk", + "hm", + "hn", + "hr", + "ht", + "hu", + "id", + "ie", + "il", + "in", + "io", + "iq", + "ir", + "is", + "it", + "jm", + "jo", + "jp", + "ke", + "kg", + "kh", + "ki", + "km", + "kn", + "kp", + "kr", + "kw", + "ky", + "kz", + "la", + "lb", + "lc", + "li", + "lk", + "lr", + "ls", + "lt", + "lu", + "lv", + "ly", + "ma", + "mc", + "md", + "mg", + "mh", + "mk", + "ml", + "mm", + "mn", + "mo", + "mp", + "mq", + "mr", + "ms", + "mt", + "mu", + "mv", + "mw", + "mx", + "my", + "mz", + "na", + "nc", + "ne", + "nf", + "ng", + "ni", + "nl", + "no", + "np", + "nr", + "nu", + "nz", + "om", + "pa", + "pe", + "pf", + "pg", + "ph", + "pk", + "pl", + "pm", + "pn", + "pr", + "ps", + "pt", + "pw", + "py", + "qa", + "re", + "ro", + "rs", + "ru", + "rw", + "sa", + "sb", + "sc", + "sd", + "se", + "sg", + "sh", + "si", + "sj", + "sk", + "sl", + "sm", + "sn", + "so", + "sr", + "st", + "sv", + "sy", + "sz", + "tc", + "td", + "tf", + "tg", + "th", + "tj", + "tk", + "tl", + "tm", + "tn", + "to", + "tr", + "tt", + "tv", + "tw", + "tz", + "ua", + "ug", + "um", + "us", + "uy", + "uz", + "va", + "vc", + "ve", + "vg", + "vi", + "vn", + "vu", + "wf", + "ws", + "ye", + "yt", + "za", + "zm", + "zw", + ] + """ + Optional country code (GL parameter) to specify the country for Google search + results. This affects the geographic location used for search queries. + """ + force_language: Literal[ "albanian", "arabic", @@ -76,6 +325,12 @@ class BrandIdentifyFromTransactionParams(TypedDict, total=False): response at the cost of less comprehensive data. """ + mcc: str + """ + Optional Merchant Category Code (MCC) to help identify the business + category/industry. + """ + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] """Optional timeout in milliseconds for the request. diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 2963c49..c246b79 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -167,8 +167,11 @@ def test_method_identify_from_transaction(self, client: BrandDev) -> None: def test_method_identify_from_transaction_with_all_params(self, client: BrandDev) -> None: brand = client.brand.identify_from_transaction( transaction_info="transaction_info", + city="city", + country_gl="ad", force_language="albanian", max_speed=True, + mcc="mcc", timeout_ms=1, ) assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) @@ -741,8 +744,11 @@ async def test_method_identify_from_transaction(self, async_client: AsyncBrandDe async def test_method_identify_from_transaction_with_all_params(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.identify_from_transaction( transaction_info="transaction_info", + city="city", + country_gl="ad", force_language="albanian", max_speed=True, + mcc="mcc", timeout_ms=1, ) assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) From be5cc6548e6b3ff8011b7b98cd93c937a1626a6f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:49:16 +0000 Subject: [PATCH 139/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 69eb19a..ba231b0 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.20.0" + ".": "1.21.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 23bc5c8..9717483 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.20.0" +version = "1.21.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index ab7552d..7767b94 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.20.0" # x-release-please-version +__version__ = "1.21.0" # x-release-please-version From 470f894bba7fdb84ba25c54216e5a838a94a3650 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:59:49 +0000 Subject: [PATCH 140/176] feat(api): api update --- .stats.yml | 4 ++-- src/brand/dev/resources/brand.py | 8 ++++---- .../dev/types/brand_identify_from_transaction_params.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.stats.yml b/.stats.yml index 9c441da..d76eb3c 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 12 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-4eb121d211177902ec0685d8829af778dbc02a21df03362e8675a8bcb4ca10a8.yml -openapi_spec_hash: b10e8626e812050d8d0664b5373d7fe4 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-fff3572a2d6f3a8461fbf52fb74882e1f4ae4a2b31a75673dcae230b336911d2.yml +openapi_spec_hash: b04ce3c7879b31f1e38cd76372e8e652 config_hash: 86160e220c81f47769a71c9343e486d8 diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 6ba3f47..a795094 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -560,8 +560,8 @@ def identify_from_transaction( city: Optional city name to prioritize when searching for the brand. - country_gl: Optional country code (GL parameter) to specify the country for Google search - results. This affects the geographic location used for search queries. + country_gl: Optional country code (GL parameter) to specify the country. This affects the + geographic location used for search queries. force_language: Optional parameter to force the language of the retrieved brand data. @@ -1933,8 +1933,8 @@ async def identify_from_transaction( city: Optional city name to prioritize when searching for the brand. - country_gl: Optional country code (GL parameter) to specify the country for Google search - results. This affects the geographic location used for search queries. + country_gl: Optional country code (GL parameter) to specify the country. This affects the + geographic location used for search queries. force_language: Optional parameter to force the language of the retrieved brand data. diff --git a/src/brand/dev/types/brand_identify_from_transaction_params.py b/src/brand/dev/types/brand_identify_from_transaction_params.py index 21f1b78..d41c1ce 100644 --- a/src/brand/dev/types/brand_identify_from_transaction_params.py +++ b/src/brand/dev/types/brand_identify_from_transaction_params.py @@ -257,9 +257,9 @@ class BrandIdentifyFromTransactionParams(TypedDict, total=False): "zm", "zw", ] - """ - Optional country code (GL parameter) to specify the country for Google search - results. This affects the geographic location used for search queries. + """Optional country code (GL parameter) to specify the country. + + This affects the geographic location used for search queries. """ force_language: Literal[ From 2c5903841b88a4af70a7f048ae0ca040dcf3df1a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 18:04:06 +0000 Subject: [PATCH 141/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ba231b0..397c420 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.21.0" + ".": "1.22.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9717483..26c4f9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.21.0" +version = "1.22.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 7767b94..76c32a4 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.21.0" # x-release-please-version +__version__ = "1.22.0" # x-release-please-version From e8cc914f65fcf224b2becfb9755c38f9f1d28e49 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 03:04:08 +0000 Subject: [PATCH 142/176] fix: ensure streams are always closed --- src/brand/dev/_streaming.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/brand/dev/_streaming.py b/src/brand/dev/_streaming.py index a61de0d..488f08c 100644 --- a/src/brand/dev/_streaming.py +++ b/src/brand/dev/_streaming.py @@ -54,11 +54,12 @@ def __stream__(self) -> Iterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # As we might not fully consume the response stream, we need to close it explicitly - response.close() + try: + for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + response.close() def __enter__(self) -> Self: return self @@ -117,11 +118,12 @@ async def __stream__(self) -> AsyncIterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - async for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # As we might not fully consume the response stream, we need to close it explicitly - await response.aclose() + try: + async for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + await response.aclose() async def __aenter__(self) -> Self: return self From daede7f247a48d96a08c30055c670b9be3fb9039 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 03:05:10 +0000 Subject: [PATCH 143/176] chore(deps): mypy 1.18.1 has a regression, pin to 1.17 --- pyproject.toml | 2 +- requirements-dev.lock | 4 +++- requirements.lock | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 26c4f9f..5c9736d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ managed = true # version pins are in requirements-dev.lock dev-dependencies = [ "pyright==1.1.399", - "mypy", + "mypy==1.17", "respx", "pytest", "pytest-asyncio", diff --git a/requirements-dev.lock b/requirements-dev.lock index e8a6c7a..07ab979 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -72,7 +72,7 @@ mdurl==0.1.2 multidict==6.4.4 # via aiohttp # via yarl -mypy==1.14.1 +mypy==1.17.0 mypy-extensions==1.0.0 # via mypy nodeenv==1.8.0 @@ -81,6 +81,8 @@ nox==2023.4.22 packaging==23.2 # via nox # via pytest +pathspec==0.12.1 + # via mypy platformdirs==3.11.0 # via virtualenv pluggy==1.5.0 diff --git a/requirements.lock b/requirements.lock index de9aff9..3ac2e1b 100644 --- a/requirements.lock +++ b/requirements.lock @@ -55,21 +55,21 @@ multidict==6.4.4 propcache==0.3.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via brand-dev -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic sniffio==1.3.0 # via anyio # via brand-dev -typing-extensions==4.12.2 +typing-extensions==4.15.0 # via anyio # via brand-dev # via multidict # via pydantic # via pydantic-core # via typing-inspection -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via pydantic yarl==1.20.0 # via aiohttp From aab50455c1882737417926c30c59f48f3c61adff Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 29 Nov 2025 20:44:32 +0000 Subject: [PATCH 144/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index d76eb3c..1fdc5f7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 12 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-fff3572a2d6f3a8461fbf52fb74882e1f4ae4a2b31a75673dcae230b336911d2.yml -openapi_spec_hash: b04ce3c7879b31f1e38cd76372e8e652 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-f69782c2c4296df9db6b41a3a7359a9e4910f59e34901b9f0e8045cec3f9ca69.yml +openapi_spec_hash: f06c3956a6fc7e57614b120910339747 config_hash: 86160e220c81f47769a71c9343e486d8 From 15f18cdf280b3873232764062e5a0246447577d8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 29 Nov 2025 20:55:27 +0000 Subject: [PATCH 145/176] feat(api): manual updates --- .stats.yml | 4 +- api.md | 2 + src/brand/dev/resources/brand.py | 114 ++++++++++++++++++++ src/brand/dev/types/__init__.py | 2 + src/brand/dev/types/brand_fonts_params.py | 24 +++++ src/brand/dev/types/brand_fonts_response.py | 44 ++++++++ tests/api_resources/test_brand.py | 87 +++++++++++++++ 7 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 src/brand/dev/types/brand_fonts_params.py create mode 100644 src/brand/dev/types/brand_fonts_response.py diff --git a/.stats.yml b/.stats.yml index 1fdc5f7..e312258 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 12 +configured_endpoints: 13 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-f69782c2c4296df9db6b41a3a7359a9e4910f59e34901b9f0e8045cec3f9ca69.yml openapi_spec_hash: f06c3956a6fc7e57614b120910339747 -config_hash: 86160e220c81f47769a71c9343e486d8 +config_hash: 6aaf0fe6f8877c9c5d9af95597123cb4 diff --git a/api.md b/api.md index cd8c7b2..6ad80e3 100644 --- a/api.md +++ b/api.md @@ -6,6 +6,7 @@ Types: from brand.dev.types import ( BrandRetrieveResponse, BrandAIQueryResponse, + BrandFontsResponse, BrandIdentifyFromTransactionResponse, BrandPrefetchResponse, BrandRetrieveByEmailResponse, @@ -23,6 +24,7 @@ Methods: - client.brand.retrieve(\*\*params) -> BrandRetrieveResponse - client.brand.ai_query(\*\*params) -> BrandAIQueryResponse +- client.brand.fonts(\*\*params) -> BrandFontsResponse - client.brand.identify_from_transaction(\*\*params) -> BrandIdentifyFromTransactionResponse - client.brand.prefetch(\*\*params) -> BrandPrefetchResponse - client.brand.retrieve_by_email(\*\*params) -> BrandRetrieveByEmailResponse diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index a795094..deb7219 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -8,6 +8,7 @@ import httpx from ..types import ( + brand_fonts_params, brand_ai_query_params, brand_prefetch_params, brand_retrieve_params, @@ -32,6 +33,7 @@ async_to_streamed_response_wrapper, ) from .._base_client import make_request_options +from ..types.brand_fonts_response import BrandFontsResponse from ..types.brand_ai_query_response import BrandAIQueryResponse from ..types.brand_prefetch_response import BrandPrefetchResponse from ..types.brand_retrieve_response import BrandRetrieveResponse @@ -239,6 +241,56 @@ def ai_query( cast_to=BrandAIQueryResponse, ) + def fonts( + self, + *, + domain: str, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandFontsResponse: + """ + Beta feature: Extract font information from a brand's website including font + families, usage statistics, fallbacks, and element/word counts. + + Args: + domain: Domain name to extract fonts from (e.g., 'example.com', 'google.com'). The + domain will be automatically normalized and validated. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/brand/fonts", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "domain": domain, + "timeout_ms": timeout_ms, + }, + brand_fonts_params.BrandFontsParams, + ), + ), + cast_to=BrandFontsResponse, + ) + def identify_from_transaction( self, *, @@ -1612,6 +1664,56 @@ async def ai_query( cast_to=BrandAIQueryResponse, ) + async def fonts( + self, + *, + domain: str, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandFontsResponse: + """ + Beta feature: Extract font information from a brand's website including font + families, usage statistics, fallbacks, and element/word counts. + + Args: + domain: Domain name to extract fonts from (e.g., 'example.com', 'google.com'). The + domain will be automatically normalized and validated. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/brand/fonts", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "domain": domain, + "timeout_ms": timeout_ms, + }, + brand_fonts_params.BrandFontsParams, + ), + ), + cast_to=BrandFontsResponse, + ) + async def identify_from_transaction( self, *, @@ -2804,6 +2906,9 @@ def __init__(self, brand: BrandResource) -> None: self.ai_query = to_raw_response_wrapper( brand.ai_query, ) + self.fonts = to_raw_response_wrapper( + brand.fonts, + ) self.identify_from_transaction = to_raw_response_wrapper( brand.identify_from_transaction, ) @@ -2846,6 +2951,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.ai_query = async_to_raw_response_wrapper( brand.ai_query, ) + self.fonts = async_to_raw_response_wrapper( + brand.fonts, + ) self.identify_from_transaction = async_to_raw_response_wrapper( brand.identify_from_transaction, ) @@ -2888,6 +2996,9 @@ def __init__(self, brand: BrandResource) -> None: self.ai_query = to_streamed_response_wrapper( brand.ai_query, ) + self.fonts = to_streamed_response_wrapper( + brand.fonts, + ) self.identify_from_transaction = to_streamed_response_wrapper( brand.identify_from_transaction, ) @@ -2930,6 +3041,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.ai_query = async_to_streamed_response_wrapper( brand.ai_query, ) + self.fonts = async_to_streamed_response_wrapper( + brand.fonts, + ) self.identify_from_transaction = async_to_streamed_response_wrapper( brand.identify_from_transaction, ) diff --git a/src/brand/dev/types/__init__.py b/src/brand/dev/types/__init__.py index eae55c9..9c3f961 100644 --- a/src/brand/dev/types/__init__.py +++ b/src/brand/dev/types/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from .brand_fonts_params import BrandFontsParams as BrandFontsParams +from .brand_fonts_response import BrandFontsResponse as BrandFontsResponse from .brand_ai_query_params import BrandAIQueryParams as BrandAIQueryParams from .brand_prefetch_params import BrandPrefetchParams as BrandPrefetchParams from .brand_retrieve_params import BrandRetrieveParams as BrandRetrieveParams diff --git a/src/brand/dev/types/brand_fonts_params.py b/src/brand/dev/types/brand_fonts_params.py new file mode 100644 index 0000000..db13d2f --- /dev/null +++ b/src/brand/dev/types/brand_fonts_params.py @@ -0,0 +1,24 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["BrandFontsParams"] + + +class BrandFontsParams(TypedDict, total=False): + domain: Required[str] + """Domain name to extract fonts from (e.g., 'example.com', 'google.com'). + + The domain will be automatically normalized and validated. + """ + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_fonts_response.py b/src/brand/dev/types/brand_fonts_response.py new file mode 100644 index 0000000..2721af9 --- /dev/null +++ b/src/brand/dev/types/brand_fonts_response.py @@ -0,0 +1,44 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from .._models import BaseModel + +__all__ = ["BrandFontsResponse", "Font"] + + +class Font(BaseModel): + fallbacks: List[str] + """Array of fallback font families""" + + font: str + """Font family name""" + + num_elements: float + """Number of elements using this font""" + + num_words: float + """Number of words using this font""" + + percent_elements: float + """Percentage of elements using this font""" + + percent_words: float + """Percentage of words using this font""" + + uses: List[str] + """Array of CSS selectors or element types where this font is used""" + + +class BrandFontsResponse(BaseModel): + code: int + """HTTP status code, e.g., 200""" + + domain: str + """The normalized domain that was processed""" + + fonts: List[Font] + """Array of font usage information""" + + status: str + """Status of the response, e.g., 'ok'""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index c246b79..43fa03b 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -10,6 +10,7 @@ from brand.dev import BrandDev, AsyncBrandDev from tests.utils import assert_matches_type from brand.dev.types import ( + BrandFontsResponse, BrandAIQueryResponse, BrandPrefetchResponse, BrandRetrieveResponse, @@ -154,6 +155,49 @@ def test_streaming_response_ai_query(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_fonts(self, client: BrandDev) -> None: + brand = client.brand.fonts( + domain="domain", + ) + assert_matches_type(BrandFontsResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_fonts_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.fonts( + domain="domain", + timeout_ms=1, + ) + assert_matches_type(BrandFontsResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_fonts(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.fonts( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandFontsResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_fonts(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.fonts( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandFontsResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_identify_from_transaction(self, client: BrandDev) -> None: @@ -731,6 +775,49 @@ async def test_streaming_response_ai_query(self, async_client: AsyncBrandDev) -> assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_fonts(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.fonts( + domain="domain", + ) + assert_matches_type(BrandFontsResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_fonts_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.fonts( + domain="domain", + timeout_ms=1, + ) + assert_matches_type(BrandFontsResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_fonts(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.fonts( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandFontsResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_fonts(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.fonts( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandFontsResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_identify_from_transaction(self, async_client: AsyncBrandDev) -> None: From 2650013efac8a0c5b6c4d1d0cec3f3eeeb318377 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 29 Nov 2025 20:58:03 +0000 Subject: [PATCH 146/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 397c420..cdcf20e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.22.0" + ".": "1.23.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5c9736d..611985f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.22.0" +version = "1.23.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 76c32a4..0745ee5 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.22.0" # x-release-please-version +__version__ = "1.23.0" # x-release-please-version From 3d86cbe3f8a54f522553eebaadd63b6bdc433ff7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 04:07:10 +0000 Subject: [PATCH 147/176] feat(api): api update --- .stats.yml | 4 ++-- src/brand/dev/types/brand_ai_query_params.py | 18 +++++++++++++++++- src/brand/dev/types/brand_ai_query_response.py | 8 ++++++-- tests/api_resources/test_brand.py | 12 ++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.stats.yml b/.stats.yml index e312258..c86b8f0 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 13 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-f69782c2c4296df9db6b41a3a7359a9e4910f59e34901b9f0e8045cec3f9ca69.yml -openapi_spec_hash: f06c3956a6fc7e57614b120910339747 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-e685328e362a28f152bcadfd1ca49680a80bfb7a3834fd422f2e459507305405.yml +openapi_spec_hash: 475543f86e39715f76588de6ccf70beb config_hash: 6aaf0fe6f8877c9c5d9af95597123cb4 diff --git a/src/brand/dev/types/brand_ai_query_params.py b/src/brand/dev/types/brand_ai_query_params.py index 303a508..fbfe99a 100644 --- a/src/brand/dev/types/brand_ai_query_params.py +++ b/src/brand/dev/types/brand_ai_query_params.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Iterable +from typing import Dict, Iterable from typing_extensions import Literal, Required, Annotated, TypedDict from .._utils import PropertyInfo @@ -41,6 +41,19 @@ class DataToExtract(TypedDict, total=False): datapoint_type: Required[Literal["text", "number", "date", "boolean", "list", "url"]] """Type of the data point""" + datapoint_list_type: Literal["string", "text", "number", "date", "boolean", "list", "url", "object"] + """Type of items in the list when datapoint_type is 'list'. + + Defaults to 'string'. Use 'object' to extract an array of objects matching a + schema. + """ + + datapoint_object_schema: Dict[str, Literal["string", "number", "date", "boolean"]] + """Schema definition for objects when datapoint_list_type is 'object'. + + Provide a map of field names to their scalar types. + """ + class SpecificPages(TypedDict, total=False): about_us: bool @@ -61,6 +74,9 @@ class SpecificPages(TypedDict, total=False): home_page: bool """Whether to analyze the home page""" + pricing: bool + """Whether to analyze the pricing page""" + privacy_policy: bool """Whether to analyze the privacy policy page""" diff --git a/src/brand/dev/types/brand_ai_query_response.py b/src/brand/dev/types/brand_ai_query_response.py index 53edd47..9971458 100644 --- a/src/brand/dev/types/brand_ai_query_response.py +++ b/src/brand/dev/types/brand_ai_query_response.py @@ -11,8 +11,12 @@ class DataExtracted(BaseModel): datapoint_name: Optional[str] = None """Name of the extracted data point""" - datapoint_value: Union[str, float, bool, List[str], List[float], None] = None - """Value of the extracted data point""" + datapoint_value: Union[str, float, bool, List[str], List[float], List[object], None] = None + """Value of the extracted data point. + + Can be a primitive type, an array of primitives, or an array of objects when + datapoint_list_type is 'object'. + """ class BrandAIQueryResponse(BaseModel): diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 43fa03b..204ab75 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -96,6 +96,11 @@ def test_method_ai_query_with_all_params(self, client: BrandDev) -> None: "datapoint_example": "datapoint_example", "datapoint_name": "datapoint_name", "datapoint_type": "text", + "datapoint_list_type": "string", + "datapoint_object_schema": { + "testimonial_text": "string", + "testimonial_author": "string", + }, } ], domain="domain", @@ -106,6 +111,7 @@ def test_method_ai_query_with_all_params(self, client: BrandDev) -> None: "contact_us": True, "faq": True, "home_page": True, + "pricing": True, "privacy_policy": True, "terms_and_conditions": True, }, @@ -716,6 +722,11 @@ async def test_method_ai_query_with_all_params(self, async_client: AsyncBrandDev "datapoint_example": "datapoint_example", "datapoint_name": "datapoint_name", "datapoint_type": "text", + "datapoint_list_type": "string", + "datapoint_object_schema": { + "testimonial_text": "string", + "testimonial_author": "string", + }, } ], domain="domain", @@ -726,6 +737,7 @@ async def test_method_ai_query_with_all_params(self, async_client: AsyncBrandDev "contact_us": True, "faq": True, "home_page": True, + "pricing": True, "privacy_policy": True, "terms_and_conditions": True, }, From 96218d1dac6b57a6af73442f265e985ab17ed9aa Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 04:18:04 +0000 Subject: [PATCH 148/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cdcf20e..bfaab56 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.23.0" + ".": "1.24.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 611985f..41e53ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.23.0" +version = "1.24.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 0745ee5..520080c 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.23.0" # x-release-please-version +__version__ = "1.24.0" # x-release-please-version From 021bd98dd59b7f64a57808ae9ef71727733d3a70 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 03:25:52 +0000 Subject: [PATCH 149/176] chore: update lockfile --- pyproject.toml | 14 +++--- requirements-dev.lock | 108 +++++++++++++++++++++++------------------- requirements.lock | 31 ++++++------ 3 files changed, 83 insertions(+), 70 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 41e53ae..04f76fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,16 @@ license = "Apache-2.0" authors = [ { name = "Brand Dev", email = "hello@brand.dev" }, ] + dependencies = [ - "httpx>=0.23.0, <1", - "pydantic>=1.9.0, <3", - "typing-extensions>=4.10, <5", - "anyio>=3.5.0, <5", - "distro>=1.7.0, <2", - "sniffio", + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.10, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", ] + requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", diff --git a/requirements-dev.lock b/requirements-dev.lock index 07ab979..e64ba5e 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -12,40 +12,45 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via brand-dev # via httpx-aiohttp -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via brand-dev # via httpx -argcomplete==3.1.2 +argcomplete==3.6.3 # via nox async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 + # via nox +backports-asyncio-runner==1.2.0 + # via pytest-asyncio +certifi==2025.11.12 # via httpcore # via httpx -colorlog==6.7.0 +colorlog==6.10.1 + # via nox +dependency-groups==1.3.1 # via nox -dirty-equals==0.6.0 -distlib==0.3.7 +dirty-equals==0.11 +distlib==0.4.0 # via virtualenv -distro==1.8.0 +distro==1.9.0 # via brand-dev -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio # via pytest -execnet==2.1.1 +execnet==2.1.2 # via pytest-xdist -filelock==3.12.4 +filelock==3.19.1 # via virtualenv -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -58,82 +63,87 @@ httpx==0.28.1 # via respx httpx-aiohttp==0.1.9 # via brand-dev -idna==3.4 +humanize==4.13.0 + # via nox +idna==3.11 # via anyio # via httpx # via yarl -importlib-metadata==7.0.0 -iniconfig==2.0.0 +importlib-metadata==8.7.0 +iniconfig==2.1.0 # via pytest markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl mypy==1.17.0 -mypy-extensions==1.0.0 +mypy-extensions==1.1.0 # via mypy -nodeenv==1.8.0 +nodeenv==1.9.1 # via pyright -nox==2023.4.22 -packaging==23.2 +nox==2025.11.12 +packaging==25.0 + # via dependency-groups # via nox # via pytest pathspec==0.12.1 # via mypy -platformdirs==3.11.0 +platformdirs==4.4.0 # via virtualenv -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via brand-dev -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic -pygments==2.18.0 +pygments==2.19.2 + # via pytest # via rich pyright==1.1.399 -pytest==8.3.3 +pytest==8.4.2 # via pytest-asyncio # via pytest-xdist -pytest-asyncio==0.24.0 -pytest-xdist==3.7.0 -python-dateutil==2.8.2 +pytest-asyncio==1.2.0 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 # via time-machine -pytz==2023.3.post1 - # via dirty-equals respx==0.22.0 -rich==13.7.1 -ruff==0.9.4 -setuptools==68.2.2 - # via nodeenv -six==1.16.0 +rich==14.2.0 +ruff==0.14.7 +six==1.17.0 # via python-dateutil -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via brand-dev -time-machine==2.9.0 -tomli==2.0.2 +time-machine==2.19.0 +tomli==2.3.0 + # via dependency-groups # via mypy + # via nox # via pytest -typing-extensions==4.12.2 +typing-extensions==4.15.0 + # via aiosignal # via anyio # via brand-dev + # via exceptiongroup # via multidict # via mypy # via pydantic # via pydantic-core # via pyright + # via pytest-asyncio # via typing-inspection -typing-inspection==0.4.1 + # via virtualenv +typing-inspection==0.4.2 # via pydantic -virtualenv==20.24.5 +virtualenv==20.35.4 # via nox -yarl==1.20.0 +yarl==1.22.0 # via aiohttp -zipp==3.17.0 +zipp==3.23.0 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index 3ac2e1b..a960d32 100644 --- a/requirements.lock +++ b/requirements.lock @@ -12,28 +12,28 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.2 # via brand-dev # via httpx-aiohttp -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.0 # via brand-dev # via httpx async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 +certifi==2025.11.12 # via httpcore # via httpx -distro==1.8.0 +distro==1.9.0 # via brand-dev -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -45,31 +45,32 @@ httpx==0.28.1 # via httpx-aiohttp httpx-aiohttp==0.1.9 # via brand-dev -idna==3.4 +idna==3.11 # via anyio # via httpx # via yarl -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl pydantic==2.12.5 # via brand-dev pydantic-core==2.41.5 # via pydantic -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via brand-dev typing-extensions==4.15.0 + # via aiosignal # via anyio # via brand-dev + # via exceptiongroup # via multidict # via pydantic # via pydantic-core # via typing-inspection typing-inspection==0.4.2 # via pydantic -yarl==1.20.0 +yarl==1.22.0 # via aiohttp From 40b1e96f3732658ee29a1eeed837d4ad902a853a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 03:34:16 +0000 Subject: [PATCH 150/176] chore(docs): use environment variables for authentication in code snippets --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5ee938a..03f5619 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ pip install brand.dev[aiohttp] Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python +import os import asyncio from brand.dev import DefaultAioHttpClient from brand.dev import AsyncBrandDev @@ -90,7 +91,7 @@ from brand.dev import AsyncBrandDev async def main() -> None: async with AsyncBrandDev( - api_key="My API Key", + api_key=os.environ.get("BRAND_DEV_API_KEY"), # This is the default and can be omitted http_client=DefaultAioHttpClient(), ) as client: brand = await client.brand.retrieve( From b1edaa7290acd34365878a3d0fcb7620b326dfff Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 03:09:02 +0000 Subject: [PATCH 151/176] fix(types): allow pyright to infer TypedDict types within SequenceNotStr --- src/brand/dev/_types.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/brand/dev/_types.py b/src/brand/dev/_types.py index b46a2ee..c9ea1de 100644 --- a/src/brand/dev/_types.py +++ b/src/brand/dev/_types.py @@ -243,6 +243,9 @@ class HttpxSendArgs(TypedDict, total=False): if TYPE_CHECKING: # This works because str.__contains__ does not accept object (either in typeshed or at runtime) # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + # + # Note: index() and count() methods are intentionally omitted to allow pyright to properly + # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr. class SequenceNotStr(Protocol[_T_co]): @overload def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... @@ -251,8 +254,6 @@ def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... def __contains__(self, value: object, /) -> bool: ... def __len__(self) -> int: ... def __iter__(self) -> Iterator[_T_co]: ... - def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... - def count(self, value: Any, /) -> int: ... def __reversed__(self) -> Iterator[_T_co]: ... else: # just point this to a normal `Sequence` at runtime to avoid having to special case From b592bc1ca704044f4eb19a049328a234115ec86e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 03:11:03 +0000 Subject: [PATCH 152/176] chore: add missing docstrings --- src/brand/dev/types/brand_ai_query_params.py | 2 ++ ...rand_identify_from_transaction_response.py | 16 ++++++++++++ .../types/brand_retrieve_by_email_response.py | 16 ++++++++++++ .../types/brand_retrieve_by_isin_response.py | 16 ++++++++++++ .../types/brand_retrieve_by_name_response.py | 16 ++++++++++++ .../brand_retrieve_by_ticker_response.py | 16 ++++++++++++ .../dev/types/brand_retrieve_response.py | 16 ++++++++++++ .../brand_retrieve_simplified_response.py | 6 +++++ .../dev/types/brand_styleguide_response.py | 26 +++++++++++++++++++ 9 files changed, 130 insertions(+) diff --git a/src/brand/dev/types/brand_ai_query_params.py b/src/brand/dev/types/brand_ai_query_params.py index fbfe99a..d45a821 100644 --- a/src/brand/dev/types/brand_ai_query_params.py +++ b/src/brand/dev/types/brand_ai_query_params.py @@ -56,6 +56,8 @@ class DataToExtract(TypedDict, total=False): class SpecificPages(TypedDict, total=False): + """Optional object specifying which pages to analyze""" + about_us: bool """Whether to analyze the about us page""" diff --git a/src/brand/dev/types/brand_identify_from_transaction_response.py b/src/brand/dev/types/brand_identify_from_transaction_response.py index 69d4707..5c1261d 100644 --- a/src/brand/dev/types/brand_identify_from_transaction_response.py +++ b/src/brand/dev/types/brand_identify_from_transaction_response.py @@ -25,6 +25,8 @@ class BrandAddress(BaseModel): + """Physical address of the brand""" + city: Optional[str] = None """City name""" @@ -56,6 +58,8 @@ class BrandBackdropColor(BaseModel): class BrandBackdropResolution(BaseModel): + """Resolution of the backdrop image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -340,11 +344,15 @@ class BrandIndustriesEic(BaseModel): class BrandIndustries(BaseModel): + """Industry classification information for the brand""" + eic: Optional[List[BrandIndustriesEic]] = None """Easy Industry Classification - array of industry and subindustry pairs""" class BrandLinks(BaseModel): + """Important website links for the brand""" + blog: Optional[str] = None """URL to the brand's blog or news page""" @@ -373,6 +381,8 @@ class BrandLogoColor(BaseModel): class BrandLogoResolution(BaseModel): + """Resolution of the logo image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -413,6 +423,10 @@ class BrandSocial(BaseModel): class BrandStock(BaseModel): + """ + Stock market information for this brand (will be null if not a publicly traded company) + """ + exchange: Optional[str] = None """Stock exchange name""" @@ -421,6 +435,8 @@ class BrandStock(BaseModel): class Brand(BaseModel): + """Detailed brand information""" + address: Optional[BrandAddress] = None """Physical address of the brand""" diff --git a/src/brand/dev/types/brand_retrieve_by_email_response.py b/src/brand/dev/types/brand_retrieve_by_email_response.py index 47411cd..922fa75 100644 --- a/src/brand/dev/types/brand_retrieve_by_email_response.py +++ b/src/brand/dev/types/brand_retrieve_by_email_response.py @@ -25,6 +25,8 @@ class BrandAddress(BaseModel): + """Physical address of the brand""" + city: Optional[str] = None """City name""" @@ -56,6 +58,8 @@ class BrandBackdropColor(BaseModel): class BrandBackdropResolution(BaseModel): + """Resolution of the backdrop image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -340,11 +344,15 @@ class BrandIndustriesEic(BaseModel): class BrandIndustries(BaseModel): + """Industry classification information for the brand""" + eic: Optional[List[BrandIndustriesEic]] = None """Easy Industry Classification - array of industry and subindustry pairs""" class BrandLinks(BaseModel): + """Important website links for the brand""" + blog: Optional[str] = None """URL to the brand's blog or news page""" @@ -373,6 +381,8 @@ class BrandLogoColor(BaseModel): class BrandLogoResolution(BaseModel): + """Resolution of the logo image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -413,6 +423,10 @@ class BrandSocial(BaseModel): class BrandStock(BaseModel): + """ + Stock market information for this brand (will be null if not a publicly traded company) + """ + exchange: Optional[str] = None """Stock exchange name""" @@ -421,6 +435,8 @@ class BrandStock(BaseModel): class Brand(BaseModel): + """Detailed brand information""" + address: Optional[BrandAddress] = None """Physical address of the brand""" diff --git a/src/brand/dev/types/brand_retrieve_by_isin_response.py b/src/brand/dev/types/brand_retrieve_by_isin_response.py index a9bbe4d..21a860e 100644 --- a/src/brand/dev/types/brand_retrieve_by_isin_response.py +++ b/src/brand/dev/types/brand_retrieve_by_isin_response.py @@ -25,6 +25,8 @@ class BrandAddress(BaseModel): + """Physical address of the brand""" + city: Optional[str] = None """City name""" @@ -56,6 +58,8 @@ class BrandBackdropColor(BaseModel): class BrandBackdropResolution(BaseModel): + """Resolution of the backdrop image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -340,11 +344,15 @@ class BrandIndustriesEic(BaseModel): class BrandIndustries(BaseModel): + """Industry classification information for the brand""" + eic: Optional[List[BrandIndustriesEic]] = None """Easy Industry Classification - array of industry and subindustry pairs""" class BrandLinks(BaseModel): + """Important website links for the brand""" + blog: Optional[str] = None """URL to the brand's blog or news page""" @@ -373,6 +381,8 @@ class BrandLogoColor(BaseModel): class BrandLogoResolution(BaseModel): + """Resolution of the logo image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -413,6 +423,10 @@ class BrandSocial(BaseModel): class BrandStock(BaseModel): + """ + Stock market information for this brand (will be null if not a publicly traded company) + """ + exchange: Optional[str] = None """Stock exchange name""" @@ -421,6 +435,8 @@ class BrandStock(BaseModel): class Brand(BaseModel): + """Detailed brand information""" + address: Optional[BrandAddress] = None """Physical address of the brand""" diff --git a/src/brand/dev/types/brand_retrieve_by_name_response.py b/src/brand/dev/types/brand_retrieve_by_name_response.py index db66fac..1e462e7 100644 --- a/src/brand/dev/types/brand_retrieve_by_name_response.py +++ b/src/brand/dev/types/brand_retrieve_by_name_response.py @@ -25,6 +25,8 @@ class BrandAddress(BaseModel): + """Physical address of the brand""" + city: Optional[str] = None """City name""" @@ -56,6 +58,8 @@ class BrandBackdropColor(BaseModel): class BrandBackdropResolution(BaseModel): + """Resolution of the backdrop image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -340,11 +344,15 @@ class BrandIndustriesEic(BaseModel): class BrandIndustries(BaseModel): + """Industry classification information for the brand""" + eic: Optional[List[BrandIndustriesEic]] = None """Easy Industry Classification - array of industry and subindustry pairs""" class BrandLinks(BaseModel): + """Important website links for the brand""" + blog: Optional[str] = None """URL to the brand's blog or news page""" @@ -373,6 +381,8 @@ class BrandLogoColor(BaseModel): class BrandLogoResolution(BaseModel): + """Resolution of the logo image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -413,6 +423,10 @@ class BrandSocial(BaseModel): class BrandStock(BaseModel): + """ + Stock market information for this brand (will be null if not a publicly traded company) + """ + exchange: Optional[str] = None """Stock exchange name""" @@ -421,6 +435,8 @@ class BrandStock(BaseModel): class Brand(BaseModel): + """Detailed brand information""" + address: Optional[BrandAddress] = None """Physical address of the brand""" diff --git a/src/brand/dev/types/brand_retrieve_by_ticker_response.py b/src/brand/dev/types/brand_retrieve_by_ticker_response.py index 5a04a44..9815a65 100644 --- a/src/brand/dev/types/brand_retrieve_by_ticker_response.py +++ b/src/brand/dev/types/brand_retrieve_by_ticker_response.py @@ -25,6 +25,8 @@ class BrandAddress(BaseModel): + """Physical address of the brand""" + city: Optional[str] = None """City name""" @@ -56,6 +58,8 @@ class BrandBackdropColor(BaseModel): class BrandBackdropResolution(BaseModel): + """Resolution of the backdrop image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -340,11 +344,15 @@ class BrandIndustriesEic(BaseModel): class BrandIndustries(BaseModel): + """Industry classification information for the brand""" + eic: Optional[List[BrandIndustriesEic]] = None """Easy Industry Classification - array of industry and subindustry pairs""" class BrandLinks(BaseModel): + """Important website links for the brand""" + blog: Optional[str] = None """URL to the brand's blog or news page""" @@ -373,6 +381,8 @@ class BrandLogoColor(BaseModel): class BrandLogoResolution(BaseModel): + """Resolution of the logo image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -413,6 +423,10 @@ class BrandSocial(BaseModel): class BrandStock(BaseModel): + """ + Stock market information for this brand (will be null if not a publicly traded company) + """ + exchange: Optional[str] = None """Stock exchange name""" @@ -421,6 +435,8 @@ class BrandStock(BaseModel): class Brand(BaseModel): + """Detailed brand information""" + address: Optional[BrandAddress] = None """Physical address of the brand""" diff --git a/src/brand/dev/types/brand_retrieve_response.py b/src/brand/dev/types/brand_retrieve_response.py index b120889..28aea6f 100644 --- a/src/brand/dev/types/brand_retrieve_response.py +++ b/src/brand/dev/types/brand_retrieve_response.py @@ -25,6 +25,8 @@ class BrandAddress(BaseModel): + """Physical address of the brand""" + city: Optional[str] = None """City name""" @@ -56,6 +58,8 @@ class BrandBackdropColor(BaseModel): class BrandBackdropResolution(BaseModel): + """Resolution of the backdrop image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -340,11 +344,15 @@ class BrandIndustriesEic(BaseModel): class BrandIndustries(BaseModel): + """Industry classification information for the brand""" + eic: Optional[List[BrandIndustriesEic]] = None """Easy Industry Classification - array of industry and subindustry pairs""" class BrandLinks(BaseModel): + """Important website links for the brand""" + blog: Optional[str] = None """URL to the brand's blog or news page""" @@ -373,6 +381,8 @@ class BrandLogoColor(BaseModel): class BrandLogoResolution(BaseModel): + """Resolution of the logo image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -413,6 +423,10 @@ class BrandSocial(BaseModel): class BrandStock(BaseModel): + """ + Stock market information for this brand (will be null if not a publicly traded company) + """ + exchange: Optional[str] = None """Stock exchange name""" @@ -421,6 +435,8 @@ class BrandStock(BaseModel): class Brand(BaseModel): + """Detailed brand information""" + address: Optional[BrandAddress] = None """Physical address of the brand""" diff --git a/src/brand/dev/types/brand_retrieve_simplified_response.py b/src/brand/dev/types/brand_retrieve_simplified_response.py index 523fe18..819e408 100644 --- a/src/brand/dev/types/brand_retrieve_simplified_response.py +++ b/src/brand/dev/types/brand_retrieve_simplified_response.py @@ -27,6 +27,8 @@ class BrandBackdropColor(BaseModel): class BrandBackdropResolution(BaseModel): + """Resolution of the backdrop image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -65,6 +67,8 @@ class BrandLogoColor(BaseModel): class BrandLogoResolution(BaseModel): + """Resolution of the logo image""" + aspect_ratio: Optional[float] = None """Aspect ratio of the image (width/height)""" @@ -97,6 +101,8 @@ class BrandLogo(BaseModel): class Brand(BaseModel): + """Simplified brand information""" + backdrops: Optional[List[BrandBackdrop]] = None """An array of backdrop images for the brand""" diff --git a/src/brand/dev/types/brand_styleguide_response.py b/src/brand/dev/types/brand_styleguide_response.py index 0cc5aa7..35b51b3 100644 --- a/src/brand/dev/types/brand_styleguide_response.py +++ b/src/brand/dev/types/brand_styleguide_response.py @@ -30,6 +30,8 @@ class StyleguideColors(BaseModel): + """Primary colors used on the website""" + accent: Optional[str] = None """Accent color of the website (hex format)""" @@ -41,6 +43,8 @@ class StyleguideColors(BaseModel): class StyleguideComponentsButtonLink(BaseModel): + """Link button style""" + background_color: Optional[str] = FieldInfo(alias="backgroundColor", default=None) border_color: Optional[str] = FieldInfo(alias="borderColor", default=None) @@ -65,6 +69,8 @@ class StyleguideComponentsButtonLink(BaseModel): class StyleguideComponentsButtonPrimary(BaseModel): + """Primary button style""" + background_color: Optional[str] = FieldInfo(alias="backgroundColor", default=None) border_color: Optional[str] = FieldInfo(alias="borderColor", default=None) @@ -89,6 +95,8 @@ class StyleguideComponentsButtonPrimary(BaseModel): class StyleguideComponentsButtonSecondary(BaseModel): + """Secondary button style""" + background_color: Optional[str] = FieldInfo(alias="backgroundColor", default=None) border_color: Optional[str] = FieldInfo(alias="borderColor", default=None) @@ -113,6 +121,8 @@ class StyleguideComponentsButtonSecondary(BaseModel): class StyleguideComponentsButton(BaseModel): + """Button component styles""" + link: Optional[StyleguideComponentsButtonLink] = None """Link button style""" @@ -124,6 +134,8 @@ class StyleguideComponentsButton(BaseModel): class StyleguideComponentsCard(BaseModel): + """Card component style""" + background_color: Optional[str] = FieldInfo(alias="backgroundColor", default=None) border_color: Optional[str] = FieldInfo(alias="borderColor", default=None) @@ -142,6 +154,8 @@ class StyleguideComponentsCard(BaseModel): class StyleguideComponents(BaseModel): + """UI component styles""" + button: Optional[StyleguideComponentsButton] = None """Button component styles""" @@ -150,6 +164,8 @@ class StyleguideComponents(BaseModel): class StyleguideElementSpacing(BaseModel): + """Spacing system used on the website""" + lg: Optional[str] = None """Large spacing value""" @@ -167,6 +183,8 @@ class StyleguideElementSpacing(BaseModel): class StyleguideShadows(BaseModel): + """Shadow styles used on the website""" + inner: Optional[str] = None """Inner shadow value""" @@ -232,6 +250,8 @@ class StyleguideTypographyHeadingsH4(BaseModel): class StyleguideTypographyHeadings(BaseModel): + """Heading styles""" + h1: Optional[StyleguideTypographyHeadingsH1] = None h2: Optional[StyleguideTypographyHeadingsH2] = None @@ -242,6 +262,8 @@ class StyleguideTypographyHeadings(BaseModel): class StyleguideTypographyP(BaseModel): + """Paragraph text styles""" + font_family: Optional[str] = FieldInfo(alias="fontFamily", default=None) font_size: Optional[str] = FieldInfo(alias="fontSize", default=None) @@ -254,6 +276,8 @@ class StyleguideTypographyP(BaseModel): class StyleguideTypography(BaseModel): + """Typography styles used on the website""" + headings: Optional[StyleguideTypographyHeadings] = None """Heading styles""" @@ -262,6 +286,8 @@ class StyleguideTypography(BaseModel): class Styleguide(BaseModel): + """Comprehensive styleguide data extracted from the website""" + colors: Optional[StyleguideColors] = None """Primary colors used on the website""" From 0fa3cd852ce28713bf779d6480dd2589f9a7b8f6 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 04:18:38 +0000 Subject: [PATCH 153/176] feat(api): api update --- .stats.yml | 4 ++-- src/brand/dev/resources/brand.py | 8 ++++++++ .../dev/types/brand_identify_from_transaction_params.py | 3 +++ tests/api_resources/test_brand.py | 2 ++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index c86b8f0..8d4f11d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 13 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-e685328e362a28f152bcadfd1ca49680a80bfb7a3834fd422f2e459507305405.yml -openapi_spec_hash: 475543f86e39715f76588de6ccf70beb +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-143086f8200f34e6ace805070e2a3ddccf15e30ed7ac3a7193f6a984f2413fa2.yml +openapi_spec_hash: f15bf2b836aee764c02a4fc185f13586 config_hash: 6aaf0fe6f8877c9c5d9af95597123cb4 diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index deb7219..4dfdb9d 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -595,6 +595,7 @@ def identify_from_transaction( | Omit = omit, max_speed: bool | Omit = omit, mcc: str | Omit = omit, + phone: float | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -624,6 +625,8 @@ def identify_from_transaction( mcc: Optional Merchant Category Code (MCC) to help identify the business category/industry. + phone: Optional phone number from the transaction to help verify brand match. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -651,6 +654,7 @@ def identify_from_transaction( "force_language": force_language, "max_speed": max_speed, "mcc": mcc, + "phone": phone, "timeout_ms": timeout_ms, }, brand_identify_from_transaction_params.BrandIdentifyFromTransactionParams, @@ -2018,6 +2022,7 @@ async def identify_from_transaction( | Omit = omit, max_speed: bool | Omit = omit, mcc: str | Omit = omit, + phone: float | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -2047,6 +2052,8 @@ async def identify_from_transaction( mcc: Optional Merchant Category Code (MCC) to help identify the business category/industry. + phone: Optional phone number from the transaction to help verify brand match. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -2074,6 +2081,7 @@ async def identify_from_transaction( "force_language": force_language, "max_speed": max_speed, "mcc": mcc, + "phone": phone, "timeout_ms": timeout_ms, }, brand_identify_from_transaction_params.BrandIdentifyFromTransactionParams, diff --git a/src/brand/dev/types/brand_identify_from_transaction_params.py b/src/brand/dev/types/brand_identify_from_transaction_params.py index d41c1ce..c509338 100644 --- a/src/brand/dev/types/brand_identify_from_transaction_params.py +++ b/src/brand/dev/types/brand_identify_from_transaction_params.py @@ -331,6 +331,9 @@ class BrandIdentifyFromTransactionParams(TypedDict, total=False): category/industry. """ + phone: float + """Optional phone number from the transaction to help verify brand match.""" + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] """Optional timeout in milliseconds for the request. diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 204ab75..09aaa86 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -222,6 +222,7 @@ def test_method_identify_from_transaction_with_all_params(self, client: BrandDev force_language="albanian", max_speed=True, mcc="mcc", + phone=0, timeout_ms=1, ) assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) @@ -848,6 +849,7 @@ async def test_method_identify_from_transaction_with_all_params(self, async_clie force_language="albanian", max_speed=True, mcc="mcc", + phone=0, timeout_ms=1, ) assert_matches_type(BrandIdentifyFromTransactionResponse, brand, path=["response"]) From cbace35b3529731822028a506c066d673c202842 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:21:28 +0000 Subject: [PATCH 154/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index bfaab56..0c0c0c3 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.24.0" + ".": "1.25.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 04f76fc..819c093 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.24.0" +version = "1.25.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 520080c..0e1f632 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.24.0" # x-release-please-version +__version__ = "1.25.0" # x-release-please-version From 0095f83c7a0195acb316a32deddbb9a64c9e633d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:59:25 +0000 Subject: [PATCH 155/176] feat(api): api update --- .stats.yml | 4 ++-- src/brand/dev/resources/brand.py | 18 ++++++++++++++++++ .../dev/types/brand_retrieve_naics_params.py | 9 +++++++++ .../dev/types/brand_retrieve_naics_response.py | 8 ++++++-- tests/api_resources/test_brand.py | 4 ++++ 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.stats.yml b/.stats.yml index 8d4f11d..b3da672 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 13 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-143086f8200f34e6ace805070e2a3ddccf15e30ed7ac3a7193f6a984f2413fa2.yml -openapi_spec_hash: f15bf2b836aee764c02a4fc185f13586 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-849e1b7b4e2aeff45d6762768c497da63f605cb80fdbfc0207f7e7555b994b5a.yml +openapi_spec_hash: 9d2ef7a402cb9eaa80cbae645aa8d2f0 config_hash: 6aaf0fe6f8877c9c5d9af95597123cb4 diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 4dfdb9d..b39a766 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -1256,6 +1256,8 @@ def retrieve_naics( self, *, input: str, + max_results: int | Omit = omit, + min_results: int | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -1272,6 +1274,11 @@ def retrieve_naics( in `input`, it will be used for classification, otherwise, we will search for the brand using the provided title. + max_results: Maximum number of NAICS codes to return. Must be between 1 and 10. Defaults + to 5. + + min_results: Minimum number of NAICS codes to return. Must be at least 1. Defaults to 1. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -1294,6 +1301,8 @@ def retrieve_naics( query=maybe_transform( { "input": input, + "max_results": max_results, + "min_results": min_results, "timeout_ms": timeout_ms, }, brand_retrieve_naics_params.BrandRetrieveNaicsParams, @@ -2683,6 +2692,8 @@ async def retrieve_naics( self, *, input: str, + max_results: int | Omit = omit, + min_results: int | Omit = omit, timeout_ms: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -2699,6 +2710,11 @@ async def retrieve_naics( in `input`, it will be used for classification, otherwise, we will search for the brand using the provided title. + max_results: Maximum number of NAICS codes to return. Must be between 1 and 10. Defaults + to 5. + + min_results: Minimum number of NAICS codes to return. Must be at least 1. Defaults to 1. + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer than this value, it will be aborted with a 408 status code. Maximum allowed value is 300000ms (5 minutes). @@ -2721,6 +2737,8 @@ async def retrieve_naics( query=await async_maybe_transform( { "input": input, + "max_results": max_results, + "min_results": min_results, "timeout_ms": timeout_ms, }, brand_retrieve_naics_params.BrandRetrieveNaicsParams, diff --git a/src/brand/dev/types/brand_retrieve_naics_params.py b/src/brand/dev/types/brand_retrieve_naics_params.py index 95ad345..2803c13 100644 --- a/src/brand/dev/types/brand_retrieve_naics_params.py +++ b/src/brand/dev/types/brand_retrieve_naics_params.py @@ -17,6 +17,15 @@ class BrandRetrieveNaicsParams(TypedDict, total=False): otherwise, we will search for the brand using the provided title. """ + max_results: Annotated[int, PropertyInfo(alias="maxResults")] + """Maximum number of NAICS codes to return. + + Must be between 1 and 10. Defaults to 5. + """ + + min_results: Annotated[int, PropertyInfo(alias="minResults")] + """Minimum number of NAICS codes to return. Must be at least 1. Defaults to 1.""" + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] """Optional timeout in milliseconds for the request. diff --git a/src/brand/dev/types/brand_retrieve_naics_response.py b/src/brand/dev/types/brand_retrieve_naics_response.py index 5aa467d..a53da5e 100644 --- a/src/brand/dev/types/brand_retrieve_naics_response.py +++ b/src/brand/dev/types/brand_retrieve_naics_response.py @@ -1,6 +1,7 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. from typing import List, Optional +from typing_extensions import Literal from .._models import BaseModel @@ -8,10 +9,13 @@ class Code(BaseModel): - code: Optional[str] = None + code: str """NAICS code""" - title: Optional[str] = None + confidence: Literal["high", "medium", "low"] + """Confidence level for how well this NAICS code matches the company description""" + + name: str """NAICS title""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 09aaa86..75673d4 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -490,6 +490,8 @@ def test_method_retrieve_naics(self, client: BrandDev) -> None: def test_method_retrieve_naics_with_all_params(self, client: BrandDev) -> None: brand = client.brand.retrieve_naics( input="input", + max_results=1, + min_results=1, timeout_ms=1, ) assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) @@ -1117,6 +1119,8 @@ async def test_method_retrieve_naics(self, async_client: AsyncBrandDev) -> None: async def test_method_retrieve_naics_with_all_params(self, async_client: AsyncBrandDev) -> None: brand = await async_client.brand.retrieve_naics( input="input", + max_results=1, + min_results=1, timeout_ms=1, ) assert_matches_type(BrandRetrieveNaicsResponse, brand, path=["response"]) From cd668dbeb25e79ca91d17a181de5e5a64a148c82 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 19:03:36 +0000 Subject: [PATCH 156/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0c0c0c3..f3dbfd2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.25.0" + ".": "1.26.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 819c093..6cc4b45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.25.0" +version = "1.26.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 0e1f632..b7aace9 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.25.0" # x-release-please-version +__version__ = "1.26.0" # x-release-please-version From 59c6fe8c2dabe8ee5b05611b83d078cf2cedd22f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 03:10:21 +0000 Subject: [PATCH 157/176] chore(internal): add missing files argument to base client --- src/brand/dev/_base_client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/brand/dev/_base_client.py b/src/brand/dev/_base_client.py index 84914ad..d5131e0 100644 --- a/src/brand/dev/_base_client.py +++ b/src/brand/dev/_base_client.py @@ -1247,9 +1247,12 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + ) return self.request(cast_to, opts) def put( @@ -1767,9 +1770,12 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + ) return await self.request(cast_to, opts) async def put( From 6b5d7e49b5a4eea6da122625bcbe37f4343693ac Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 03:14:47 +0000 Subject: [PATCH 158/176] chore: speedup initial import --- src/brand/dev/_client.py | 88 +++++++++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/src/brand/dev/_client.py b/src/brand/dev/_client.py index 26dcb25..5b46246 100644 --- a/src/brand/dev/_client.py +++ b/src/brand/dev/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, Mapping +from typing import TYPE_CHECKING, Any, Mapping from typing_extensions import Self, override import httpx @@ -20,8 +20,8 @@ not_given, ) from ._utils import is_given, get_async_library +from ._compat import cached_property from ._version import __version__ -from .resources import brand from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import BrandDevError, APIStatusError from ._base_client import ( @@ -30,6 +30,10 @@ AsyncAPIClient, ) +if TYPE_CHECKING: + from .resources import brand + from .resources.brand import BrandResource, AsyncBrandResource + __all__ = [ "Timeout", "Transport", @@ -43,10 +47,6 @@ class BrandDev(SyncAPIClient): - brand: brand.BrandResource - with_raw_response: BrandDevWithRawResponse - with_streaming_response: BrandDevWithStreamedResponse - # client options api_key: str @@ -101,9 +101,19 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.brand = brand.BrandResource(self) - self.with_raw_response = BrandDevWithRawResponse(self) - self.with_streaming_response = BrandDevWithStreamedResponse(self) + @cached_property + def brand(self) -> BrandResource: + from .resources.brand import BrandResource + + return BrandResource(self) + + @cached_property + def with_raw_response(self) -> BrandDevWithRawResponse: + return BrandDevWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> BrandDevWithStreamedResponse: + return BrandDevWithStreamedResponse(self) @property @override @@ -211,10 +221,6 @@ def _make_status_error( class AsyncBrandDev(AsyncAPIClient): - brand: brand.AsyncBrandResource - with_raw_response: AsyncBrandDevWithRawResponse - with_streaming_response: AsyncBrandDevWithStreamedResponse - # client options api_key: str @@ -269,9 +275,19 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.brand = brand.AsyncBrandResource(self) - self.with_raw_response = AsyncBrandDevWithRawResponse(self) - self.with_streaming_response = AsyncBrandDevWithStreamedResponse(self) + @cached_property + def brand(self) -> AsyncBrandResource: + from .resources.brand import AsyncBrandResource + + return AsyncBrandResource(self) + + @cached_property + def with_raw_response(self) -> AsyncBrandDevWithRawResponse: + return AsyncBrandDevWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncBrandDevWithStreamedResponse: + return AsyncBrandDevWithStreamedResponse(self) @property @override @@ -379,23 +395,55 @@ def _make_status_error( class BrandDevWithRawResponse: + _client: BrandDev + def __init__(self, client: BrandDev) -> None: - self.brand = brand.BrandResourceWithRawResponse(client.brand) + self._client = client + + @cached_property + def brand(self) -> brand.BrandResourceWithRawResponse: + from .resources.brand import BrandResourceWithRawResponse + + return BrandResourceWithRawResponse(self._client.brand) class AsyncBrandDevWithRawResponse: + _client: AsyncBrandDev + def __init__(self, client: AsyncBrandDev) -> None: - self.brand = brand.AsyncBrandResourceWithRawResponse(client.brand) + self._client = client + + @cached_property + def brand(self) -> brand.AsyncBrandResourceWithRawResponse: + from .resources.brand import AsyncBrandResourceWithRawResponse + + return AsyncBrandResourceWithRawResponse(self._client.brand) class BrandDevWithStreamedResponse: + _client: BrandDev + def __init__(self, client: BrandDev) -> None: - self.brand = brand.BrandResourceWithStreamingResponse(client.brand) + self._client = client + + @cached_property + def brand(self) -> brand.BrandResourceWithStreamingResponse: + from .resources.brand import BrandResourceWithStreamingResponse + + return BrandResourceWithStreamingResponse(self._client.brand) class AsyncBrandDevWithStreamedResponse: + _client: AsyncBrandDev + def __init__(self, client: AsyncBrandDev) -> None: - self.brand = brand.AsyncBrandResourceWithStreamingResponse(client.brand) + self._client = client + + @cached_property + def brand(self) -> brand.AsyncBrandResourceWithStreamingResponse: + from .resources.brand import AsyncBrandResourceWithStreamingResponse + + return AsyncBrandResourceWithStreamingResponse(self._client.brand) Client = BrandDev From cd6a085ebecee255881ff7e9192b62d57133b43d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 03:20:01 +0000 Subject: [PATCH 159/176] fix: use async_to_httpx_files in patch method --- src/brand/dev/_base_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/brand/dev/_base_client.py b/src/brand/dev/_base_client.py index d5131e0..c20a6a8 100644 --- a/src/brand/dev/_base_client.py +++ b/src/brand/dev/_base_client.py @@ -1774,7 +1774,7 @@ async def patch( options: RequestOptions = {}, ) -> ResponseT: opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + method="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts) From f0f213ea93b581e4e53040d84c564dd95cef7696 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 03:19:48 +0000 Subject: [PATCH 160/176] chore(internal): codegen related update --- scripts/lint | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/lint b/scripts/lint index 6ba0912..4dc44b7 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,8 +4,13 @@ set -e cd "$(dirname "$0")/.." -echo "==> Running lints" -rye run lint +if [ "$1" = "--fix" ]; then + echo "==> Running lints with --fix" + rye run fix:ruff +else + echo "==> Running lints" + rye run lint +fi echo "==> Making sure it imports" rye run python -c 'import brand.dev' From 2ca4cf313d019f279744a8657747d8ce4f291550 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:11:38 +0000 Subject: [PATCH 161/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index b3da672..3575433 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 13 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-849e1b7b4e2aeff45d6762768c497da63f605cb80fdbfc0207f7e7555b994b5a.yml -openapi_spec_hash: 9d2ef7a402cb9eaa80cbae645aa8d2f0 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-1391c1e69bcdce517f71265a2f22f4e8d35c3f12a224899e64f85e25897b743a.yml +openapi_spec_hash: f8a2917cc425300d272ae943f88d7bb7 config_hash: 6aaf0fe6f8877c9c5d9af95597123cb4 From 5b553960646eec884b57a40426d27741d4454b2e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:49:26 +0000 Subject: [PATCH 162/176] feat(api): manual updates --- .stats.yml | 4 +- api.md | 2 + src/brand/dev/resources/brand.py | 118 ++++++++++++++++++ src/brand/dev/types/__init__.py | 2 + .../types/brand_prefetch_by_email_params.py | 25 ++++ .../types/brand_prefetch_by_email_response.py | 18 +++ tests/api_resources/test_brand.py | 87 +++++++++++++ 7 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 src/brand/dev/types/brand_prefetch_by_email_params.py create mode 100644 src/brand/dev/types/brand_prefetch_by_email_response.py diff --git a/.stats.yml b/.stats.yml index 3575433..f4a2e85 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 13 +configured_endpoints: 14 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-1391c1e69bcdce517f71265a2f22f4e8d35c3f12a224899e64f85e25897b743a.yml openapi_spec_hash: f8a2917cc425300d272ae943f88d7bb7 -config_hash: 6aaf0fe6f8877c9c5d9af95597123cb4 +config_hash: 7f48d078645cb8331328bcd6f6ab3281 diff --git a/api.md b/api.md index 6ad80e3..c258df9 100644 --- a/api.md +++ b/api.md @@ -9,6 +9,7 @@ from brand.dev.types import ( BrandFontsResponse, BrandIdentifyFromTransactionResponse, BrandPrefetchResponse, + BrandPrefetchByEmailResponse, BrandRetrieveByEmailResponse, BrandRetrieveByIsinResponse, BrandRetrieveByNameResponse, @@ -27,6 +28,7 @@ Methods: - client.brand.fonts(\*\*params) -> BrandFontsResponse - client.brand.identify_from_transaction(\*\*params) -> BrandIdentifyFromTransactionResponse - client.brand.prefetch(\*\*params) -> BrandPrefetchResponse +- client.brand.prefetch_by_email(\*\*params) -> BrandPrefetchByEmailResponse - client.brand.retrieve_by_email(\*\*params) -> BrandRetrieveByEmailResponse - client.brand.retrieve_by_isin(\*\*params) -> BrandRetrieveByIsinResponse - client.brand.retrieve_by_name(\*\*params) -> BrandRetrieveByNameResponse diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index b39a766..a120e2e 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -17,6 +17,7 @@ brand_retrieve_naics_params, brand_retrieve_by_isin_params, brand_retrieve_by_name_params, + brand_prefetch_by_email_params, brand_retrieve_by_email_params, brand_retrieve_by_ticker_params, brand_retrieve_simplified_params, @@ -42,6 +43,7 @@ from ..types.brand_retrieve_naics_response import BrandRetrieveNaicsResponse from ..types.brand_retrieve_by_isin_response import BrandRetrieveByIsinResponse from ..types.brand_retrieve_by_name_response import BrandRetrieveByNameResponse +from ..types.brand_prefetch_by_email_response import BrandPrefetchByEmailResponse from ..types.brand_retrieve_by_email_response import BrandRetrieveByEmailResponse from ..types.brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse from ..types.brand_retrieve_simplified_response import BrandRetrieveSimplifiedResponse @@ -711,6 +713,58 @@ def prefetch( cast_to=BrandPrefetchResponse, ) + def prefetch_by_email( + self, + *, + email: str, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandPrefetchByEmailResponse: + """ + Signal that you may fetch brand data for a particular domain soon to improve + latency. This endpoint accepts an email address, extracts the domain from it, + validates that it's not a disposable or free email provider, and queues the + domain for prefetching. This endpoint does not charge credits and is available + for paid customers to optimize future requests. [You must be on a paid plan to + use this endpoint] + + Args: + email: Email address to prefetch brand data for. The domain will be extracted from the + email. Free email providers (gmail.com, yahoo.com, etc.) and disposable email + addresses are not allowed. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/brand/prefetch-by-email", + body=maybe_transform( + { + "email": email, + "timeout_ms": timeout_ms, + }, + brand_prefetch_by_email_params.BrandPrefetchByEmailParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrandPrefetchByEmailResponse, + ) + def retrieve_by_email( self, *, @@ -2147,6 +2201,58 @@ async def prefetch( cast_to=BrandPrefetchResponse, ) + async def prefetch_by_email( + self, + *, + email: str, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandPrefetchByEmailResponse: + """ + Signal that you may fetch brand data for a particular domain soon to improve + latency. This endpoint accepts an email address, extracts the domain from it, + validates that it's not a disposable or free email provider, and queues the + domain for prefetching. This endpoint does not charge credits and is available + for paid customers to optimize future requests. [You must be on a paid plan to + use this endpoint] + + Args: + email: Email address to prefetch brand data for. The domain will be extracted from the + email. Free email providers (gmail.com, yahoo.com, etc.) and disposable email + addresses are not allowed. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/brand/prefetch-by-email", + body=await async_maybe_transform( + { + "email": email, + "timeout_ms": timeout_ms, + }, + brand_prefetch_by_email_params.BrandPrefetchByEmailParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrandPrefetchByEmailResponse, + ) + async def retrieve_by_email( self, *, @@ -2941,6 +3047,9 @@ def __init__(self, brand: BrandResource) -> None: self.prefetch = to_raw_response_wrapper( brand.prefetch, ) + self.prefetch_by_email = to_raw_response_wrapper( + brand.prefetch_by_email, + ) self.retrieve_by_email = to_raw_response_wrapper( brand.retrieve_by_email, ) @@ -2986,6 +3095,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.prefetch = async_to_raw_response_wrapper( brand.prefetch, ) + self.prefetch_by_email = async_to_raw_response_wrapper( + brand.prefetch_by_email, + ) self.retrieve_by_email = async_to_raw_response_wrapper( brand.retrieve_by_email, ) @@ -3031,6 +3143,9 @@ def __init__(self, brand: BrandResource) -> None: self.prefetch = to_streamed_response_wrapper( brand.prefetch, ) + self.prefetch_by_email = to_streamed_response_wrapper( + brand.prefetch_by_email, + ) self.retrieve_by_email = to_streamed_response_wrapper( brand.retrieve_by_email, ) @@ -3076,6 +3191,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.prefetch = async_to_streamed_response_wrapper( brand.prefetch, ) + self.prefetch_by_email = async_to_streamed_response_wrapper( + brand.prefetch_by_email, + ) self.retrieve_by_email = async_to_streamed_response_wrapper( brand.retrieve_by_email, ) diff --git a/src/brand/dev/types/__init__.py b/src/brand/dev/types/__init__.py index 9c3f961..7617e44 100644 --- a/src/brand/dev/types/__init__.py +++ b/src/brand/dev/types/__init__.py @@ -18,10 +18,12 @@ from .brand_retrieve_by_isin_params import BrandRetrieveByIsinParams as BrandRetrieveByIsinParams from .brand_retrieve_by_name_params import BrandRetrieveByNameParams as BrandRetrieveByNameParams from .brand_retrieve_naics_response import BrandRetrieveNaicsResponse as BrandRetrieveNaicsResponse +from .brand_prefetch_by_email_params import BrandPrefetchByEmailParams as BrandPrefetchByEmailParams from .brand_retrieve_by_email_params import BrandRetrieveByEmailParams as BrandRetrieveByEmailParams from .brand_retrieve_by_isin_response import BrandRetrieveByIsinResponse as BrandRetrieveByIsinResponse from .brand_retrieve_by_name_response import BrandRetrieveByNameResponse as BrandRetrieveByNameResponse from .brand_retrieve_by_ticker_params import BrandRetrieveByTickerParams as BrandRetrieveByTickerParams +from .brand_prefetch_by_email_response import BrandPrefetchByEmailResponse as BrandPrefetchByEmailResponse from .brand_retrieve_by_email_response import BrandRetrieveByEmailResponse as BrandRetrieveByEmailResponse from .brand_retrieve_simplified_params import BrandRetrieveSimplifiedParams as BrandRetrieveSimplifiedParams from .brand_retrieve_by_ticker_response import BrandRetrieveByTickerResponse as BrandRetrieveByTickerResponse diff --git a/src/brand/dev/types/brand_prefetch_by_email_params.py b/src/brand/dev/types/brand_prefetch_by_email_params.py new file mode 100644 index 0000000..ec7cc6f --- /dev/null +++ b/src/brand/dev/types/brand_prefetch_by_email_params.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["BrandPrefetchByEmailParams"] + + +class BrandPrefetchByEmailParams(TypedDict, total=False): + email: Required[str] + """Email address to prefetch brand data for. + + The domain will be extracted from the email. Free email providers (gmail.com, + yahoo.com, etc.) and disposable email addresses are not allowed. + """ + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_prefetch_by_email_response.py b/src/brand/dev/types/brand_prefetch_by_email_response.py new file mode 100644 index 0000000..c117f7d --- /dev/null +++ b/src/brand/dev/types/brand_prefetch_by_email_response.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrandPrefetchByEmailResponse"] + + +class BrandPrefetchByEmailResponse(BaseModel): + domain: Optional[str] = None + """The domain that was queued for prefetching""" + + message: Optional[str] = None + """Success message""" + + status: Optional[str] = None + """Status of the response, e.g., 'ok'""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index 75673d4..a4b713d 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -19,6 +19,7 @@ BrandRetrieveNaicsResponse, BrandRetrieveByIsinResponse, BrandRetrieveByNameResponse, + BrandPrefetchByEmailResponse, BrandRetrieveByEmailResponse, BrandRetrieveByTickerResponse, BrandRetrieveSimplifiedResponse, @@ -296,6 +297,49 @@ def test_streaming_response_prefetch(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_prefetch_by_email(self, client: BrandDev) -> None: + brand = client.brand.prefetch_by_email( + email="dev@stainless.com", + ) + assert_matches_type(BrandPrefetchByEmailResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_prefetch_by_email_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.prefetch_by_email( + email="dev@stainless.com", + timeout_ms=1, + ) + assert_matches_type(BrandPrefetchByEmailResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_prefetch_by_email(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.prefetch_by_email( + email="dev@stainless.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandPrefetchByEmailResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_prefetch_by_email(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.prefetch_by_email( + email="dev@stainless.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandPrefetchByEmailResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_retrieve_by_email(self, client: BrandDev) -> None: @@ -925,6 +969,49 @@ async def test_streaming_response_prefetch(self, async_client: AsyncBrandDev) -> assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_prefetch_by_email(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.prefetch_by_email( + email="dev@stainless.com", + ) + assert_matches_type(BrandPrefetchByEmailResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_prefetch_by_email_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.prefetch_by_email( + email="dev@stainless.com", + timeout_ms=1, + ) + assert_matches_type(BrandPrefetchByEmailResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_prefetch_by_email(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.prefetch_by_email( + email="dev@stainless.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandPrefetchByEmailResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_prefetch_by_email(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.prefetch_by_email( + email="dev@stainless.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandPrefetchByEmailResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_retrieve_by_email(self, async_client: AsyncBrandDev) -> None: From 706f44be8f80ae8a894e1d997b044001299eb3e1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:56:13 +0000 Subject: [PATCH 163/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f3dbfd2..4eb8987 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.26.0" + ".": "1.27.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6cc4b45..69c176d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.26.0" +version = "1.27.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index b7aace9..89f64e9 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.26.0" # x-release-please-version +__version__ = "1.27.0" # x-release-please-version From 405161eb0b8783035d623bcc9f2d21752c04587e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:05:25 +0000 Subject: [PATCH 164/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index f4a2e85..53bd8bc 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 14 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-1391c1e69bcdce517f71265a2f22f4e8d35c3f12a224899e64f85e25897b743a.yml -openapi_spec_hash: f8a2917cc425300d272ae943f88d7bb7 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-53bd1b5a6d4207268e23f4d22e28317d660c8c27a2be37c5cc9db2702cf04b6c.yml +openapi_spec_hash: 71974f160905d65b2dc8ad871c994c98 config_hash: 7f48d078645cb8331328bcd6f6ab3281 From 99e3d6f0dc938d9b65638306ef67cfee0705712b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:15:54 +0000 Subject: [PATCH 165/176] feat(api): api update --- .stats.yml | 4 +-- LICENSE | 2 +- src/brand/dev/resources/brand.py | 54 +++++++++++++++----------------- 3 files changed, 29 insertions(+), 31 deletions(-) diff --git a/.stats.yml b/.stats.yml index 53bd8bc..078c968 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 14 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-53bd1b5a6d4207268e23f4d22e28317d660c8c27a2be37c5cc9db2702cf04b6c.yml -openapi_spec_hash: 71974f160905d65b2dc8ad871c994c98 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-eb5286aea707c617b23a4a5f5a7720af2bbb3f76b4b7354faa466489073b9bb1.yml +openapi_spec_hash: fbb7a3c05db6c108b979807731752dd6 config_hash: 7f48d078645cb8331328bcd6f6ab3281 diff --git a/LICENSE b/LICENSE index d93da50..295331e 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2025 Brand Dev + Copyright 2026 Brand Dev Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index a120e2e..587d5d0 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -201,11 +201,11 @@ def ai_query( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandAIQueryResponse: - """Beta feature: Use AI to extract specific data points from a brand's website. + """Use AI to extract specific data points from a brand's website. - The - AI will crawl the website and extract the requested information based on the - provided data points. + The AI will crawl + the website and extract the requested information based on the provided data + points. Args: data_to_extract: Array of data points to extract from the website @@ -256,8 +256,8 @@ def fonts( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandFontsResponse: """ - Beta feature: Extract font information from a brand's website including font - families, usage statistics, fallbacks, and element/word counts. + Extract font information from a brand's website including font families, usage + statistics, fallbacks, and element/word counts. Args: domain: Domain name to extract fonts from (e.g., 'example.com', 'google.com'). The @@ -1429,12 +1429,12 @@ def screenshot( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandScreenshotResponse: - """Beta feature: Capture a screenshot of a website. + """Capture a screenshot of a website. - Supports both viewport - (standard browser view) and full-page screenshots. Can also screenshot specific - page types (login, pricing, etc.) by using heuristics to find the appropriate - URL. Returns a URL to the uploaded screenshot image hosted on our CDN. + Supports both viewport (standard browser + view) and full-page screenshots. Can also screenshot specific page types (login, + pricing, etc.) by using heuristics to find the appropriate URL. Returns a URL to + the uploaded screenshot image hosted on our CDN. Args: domain: Domain name to take screenshot of (e.g., 'example.com', 'google.com'). The @@ -1495,9 +1495,8 @@ def styleguide( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandStyleguideResponse: """ - Beta feature: Automatically extract comprehensive design system information from - a brand's website including colors, typography, spacing, shadows, and UI - components. + Automatically extract comprehensive design system information from a brand's + website including colors, typography, spacing, shadows, and UI components. Args: domain: Domain name to extract styleguide from (e.g., 'example.com', 'google.com'). The @@ -1689,11 +1688,11 @@ async def ai_query( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandAIQueryResponse: - """Beta feature: Use AI to extract specific data points from a brand's website. + """Use AI to extract specific data points from a brand's website. - The - AI will crawl the website and extract the requested information based on the - provided data points. + The AI will crawl + the website and extract the requested information based on the provided data + points. Args: data_to_extract: Array of data points to extract from the website @@ -1744,8 +1743,8 @@ async def fonts( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandFontsResponse: """ - Beta feature: Extract font information from a brand's website including font - families, usage statistics, fallbacks, and element/word counts. + Extract font information from a brand's website including font families, usage + statistics, fallbacks, and element/word counts. Args: domain: Domain name to extract fonts from (e.g., 'example.com', 'google.com'). The @@ -2917,12 +2916,12 @@ async def screenshot( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandScreenshotResponse: - """Beta feature: Capture a screenshot of a website. + """Capture a screenshot of a website. - Supports both viewport - (standard browser view) and full-page screenshots. Can also screenshot specific - page types (login, pricing, etc.) by using heuristics to find the appropriate - URL. Returns a URL to the uploaded screenshot image hosted on our CDN. + Supports both viewport (standard browser + view) and full-page screenshots. Can also screenshot specific page types (login, + pricing, etc.) by using heuristics to find the appropriate URL. Returns a URL to + the uploaded screenshot image hosted on our CDN. Args: domain: Domain name to take screenshot of (e.g., 'example.com', 'google.com'). The @@ -2983,9 +2982,8 @@ async def styleguide( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandStyleguideResponse: """ - Beta feature: Automatically extract comprehensive design system information from - a brand's website including colors, typography, spacing, shadows, and UI - components. + Automatically extract comprehensive design system information from a brand's + website including colors, typography, spacing, shadows, and UI components. Args: domain: Domain name to extract styleguide from (e.g., 'example.com', 'google.com'). The From 305466f48c6f4af2d6d5fd328041b4c31c9a8f83 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:56:23 +0000 Subject: [PATCH 166/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 078c968..a7e2596 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 14 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-eb5286aea707c617b23a4a5f5a7720af2bbb3f76b4b7354faa466489073b9bb1.yml -openapi_spec_hash: fbb7a3c05db6c108b979807731752dd6 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-317a4cbdb3460421558cf44d04d0f17a8f2599ccec2bd697f407ccdb1d4bfc47.yml +openapi_spec_hash: e678a71b83ee0a2ac973bebd6e26f823 config_hash: 7f48d078645cb8331328bcd6f6ab3281 From db3f5b840e40d028ab9ac0f72b1ab1248f4a75d8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:35:50 +0000 Subject: [PATCH 167/176] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index a7e2596..5bf29b9 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 14 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-317a4cbdb3460421558cf44d04d0f17a8f2599ccec2bd697f407ccdb1d4bfc47.yml -openapi_spec_hash: e678a71b83ee0a2ac973bebd6e26f823 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-c45c9faa82a7f8b0b0221deab17a0f6cc6b096906e192aea9ef535e17667008f.yml +openapi_spec_hash: f3330c699ed3a55e48cdeecfce379e7b config_hash: 7f48d078645cb8331328bcd6f6ab3281 From 0736c0ffa500e0d7f8eddd100b85fbafa15948bb Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:37:01 +0000 Subject: [PATCH 168/176] feat(api): manual updates --- .stats.yml | 4 +- api.md | 2 + src/brand/dev/resources/brand.py | 118 ++++++++++++++++++ src/brand/dev/types/__init__.py | 2 + .../dev/types/brand_ai_products_params.py | 24 ++++ .../dev/types/brand_ai_products_response.py | 51 ++++++++ tests/api_resources/test_brand.py | 89 +++++++++++++ 7 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 src/brand/dev/types/brand_ai_products_params.py create mode 100644 src/brand/dev/types/brand_ai_products_response.py diff --git a/.stats.yml b/.stats.yml index 5bf29b9..4896159 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 14 +configured_endpoints: 15 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-c45c9faa82a7f8b0b0221deab17a0f6cc6b096906e192aea9ef535e17667008f.yml openapi_spec_hash: f3330c699ed3a55e48cdeecfce379e7b -config_hash: 7f48d078645cb8331328bcd6f6ab3281 +config_hash: 6f10592c7d0c3bafefc1271472283217 diff --git a/api.md b/api.md index c258df9..9f48a8b 100644 --- a/api.md +++ b/api.md @@ -5,6 +5,7 @@ Types: ```python from brand.dev.types import ( BrandRetrieveResponse, + BrandAIProductsResponse, BrandAIQueryResponse, BrandFontsResponse, BrandIdentifyFromTransactionResponse, @@ -24,6 +25,7 @@ from brand.dev.types import ( Methods: - client.brand.retrieve(\*\*params) -> BrandRetrieveResponse +- client.brand.ai_products(\*\*params) -> BrandAIProductsResponse - client.brand.ai_query(\*\*params) -> BrandAIQueryResponse - client.brand.fonts(\*\*params) -> BrandFontsResponse - client.brand.identify_from_transaction(\*\*params) -> BrandIdentifyFromTransactionResponse diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 587d5d0..74532aa 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -14,6 +14,7 @@ brand_retrieve_params, brand_screenshot_params, brand_styleguide_params, + brand_ai_products_params, brand_retrieve_naics_params, brand_retrieve_by_isin_params, brand_retrieve_by_name_params, @@ -40,6 +41,7 @@ from ..types.brand_retrieve_response import BrandRetrieveResponse from ..types.brand_screenshot_response import BrandScreenshotResponse from ..types.brand_styleguide_response import BrandStyleguideResponse +from ..types.brand_ai_products_response import BrandAIProductsResponse from ..types.brand_retrieve_naics_response import BrandRetrieveNaicsResponse from ..types.brand_retrieve_by_isin_response import BrandRetrieveByIsinResponse from ..types.brand_retrieve_by_name_response import BrandRetrieveByNameResponse @@ -187,6 +189,58 @@ def retrieve( cast_to=BrandRetrieveResponse, ) + def ai_products( + self, + *, + domain: str, + max_products: int | Omit = omit, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandAIProductsResponse: + """Beta feature: Use AI to extract product information from a brand's website. + + The + AI will analyze the website and return a list of products with details such as + name, description, pricing, features, and more. + + Args: + domain: The domain name to analyze + + max_products: Maximum number of products to extract. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/brand/ai/products", + body=maybe_transform( + { + "domain": domain, + "max_products": max_products, + "timeout_ms": timeout_ms, + }, + brand_ai_products_params.BrandAIProductsParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrandAIProductsResponse, + ) + def ai_query( self, *, @@ -1674,6 +1728,58 @@ async def retrieve( cast_to=BrandRetrieveResponse, ) + async def ai_products( + self, + *, + domain: str, + max_products: int | Omit = omit, + timeout_ms: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BrandAIProductsResponse: + """Beta feature: Use AI to extract product information from a brand's website. + + The + AI will analyze the website and return a list of products with details such as + name, description, pricing, features, and more. + + Args: + domain: The domain name to analyze + + max_products: Maximum number of products to extract. + + timeout_ms: Optional timeout in milliseconds for the request. If the request takes longer + than this value, it will be aborted with a 408 status code. Maximum allowed + value is 300000ms (5 minutes). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/brand/ai/products", + body=await async_maybe_transform( + { + "domain": domain, + "max_products": max_products, + "timeout_ms": timeout_ms, + }, + brand_ai_products_params.BrandAIProductsParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrandAIProductsResponse, + ) + async def ai_query( self, *, @@ -3033,6 +3139,9 @@ def __init__(self, brand: BrandResource) -> None: self.retrieve = to_raw_response_wrapper( brand.retrieve, ) + self.ai_products = to_raw_response_wrapper( + brand.ai_products, + ) self.ai_query = to_raw_response_wrapper( brand.ai_query, ) @@ -3081,6 +3190,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.retrieve = async_to_raw_response_wrapper( brand.retrieve, ) + self.ai_products = async_to_raw_response_wrapper( + brand.ai_products, + ) self.ai_query = async_to_raw_response_wrapper( brand.ai_query, ) @@ -3129,6 +3241,9 @@ def __init__(self, brand: BrandResource) -> None: self.retrieve = to_streamed_response_wrapper( brand.retrieve, ) + self.ai_products = to_streamed_response_wrapper( + brand.ai_products, + ) self.ai_query = to_streamed_response_wrapper( brand.ai_query, ) @@ -3177,6 +3292,9 @@ def __init__(self, brand: AsyncBrandResource) -> None: self.retrieve = async_to_streamed_response_wrapper( brand.retrieve, ) + self.ai_products = async_to_streamed_response_wrapper( + brand.ai_products, + ) self.ai_query = async_to_streamed_response_wrapper( brand.ai_query, ) diff --git a/src/brand/dev/types/__init__.py b/src/brand/dev/types/__init__.py index 7617e44..1f767b1 100644 --- a/src/brand/dev/types/__init__.py +++ b/src/brand/dev/types/__init__.py @@ -12,8 +12,10 @@ from .brand_retrieve_response import BrandRetrieveResponse as BrandRetrieveResponse from .brand_screenshot_params import BrandScreenshotParams as BrandScreenshotParams from .brand_styleguide_params import BrandStyleguideParams as BrandStyleguideParams +from .brand_ai_products_params import BrandAIProductsParams as BrandAIProductsParams from .brand_screenshot_response import BrandScreenshotResponse as BrandScreenshotResponse from .brand_styleguide_response import BrandStyleguideResponse as BrandStyleguideResponse +from .brand_ai_products_response import BrandAIProductsResponse as BrandAIProductsResponse from .brand_retrieve_naics_params import BrandRetrieveNaicsParams as BrandRetrieveNaicsParams from .brand_retrieve_by_isin_params import BrandRetrieveByIsinParams as BrandRetrieveByIsinParams from .brand_retrieve_by_name_params import BrandRetrieveByNameParams as BrandRetrieveByNameParams diff --git a/src/brand/dev/types/brand_ai_products_params.py b/src/brand/dev/types/brand_ai_products_params.py new file mode 100644 index 0000000..e9ad4ec --- /dev/null +++ b/src/brand/dev/types/brand_ai_products_params.py @@ -0,0 +1,24 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["BrandAIProductsParams"] + + +class BrandAIProductsParams(TypedDict, total=False): + domain: Required[str] + """The domain name to analyze""" + + max_products: Annotated[int, PropertyInfo(alias="maxProducts")] + """Maximum number of products to extract.""" + + timeout_ms: Annotated[int, PropertyInfo(alias="timeoutMS")] + """Optional timeout in milliseconds for the request. + + If the request takes longer than this value, it will be aborted with a 408 + status code. Maximum allowed value is 300000ms (5 minutes). + """ diff --git a/src/brand/dev/types/brand_ai_products_response.py b/src/brand/dev/types/brand_ai_products_response.py new file mode 100644 index 0000000..1ec6a60 --- /dev/null +++ b/src/brand/dev/types/brand_ai_products_response.py @@ -0,0 +1,51 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["BrandAIProductsResponse", "Product"] + + +class Product(BaseModel): + description: str + """Description of the product""" + + features: List[str] + """List of product features""" + + name: str + """Name of the product""" + + tags: List[str] + """Tags associated with the product""" + + target_audience: List[str] + """Target audience for the product (array of strings)""" + + billing_frequency: Optional[Literal["monthly", "yearly", "one_time", "usage_based"]] = None + """Billing frequency for the product""" + + category: Optional[str] = None + """Category of the product""" + + currency: Optional[str] = None + """Currency code for the price (e.g., USD, EUR)""" + + image_url: Optional[str] = None + """URL to the product image""" + + price: Optional[float] = None + """Price of the product""" + + pricing_model: Optional[Literal["per_seat", "flat", "tiered", "freemium", "custom"]] = None + """Pricing model for the product""" + + url: Optional[str] = None + """URL to the product page""" + + +class BrandAIProductsResponse(BaseModel): + products: Optional[List[Product]] = None + """Array of products extracted from the website""" diff --git a/tests/api_resources/test_brand.py b/tests/api_resources/test_brand.py index a4b713d..06d0515 100644 --- a/tests/api_resources/test_brand.py +++ b/tests/api_resources/test_brand.py @@ -14,6 +14,7 @@ BrandAIQueryResponse, BrandPrefetchResponse, BrandRetrieveResponse, + BrandAIProductsResponse, BrandScreenshotResponse, BrandStyleguideResponse, BrandRetrieveNaicsResponse, @@ -71,6 +72,50 @@ def test_streaming_response_retrieve(self, client: BrandDev) -> None: assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_ai_products(self, client: BrandDev) -> None: + brand = client.brand.ai_products( + domain="domain", + ) + assert_matches_type(BrandAIProductsResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_method_ai_products_with_all_params(self, client: BrandDev) -> None: + brand = client.brand.ai_products( + domain="domain", + max_products=1, + timeout_ms=1, + ) + assert_matches_type(BrandAIProductsResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_raw_response_ai_products(self, client: BrandDev) -> None: + response = client.brand.with_raw_response.ai_products( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = response.parse() + assert_matches_type(BrandAIProductsResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + def test_streaming_response_ai_products(self, client: BrandDev) -> None: + with client.brand.with_streaming_response.ai_products( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = response.parse() + assert_matches_type(BrandAIProductsResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_ai_query(self, client: BrandDev) -> None: @@ -743,6 +788,50 @@ async def test_streaming_response_retrieve(self, async_client: AsyncBrandDev) -> assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_ai_products(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.ai_products( + domain="domain", + ) + assert_matches_type(BrandAIProductsResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_method_ai_products_with_all_params(self, async_client: AsyncBrandDev) -> None: + brand = await async_client.brand.ai_products( + domain="domain", + max_products=1, + timeout_ms=1, + ) + assert_matches_type(BrandAIProductsResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_raw_response_ai_products(self, async_client: AsyncBrandDev) -> None: + response = await async_client.brand.with_raw_response.ai_products( + domain="domain", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + brand = await response.parse() + assert_matches_type(BrandAIProductsResponse, brand, path=["response"]) + + @pytest.mark.skip(reason="Prism tests are disabled") + @parametrize + async def test_streaming_response_ai_products(self, async_client: AsyncBrandDev) -> None: + async with async_client.brand.with_streaming_response.ai_products( + domain="domain", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + brand = await response.parse() + assert_matches_type(BrandAIProductsResponse, brand, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_ai_query(self, async_client: AsyncBrandDev) -> None: From aeec3b65c20b557a5f54579f613c7f63fada6374 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:46:37 +0000 Subject: [PATCH 169/176] feat(api): api update --- .stats.yml | 4 ++-- src/brand/dev/resources/brand.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.stats.yml b/.stats.yml index 4896159..ccc6aee 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-c45c9faa82a7f8b0b0221deab17a0f6cc6b096906e192aea9ef535e17667008f.yml -openapi_spec_hash: f3330c699ed3a55e48cdeecfce379e7b +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/brand-dev%2Fbrand.dev-863ddc13e032497459a639cf02a16349831dda7e39557cbd5ce33da34d086b02.yml +openapi_spec_hash: f972aac9618fe8df340d96344b3d0578 config_hash: 6f10592c7d0c3bafefc1271472283217 diff --git a/src/brand/dev/resources/brand.py b/src/brand/dev/resources/brand.py index 74532aa..fe9cbe7 100644 --- a/src/brand/dev/resources/brand.py +++ b/src/brand/dev/resources/brand.py @@ -202,11 +202,11 @@ def ai_products( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandAIProductsResponse: - """Beta feature: Use AI to extract product information from a brand's website. + """Beta feature: Extract product information from a brand's website. - The - AI will analyze the website and return a list of products with details such as - name, description, pricing, features, and more. + Brand.dev will + analyze the website and return a list of products with details such as name, + description, image, pricing, features, and more. Args: domain: The domain name to analyze @@ -1741,11 +1741,11 @@ async def ai_products( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BrandAIProductsResponse: - """Beta feature: Use AI to extract product information from a brand's website. + """Beta feature: Extract product information from a brand's website. - The - AI will analyze the website and return a list of products with details such as - name, description, pricing, features, and more. + Brand.dev will + analyze the website and return a list of products with details such as name, + description, image, pricing, features, and more. Args: domain: The domain name to analyze From c6cc546c9b2883c2232ece6187728bdf98def9eb Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:51:40 +0000 Subject: [PATCH 170/176] chore(internal): version bump --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/brand/dev/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4eb8987..31d8238 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.27.0" + ".": "1.28.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 69c176d..98ce3e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "brand.dev" -version = "1.27.0" +version = "1.28.0" description = "The official Python library for the brand.dev API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/brand/dev/_version.py b/src/brand/dev/_version.py index 89f64e9..f67f9fb 100644 --- a/src/brand/dev/_version.py +++ b/src/brand/dev/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "brand.dev" -__version__ = "1.27.0" # x-release-please-version +__version__ = "1.28.0" # x-release-please-version From 66e12c0bfb6cdf1eefc0ce4e5f73a0a994a3472f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 03:12:20 +0000 Subject: [PATCH 171/176] docs: prominently feature MCP server setup in root SDK readmes --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 03f5619..8e5207b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,15 @@ and offers both synchronous and asynchronous clients powered by [httpx](https:// It is generated with [Stainless](https://www.stainless.com/). +## MCP Server + +Use the Brand Dev MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application. + +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=brand.dev-mcp&config=eyJuYW1lIjoiYnJhbmQuZGV2LW1jcCIsInRyYW5zcG9ydCI6InNzZSIsInVybCI6Imh0dHBzOi8vYnJhbmQtZGV2LnN0bG1jcC5jb20vc3NlIn0) +[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22brand.dev-mcp%22%2C%22type%22%3A%22sse%22%2C%22url%22%3A%22https%3A%2F%2Fbrand-dev.stlmcp.com%2Fsse%22%7D) + +> Note: You may need to set environment variables in your MCP client. + ## Documentation The REST API documentation can be found on [docs.brand.dev](https://docs.brand.dev/). The full API of this library can be found in [api.md](api.md). From 31ea6d6cf4610b5585b20b2ea6c10f72118c6070 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 03:47:56 +0000 Subject: [PATCH 172/176] feat(client): add support for binary request streaming --- src/brand/dev/_base_client.py | 145 ++++++++++++++++++++++++-- src/brand/dev/_models.py | 17 +++- src/brand/dev/_types.py | 9 ++ tests/test_client.py | 187 +++++++++++++++++++++++++++++++++- 4 files changed, 344 insertions(+), 14 deletions(-) diff --git a/src/brand/dev/_base_client.py b/src/brand/dev/_base_client.py index c20a6a8..d85b510 100644 --- a/src/brand/dev/_base_client.py +++ b/src/brand/dev/_base_client.py @@ -9,6 +9,7 @@ import inspect import logging import platform +import warnings import email.utils from types import TracebackType from random import random @@ -51,9 +52,11 @@ ResponseT, AnyMapping, PostParser, + BinaryTypes, RequestFiles, HttpxSendArgs, RequestOptions, + AsyncBinaryTypes, HttpxRequestFiles, ModelBuilderProtocol, not_given, @@ -477,8 +480,19 @@ def _build_request( retries_taken: int = 0, ) -> httpx.Request: if log.isEnabledFor(logging.DEBUG): - log.debug("Request options: %s", model_dump(options, exclude_unset=True)) - + log.debug( + "Request options: %s", + model_dump( + options, + exclude_unset=True, + # Pydantic v1 can't dump every type we support in content, so we exclude it for now. + exclude={ + "content", + } + if PYDANTIC_V1 + else {}, + ), + ) kwargs: dict[str, Any] = {} json_data = options.json_data @@ -532,7 +546,13 @@ def _build_request( is_body_allowed = options.method.lower() != "get" if is_body_allowed: - if isinstance(json_data, bytes): + if options.content is not None and json_data is not None: + raise TypeError("Passing both `content` and `json_data` is not supported") + if options.content is not None and files is not None: + raise TypeError("Passing both `content` and `files` is not supported") + if options.content is not None: + kwargs["content"] = options.content + elif isinstance(json_data, bytes): kwargs["content"] = json_data else: kwargs["json"] = json_data if is_given(json_data) else None @@ -1194,6 +1214,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[False] = False, @@ -1206,6 +1227,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[True], @@ -1219,6 +1241,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool, @@ -1231,13 +1254,25 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool = False, stream_cls: type[_StreamT] | None = None, ) -> ResponseT | _StreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) @@ -1247,11 +1282,23 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1261,11 +1308,23 @@ def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1275,9 +1334,19 @@ def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return self.request(cast_to, opts) def get_api_list( @@ -1717,6 +1786,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[False] = False, @@ -1729,6 +1799,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[True], @@ -1742,6 +1813,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool, @@ -1754,13 +1826,25 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool = False, stream_cls: type[_AsyncStreamT] | None = None, ) -> ResponseT | _AsyncStreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) @@ -1770,11 +1854,28 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="patch", + url=path, + json_data=body, + content=content, + files=await async_to_httpx_files(files), + **options, ) return await self.request(cast_to, opts) @@ -1784,11 +1885,23 @@ async def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts) @@ -1798,9 +1911,19 @@ async def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return await self.request(cast_to, opts) def get_api_list( diff --git a/src/brand/dev/_models.py b/src/brand/dev/_models.py index ca9500b..29070e0 100644 --- a/src/brand/dev/_models.py +++ b/src/brand/dev/_models.py @@ -3,7 +3,20 @@ import os import inspect import weakref -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast +from typing import ( + IO, + TYPE_CHECKING, + Any, + Type, + Union, + Generic, + TypeVar, + Callable, + Iterable, + Optional, + AsyncIterable, + cast, +) from datetime import date, datetime from typing_extensions import ( List, @@ -787,6 +800,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): timeout: float | Timeout | None files: HttpxRequestFiles | None idempotency_key: str + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] json_data: Body extra_json: AnyMapping follow_redirects: bool @@ -805,6 +819,7 @@ class FinalRequestOptions(pydantic.BaseModel): post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() follow_redirects: Union[bool, None] = None + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. json_data: Union[Body, None] = None diff --git a/src/brand/dev/_types.py b/src/brand/dev/_types.py index c9ea1de..fce564e 100644 --- a/src/brand/dev/_types.py +++ b/src/brand/dev/_types.py @@ -13,9 +13,11 @@ Mapping, TypeVar, Callable, + Iterable, Iterator, Optional, Sequence, + AsyncIterable, ) from typing_extensions import ( Set, @@ -56,6 +58,13 @@ else: Base64FileInput = Union[IO[bytes], PathLike] FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. + + +# Used for sending raw binary data / streaming data in request bodies +# e.g. for file uploads without multipart encoding +BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]] +AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]] + FileTypes = Union[ # file (or bytes) FileContent, diff --git a/tests/test_client.py b/tests/test_client.py index 0e001a2..472b7bf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,10 +8,11 @@ import json import asyncio import inspect +import dataclasses import tracemalloc -from typing import Any, Union, cast +from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast from unittest import mock -from typing_extensions import Literal +from typing_extensions import Literal, AsyncIterator, override import httpx import pytest @@ -36,6 +37,7 @@ from .utils import update_env +T = TypeVar("T") base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") api_key = "My API Key" @@ -50,6 +52,57 @@ def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: return 0.1 +def mirror_request_content(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=request.content) + + +# note: we can't use the httpx.MockTransport class as it consumes the request +# body itself, which means we can't test that the body is read lazily +class MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): + def __init__( + self, + handler: Callable[[httpx.Request], httpx.Response] + | Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]], + ) -> None: + self.handler = handler + + @override + def handle_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert not inspect.iscoroutinefunction(self.handler), "handler must not be a coroutine function" + assert inspect.isfunction(self.handler), "handler must be a function" + return self.handler(request) + + @override + async def handle_async_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert inspect.iscoroutinefunction(self.handler), "handler must be a coroutine function" + return await self.handler(request) + + +@dataclasses.dataclass +class Counter: + value: int = 0 + + +def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + +async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> AsyncIterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + def _get_open_connections(client: BrandDev | AsyncBrandDev) -> int: transport = client._client._transport assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) @@ -502,6 +555,70 @@ def test_multipart_repeating_array(self, client: BrandDev) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload(self, respx_mock: MockRouter, client: BrandDev) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + def test_binary_content_upload_with_iterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_sync_iterator([file_content], counter=counter) + + def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=request.read()) + + with BrandDev( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + response = client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload_with_body_is_deprecated(self, respx_mock: MockRouter, client: BrandDev) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) def test_basic_union_response(self, respx_mock: MockRouter, client: BrandDev) -> None: class Model1(BaseModel): @@ -1321,6 +1438,72 @@ def test_multipart_repeating_array(self, async_client: AsyncBrandDev) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncBrandDev) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = await async_client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + async def test_binary_content_upload_with_asynciterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_async_iterator([file_content], counter=counter) + + async def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=await request.aread()) + + async with AsyncBrandDev( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + response = await client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload_with_body_is_deprecated( + self, respx_mock: MockRouter, async_client: AsyncBrandDev + ) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = await async_client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncBrandDev) -> None: class Model1(BaseModel): From 9c0e8163c4bda312204128c97211f8cb2f7370af Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 03:34:03 +0000 Subject: [PATCH 173/176] chore(internal): update `actions/checkout` version --- .github/workflows/ci.yml | 6 +++--- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/release-doctor.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f0f03d..5fb1eec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/brand.dev-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -44,7 +44,7 @@ jobs: id-token: write runs-on: ${{ github.repository == 'stainless-sdks/brand.dev-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -81,7 +81,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/brand.dev-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index e77a334..c8f1269 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index 6d1c39b..e30fe2f 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'brand-dot-dev/python-sdk' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check release environment run: | From 204b43493cc2fc40d8d831954fccfdffe40e5621 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 03:24:04 +0000 Subject: [PATCH 174/176] chore(ci): upgrade `actions/github-script` --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fb1eec..5896e8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: - name: Get GitHub OIDC Token if: github.repository == 'stainless-sdks/brand.dev-python' id: github-oidc - uses: actions/github-script@v6 + uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); From 5ba6fd950972a2bf5b2e95a0fef6f849cb3e0555 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 03:28:31 +0000 Subject: [PATCH 175/176] fix(docs): fix mcp installation instructions for remote servers --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8e5207b..7ff4d43 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ It is generated with [Stainless](https://www.stainless.com/). Use the Brand Dev MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application. -[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=brand.dev-mcp&config=eyJuYW1lIjoiYnJhbmQuZGV2LW1jcCIsInRyYW5zcG9ydCI6InNzZSIsInVybCI6Imh0dHBzOi8vYnJhbmQtZGV2LnN0bG1jcC5jb20vc3NlIn0) -[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22brand.dev-mcp%22%2C%22type%22%3A%22sse%22%2C%22url%22%3A%22https%3A%2F%2Fbrand-dev.stlmcp.com%2Fsse%22%7D) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=brand.dev-mcp&config=eyJuYW1lIjoiYnJhbmQuZGV2LW1jcCIsInRyYW5zcG9ydCI6Imh0dHAiLCJ1cmwiOiJodHRwczovL2JyYW5kLWRldi5zdGxtY3AuY29tIiwiaGVhZGVycyI6eyJ4LWJyYW5kLWRldi1hcGkta2V5IjoiTXkgQVBJIEtleSJ9fQ) +[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22brand.dev-mcp%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fbrand-dev.stlmcp.com%22%2C%22headers%22%3A%7B%22x-brand-dev-api-key%22%3A%22My%20API%20Key%22%7D%7D) > Note: You may need to set environment variables in your MCP client. From 6b40c276aaae41e59b50ca04d5db0d1a47ae0b7d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:18:38 +0000 Subject: [PATCH 176/176] feat(client): add custom JSON encoder for extended type support --- src/brand/dev/_base_client.py | 7 +- src/brand/dev/_compat.py | 6 +- src/brand/dev/_utils/_json.py | 35 ++++++++++ tests/test_utils/test_json.py | 126 ++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 src/brand/dev/_utils/_json.py create mode 100644 tests/test_utils/test_json.py diff --git a/src/brand/dev/_base_client.py b/src/brand/dev/_base_client.py index d85b510..dc4af6b 100644 --- a/src/brand/dev/_base_client.py +++ b/src/brand/dev/_base_client.py @@ -86,6 +86,7 @@ APIConnectionError, APIResponseValidationError, ) +from ._utils._json import openapi_dumps log: logging.Logger = logging.getLogger(__name__) @@ -554,8 +555,10 @@ def _build_request( kwargs["content"] = options.content elif isinstance(json_data, bytes): kwargs["content"] = json_data - else: - kwargs["json"] = json_data if is_given(json_data) else None + elif not files: + # Don't set content when JSON is sent as multipart/form-data, + # since httpx's content param overrides other body arguments + kwargs["content"] = openapi_dumps(json_data) if is_given(json_data) and json_data is not None else None kwargs["files"] = files else: headers.pop("Content-Type", None) diff --git a/src/brand/dev/_compat.py b/src/brand/dev/_compat.py index bdef67f..786ff42 100644 --- a/src/brand/dev/_compat.py +++ b/src/brand/dev/_compat.py @@ -139,6 +139,7 @@ def model_dump( exclude_defaults: bool = False, warnings: bool = True, mode: Literal["json", "python"] = "python", + by_alias: bool | None = None, ) -> dict[str, Any]: if (not PYDANTIC_V1) or hasattr(model, "model_dump"): return model.model_dump( @@ -148,13 +149,12 @@ def model_dump( exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 warnings=True if PYDANTIC_V1 else warnings, + by_alias=by_alias, ) return cast( "dict[str, Any]", model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - exclude=exclude, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, + exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, by_alias=bool(by_alias) ), ) diff --git a/src/brand/dev/_utils/_json.py b/src/brand/dev/_utils/_json.py new file mode 100644 index 0000000..6058421 --- /dev/null +++ b/src/brand/dev/_utils/_json.py @@ -0,0 +1,35 @@ +import json +from typing import Any +from datetime import datetime +from typing_extensions import override + +import pydantic + +from .._compat import model_dump + + +def openapi_dumps(obj: Any) -> bytes: + """ + Serialize an object to UTF-8 encoded JSON bytes. + + Extends the standard json.dumps with support for additional types + commonly used in the SDK, such as `datetime`, `pydantic.BaseModel`, etc. + """ + return json.dumps( + obj, + cls=_CustomEncoder, + # Uses the same defaults as httpx's JSON serialization + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +class _CustomEncoder(json.JSONEncoder): + @override + def default(self, o: Any) -> Any: + if isinstance(o, datetime): + return o.isoformat() + if isinstance(o, pydantic.BaseModel): + return model_dump(o, exclude_unset=True, mode="json", by_alias=True) + return super().default(o) diff --git a/tests/test_utils/test_json.py b/tests/test_utils/test_json.py new file mode 100644 index 0000000..feb1785 --- /dev/null +++ b/tests/test_utils/test_json.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import datetime +from typing import Union + +import pydantic + +from brand.dev import _compat +from brand.dev._utils._json import openapi_dumps + + +class TestOpenapiDumps: + def test_basic(self) -> None: + data = {"key": "value", "number": 42} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"key":"value","number":42}' + + def test_datetime_serialization(self) -> None: + dt = datetime.datetime(2023, 1, 1, 12, 0, 0) + data = {"datetime": dt} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"datetime":"2023-01-01T12:00:00"}' + + def test_pydantic_model_serialization(self) -> None: + class User(pydantic.BaseModel): + first_name: str + last_name: str + age: int + + model_instance = User(first_name="John", last_name="Kramer", age=83) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"first_name":"John","last_name":"Kramer","age":83}}' + + def test_pydantic_model_with_default_values(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + score: int = 0 + + model_instance = User(name="Alice") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Alice"}}' + + def test_pydantic_model_with_default_values_overridden(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + + model_instance = User(name="Bob", role="admin", active=False) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Bob","role":"admin","active":false}}' + + def test_pydantic_model_with_alias(self) -> None: + class User(pydantic.BaseModel): + first_name: str = pydantic.Field(alias="firstName") + last_name: str = pydantic.Field(alias="lastName") + + model_instance = User(firstName="John", lastName="Doe") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"firstName":"John","lastName":"Doe"}}' + + def test_pydantic_model_with_alias_and_default(self) -> None: + class User(pydantic.BaseModel): + user_name: str = pydantic.Field(alias="userName") + user_role: str = pydantic.Field(default="member", alias="userRole") + is_active: bool = pydantic.Field(default=True, alias="isActive") + + model_instance = User(userName="charlie") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"charlie"}}' + + model_with_overrides = User(userName="diana", userRole="admin", isActive=False) + data = {"model": model_with_overrides} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"diana","userRole":"admin","isActive":false}}' + + def test_pydantic_model_with_nested_models_and_defaults(self) -> None: + class Address(pydantic.BaseModel): + street: str + city: str = "Unknown" + + class User(pydantic.BaseModel): + name: str + address: Address + verified: bool = False + + if _compat.PYDANTIC_V1: + # to handle forward references in Pydantic v1 + User.update_forward_refs(**locals()) # type: ignore[reportDeprecated] + + address = Address(street="123 Main St") + user = User(name="Diana", address=address) + data = {"user": user} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"user":{"name":"Diana","address":{"street":"123 Main St"}}}' + + address_with_city = Address(street="456 Oak Ave", city="Boston") + user_verified = User(name="Eve", address=address_with_city, verified=True) + data = {"user": user_verified} + json_bytes = openapi_dumps(data) + assert ( + json_bytes == b'{"user":{"name":"Eve","address":{"street":"456 Oak Ave","city":"Boston"},"verified":true}}' + ) + + def test_pydantic_model_with_optional_fields(self) -> None: + class User(pydantic.BaseModel): + name: str + email: Union[str, None] + phone: Union[str, None] + + model_with_none = User(name="Eve", email=None, phone=None) + data = {"model": model_with_none} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Eve","email":null,"phone":null}}' + + model_with_values = User(name="Frank", email="frank@example.com", phone=None) + data = {"model": model_with_values} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Frank","email":"frank@example.com","phone":null}}'