├── .github ├── release.yml └── workflows │ └── ci.yaml ├── .gitignore ├── CHANGELOG.md ├── CODEOWNERS ├── Dockerfile ├── LICENSE ├── README.rst ├── bin ├── install_env.sh ├── lint.sh ├── publish.sh └── test.sh ├── docker-compose.yaml ├── poetry.lock ├── pyproject.toml ├── renovate.json ├── rtbhouse_sdk ├── __init__.py ├── _utils.py ├── client.py ├── exceptions.py ├── py.typed └── schema.py ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── test_async_client.py ├── test_auth.py ├── test_client.py ├── test_schema.py └── test_utils.py /.github/release.yml: -------------------------------------------------------------------------------- 1 | # Schema: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes#configuration-options 2 | changelog: 3 | categories: 4 | - title: Dependency update 5 | labels: 6 | - "dependencies" 7 | 8 | - title: Changes 9 | labels: 10 | - "*" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request_target: 6 | types: [labeled] 7 | 8 | env: 9 | POETRY_VERSION: "1.8.5" 10 | 11 | jobs: 12 | ci: 13 | name: CI ${{ matrix.os }} / Python ${{ matrix.python-version }} 14 | runs-on: ${{ matrix.os }} 15 | if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'pr approved') 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [ubuntu-24.04] 20 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 21 | env: 22 | PYTHONDONTWRITEBYTECODE: 1 23 | 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | 28 | - name: Setup python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | 33 | - name: Setup poetry ${{ env.POETRY_VERSION }} 34 | run: | 35 | curl -sSl https://install.python-poetry.org | python - --version ${{ env.POETRY_VERSION }} 36 | ln -s ${POETRY_HOME}/bin/poetry /usr/local/bin/poetry 37 | echo "$HOME/.poetry/bin" >> $GITHUB_PATH 38 | 39 | - name: Set up poetry cache 40 | uses: actions/cache@v4 41 | with: 42 | path: /home/runner/.cache/pypoetry/virtualenvs 43 | key: venv-${{ matrix.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} 44 | restore-keys: venv-${{ matrix.os }}-${{ matrix.python-version }}- 45 | 46 | - name: Install dependencies 47 | run: | 48 | poetry install --sync --no-root 49 | 50 | - name: Lint 51 | run: | 52 | ./bin/lint.sh 53 | 54 | - name: Test 55 | run: | 56 | ./bin/test.sh 57 | 58 | release-package: 59 | name: "Release package" 60 | runs-on: "ubuntu-24.04" 61 | needs: ["ci"] 62 | env: 63 | PYTHON_VERSION: "3.11" 64 | if: contains('refs/heads/master refs/heads/main', github.ref) 65 | 66 | permissions: 67 | contents: "write" 68 | id-token: "write" 69 | 70 | steps: 71 | - name: Checkout 72 | uses: actions/checkout@v4 73 | with: 74 | fetch-depth: 0 75 | 76 | - name: "Setup python ${{ env.PYTHON_VERSION }}" 77 | uses: "actions/setup-python@v5" 78 | with: 79 | python-version: "${{ env.PYTHON_VERSION }}" 80 | cache: "pip" 81 | 82 | - name: "Setup poetry ${{ env.POETRY_VERSION }}" 83 | run: | 84 | curl -sSL https://install.python-poetry.org | POETRY_HOME="$HOME/.poetry" python - --version ${{ env.POETRY_VERSION }} --force 85 | echo "$HOME/.poetry/bin" >> $GITHUB_PATH 86 | 87 | - name: Should publish package 88 | id: should-publish-package 89 | run: | 90 | npm install -g semver 91 | 92 | ALL_GIT_TAGS=$(git tag) 93 | ALL_RELEASES=$(semver ${ALL_GIT_TAGS}) 94 | LAST_RELEASE=$(semver ${ALL_GIT_TAGS} | tail -n1) 95 | 96 | # Current version set in pyproject.toml file 97 | CURRENT_VERSION=$(semver $(poetry version -s)) 98 | 99 | if [[ $ALL_RELEASES == *"${CURRENT_VERSION}"* ]]; then 100 | echo "::warning::Package version in pyproject.toml not bumped, will not publish new package" 101 | echo "publish=0" >> $GITHUB_OUTPUT 102 | else 103 | echo "::info::Package will be released. Last release version: ${LAST_RELEASE}, current version: ${CURRENT_VERSION}" 104 | echo "publish=1" >> $GITHUB_OUTPUT 105 | fi 106 | 107 | - name: Get version 108 | id: get-version 109 | run: | 110 | echo "version=$(poetry version -s)" >> $GITHUB_OUTPUT 111 | if: steps.should-publish-package.outputs.publish == 1 112 | 113 | - name: "Build final package" 114 | run: | 115 | poetry build 116 | if: steps.should-publish-package.outputs.publish == 1 117 | 118 | - name: "Publish package to PyPI" 119 | uses: pypa/gh-action-pypi-publish@release/v1 120 | if: steps.should-publish-package.outputs.publish == 1 121 | 122 | - name: "Create release" 123 | uses: softprops/action-gh-release@v2 124 | with: 125 | tag_name: v${{ steps.get-version.outputs.version }} 126 | generate_release_notes: true 127 | if: steps.should-publish-package.outputs.publish == 1 128 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | __pycache__/ 4 | *.py[cod] 5 | *.egg-info/ 6 | *.egg 7 | pip-log.txt 8 | virtualenv 9 | venv 10 | config.py 11 | /dist 12 | /results 13 | .pytest_cache 14 | .mypy_cache 15 | .mypy.xml 16 | .coverage -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v14.0.0 2 | - [breaking change] dropped support for python 3.8 (which is end-of-life), please use python 3.9+ 3 | - added support for python 3.12 and 3.13 4 | 5 | # v13.0.0 6 | - [breaking change] current `viewability` metric has been renamed to `ssp_viewability`. We are leaving compatibility layer in the api until 2024-12-31. 7 | - added audio related metrics: `audio_complete_listens`, `ecpl`, `acr`. 8 | - added viewability related metrics: `viewability_measurability`, `viewability_viewability`, `evcpm`. 9 | - added visits related metrics: `visits_count`, `cpvisit`. 10 | 11 | # v12.0.0 12 | - [breaking change] Stats (`schema.Stats`, returned from `get_rtb_stats` and `get_summary_stats`) metrics: `imps_count`, `clicks_count`, `conversions_count`, `video_complete_views` are now represented as float type, to reflect actual api responses. Fractional metrics may appear in certain scenarios, eg. for custom grouping and/or as a result of manual adjustment. 13 | 14 | # v11.1.0 15 | - Added `utc_offset_hours` parameter for `get_rtb_stats` and `get_summary_stats` 16 | 17 | # v11.0.0 18 | - Added support for Pydantic v2 19 | - [breaking change] `cookie_hash`, `last_click_time` and `last_impression_time` fields in `Conversion` schema are now nullable 20 | - [breaking change] fixed naming convention compliance for fields in schemas: `rateCardId` (`UserInfo`) is now `rate_card_id` and `customProperties` (`Offer`) is now `custom_properties` 21 | 22 | # v10.1.0 23 | - Removed [inflection](https://pypi.org/project/inflection/) from the project dependencies 24 | - Added `camelize` and `underscore` helper functions 25 | 26 | # v10.0.0 27 | - Dropped support for python 3.7 (which is reaching end-of-life), please use python 3.8.1+, v9 branch with python 3.7 compatibility will be updated until 2023-06-27 28 | - Added support for python 3.11 29 | - Unfrozen httpx, httpcore has been fixed 30 | 31 | # v9.0.1 32 | - Freeze httpx in version 0.23.0, as 0.23.1 uses bugged httpcore 0.16.x (see https://github.com/encode/httpcore/issues/621) 33 | 34 | # v9.0.0 35 | This version introduces breaking changes. Please see the migration guide below on how to migrate from older versions. 36 | 37 | ## Changes 38 | - Drop support for Python 2 39 | - Split `reports_api` module into `client`, `exceptions`, `schema` modules 40 | - Rename `ReportsApiSession` to `Client` and change its constructor arguments 41 | - Remove `Reports` prefix from exception names 42 | - Return objects with well-defined fields instead of dicts 43 | - Use proper enums for parameters instead of string values 44 | - Add type annotations 45 | - Add token authentication method 46 | - Add asynchronous client 47 | 48 | ## Migration 49 | 50 | ### Python compatibility 51 | Python 2 is no longer supported, please use Python 3.7+. 52 | 53 | ### Authentication 54 | For example, previous code creating API client instance: 55 | ```python 56 | from rtbhouse_sdk.reports_api import ReportsApiSession 57 | api = ReportsApiSession(username="myuser", password="mypassword") 58 | ``` 59 | 60 | Now should look like this: 61 | ```python 62 | from rtbhouse_sdk.client import BasicAuth, Client 63 | api = Client(auth=BasicAuth(username="myuser", password="mypassword")) 64 | ``` 65 | 66 | Additionally, it is now possible to authenticate with a token: 67 | ```python 68 | from rtbhouse_sdk.client import BasicTokenAuth, Client 69 | api = Client(auth=BasicTokenAuth(token="mytoken")) 70 | ``` 71 | 72 | ### Clients 73 | Now SDK offers both synchronous and asynchronous clients to work with. They have the same set of endpoints. 74 | 75 | It is recommended to close the session using `close()` method. For convenience there is also a context manager that takes care of that. 76 | 77 | Usage example with sync client: 78 | ```python 79 | from rtbhouse_sdk.client import BasicTokenAuth, Client 80 | 81 | auth = BasicTokenAuth(token='mytoken') 82 | 83 | # using close 84 | api = Client(auth=auth) 85 | info = api.get_user_info() 86 | api.close() 87 | 88 | # or using context manager 89 | with Client(auth=auth) as api: 90 | info = api.get_user_info() 91 | ``` 92 | 93 | Usage example with async client: 94 | ```python 95 | from rtbhouse_sdk.client import BasicTokenAuth, AsyncClient 96 | 97 | auth = BasicTokenAuth(token='mytoken') 98 | 99 | # using close 100 | api = AsyncClient(auth=auth) 101 | info = await api.get_user_info() 102 | await api.close() 103 | 104 | # or using context manager 105 | async with AsyncClient(auth=auth) as api: 106 | info = await api.get_user_info() 107 | ``` 108 | 109 | ### Result data 110 | Each endpoint method returns data in form of [Pydantic model](https://pydantic-docs.helpmanual.io/usage/models/). 111 | 112 | If you wish to access the data as a dict, you can call `dict()` method on the resulting object. 113 | 114 | For example, previous code fetching user info: 115 | ```python 116 | info = api.get_user_info() 117 | print(info['isClientUser']) 118 | ``` 119 | 120 | Now should look like: 121 | ```python 122 | # recommended way 123 | info = api.get_user_info() 124 | print(info.is_client_user) 125 | 126 | # alternative way using dict with camelCase keys 127 | # (same as in the code for previous SDK version) 128 | info = api.get_user_info().dict(by_alias=True) 129 | print(info['isClientUser']) 130 | 131 | # alternative way using dict with snake_case keys 132 | info = api.get_user_info().dict() 133 | print(info['is_client_user']) 134 | ``` 135 | 136 | ### Query params 137 | Instead of plain strings there are now enums which should be used for query filters. 138 | Moreover, `date` objects should be used for `day_from` and `day_to` parameters. 139 | 140 | For example, previous code fetching stats: 141 | ```python 142 | from rtbhouse_sdk.reports_api import ReportsApiSession 143 | 144 | api = ReportsApiSession(username="myuser", password="mypassword") 145 | results = api.get_rtb_stats( 146 | adv_hash="myadvertiser", 147 | day_from="2022-06-01", 148 | day_to="2022-06-30", 149 | group_by=["subcampaign", "userSegment"], 150 | metrics=["clicksCount"] 151 | ) 152 | for row in results: 153 | print(row["subcampaign"] + " - " + row["userSegment"] + ": " + str(row["clicksCount"])) 154 | ``` 155 | 156 | Now should look like this: 157 | ```python 158 | from datetime import date 159 | from rtbhouse_sdk.client import Client, BasicAuth 160 | from rtbhouse_sdk.schema import StatsGroupBy, StatsMetric 161 | 162 | api = Client(auth=BasicAuth(username="myuser", password="mypassword")) 163 | results = api.get_rtb_stats( 164 | adv_hash="myadvertiser", 165 | day_from=date(2022, 6, 1), 166 | day_to=date(2022, 6, 30), 167 | group_by=[StatsGroupBy.SUBCAMPAIGN, StatsGroupBy.USER_SEGMENT], 168 | metrics=[StatsMetric.CLICKS_COUNT] 169 | ) 170 | for row in results: 171 | print(row.subcampaign + " - " + row.user_segment + ": " + str(row.clicks_count)) 172 | api.close() 173 | ``` 174 | 175 | ### Other changes 176 | - `get_rtb_conversions` is now a generator function (previously it returned list) 177 | 178 | 179 | # v8.1.0 180 | Dependencies bump 181 | Support for Python 3.10 182 | Drop support for python 3.5 and 3.6 183 | 184 | # v8.0.0 185 | Remove `get_dpa_accounts`, `get_dpa_stats`, `get_dpa_conversions` functions 186 | 187 | # v7.1.0 188 | Update build tooling, added poetry 189 | Apply lint fixes 190 | Drop support for Python 3.3 and Python 3.4 191 | 192 | 193 | # v7.0.0 194 | Remove `get_dpa_creatives` function 195 | 196 | 197 | # v6.0.2 198 | Update usage example in README.rst 199 | 200 | 201 | # v6.0.1 202 | Restored support for python 2.7 203 | 204 | 205 | # v6.0.0 206 | This version adapts to latest api v5 changes. 207 | See API docs: https://panel.rtbhouse.com/api/docs for details. 208 | 209 | For now, three methods - `get_rtb_stats` (for RTB only), `get_dpa_stats` (for DPA only) and `get_summary_stats` (for RTB + DPA) shares similar parameters and output: 210 | ``` 211 | get_(rtb|dpa|summary)_stats( 212 | adv_hash, # Advertiser hash. No changes. 213 | day_from, # Date range start (inclusive). No changes for RTB. For DPA this parameter is now obligatory (was not in the past). 214 | day_to, # Date range end (inclusive). No changes for RTB. For DPA this parameter is now obligatory (was not in the past). 215 | group_by, # Iterable (eg. list, set) of grouping columns. Refer to api docs for list of possible values. No changes for RTB. For DPA this now accepts list instead of single value. 216 | metrics, # Iterable (eg. list, set) of value columns. Refer to api docs for list of possible values. This parameter was newly added. 217 | count_convention, # (Optional) Conversions counting convention. Changes: Defaults to None; This parameter must only be set if at least one conversions related metric is selected. 218 | subcampaigns, # (Optional) Subcampaigns filter. No changes. 219 | user_segments, # (Optional, RTB only) User segments filter. No changes. 220 | device_types, # (Optional, RTB only) Device types filter. No changes. 221 | placement, # (Optional, DPA only). Placement filter. No changes. 222 | ) -> [{ 223 | "grouping field 1 name": "grouping field 1 value 1", # No changes 224 | "grouping field N name": "grouping field N value 1", # No changes 225 | "grouping field X details": "grouping field X details values", # No changes 226 | "metric 1 name": "metric field 1 value", # Changes: now only metrics requested by `metrics` parameter are returned 227 | }] 228 | ``` 229 | 230 | `get_dpa_campaign_stats` was removed, use `get_dpa_stats` instead. 231 | 232 | `include_dpa` in `get_rtb_stats` is no longer supported, use `get_summary_stats` instead. 233 | 234 | A few new metrics were added, refer to docs (as above) for details. 235 | 236 | A few metrics changed their names. `ecc` was renamed to `ecpa`, `cpc` was renamed to `ecpc`. 237 | 238 | `count_convention` parameter is now not needed if no conversions related metrics are requested. 239 | 240 | 241 | # v5.0.0 242 | This version adapts to latest api v4 changes. 243 | 244 | `get_rtb_creatives` now provides faster results with different output: 245 | Refer to `https://panel.rtbhouse.com/api/docs` - `GET /advertisers/{hash}/rtb-creatives` for details 246 | 247 | 248 | # v4.1.0 249 | Add python 2.7 support 250 | 251 | 252 | # v4.0.0 253 | This version adapts to latest api v3. changes. 254 | 255 | Multiple stats loading functions: `get_campaign_stats_total`, `get_rtb_campaign_stats`, `get_rtb_category_stats`, `get_rtb_creative_stats`, `get_rtb_device_stats`, `get_rtb_country_stats`, `get_rtb_creative_country_stats` are now replaced with single `get_rtb_stats` method, see below. 256 | - `campaign` in `group_by` is renamed to `subcampaign`. 257 | - `categoryId` grouping is renamed to `category`. In output `categoryId` is removed, `category` now contains category identifier (previously name) and new field `categoryName` is added. 258 | - `creativeId` grouping is renamed to `creative`. In output `hash` is renamed to `creative`. All creative details are prefixed with `creative` (`creativeName`, `creativeWidth`, `creativeCreatedAt`). 259 | - `conversionsRate` in output is renamed to `cr`. 260 | - Indeterminate values (ex. ctr when there are no imps and clicks) are now `null` / `None`, previously `0`. 261 | 262 | For example: 263 | - `get_rtb_campaign_stats` equals to `get_rtb_stats`, with default `group_by` set to `{'day'}`. 264 | - `get_campaign_stats_total` equals to `get_rtb_stats`, with default `group_by` set to `{'day'}` and `includeDpa` set to `True`. 265 | - `get_rtb_category_stats` equals to `get_rtb_stats` with `group_by` set to `{'category'}`. 266 | - `get_rtb_creative_stats` equals to `get_rtb_stats` with `group_by` set to `{'creative'}`. 267 | - `get_rtb_device_stats` equals to `get_rtb_stats` with `group_by` set to `{'deviceType'}`. 268 | - `get_rtb_country_stats` equals to `get_rtb_stats` with `group_by` set to `{'country'}`. 269 | - `get_rtb_creative_country_stats` equals to `get_rtb_stats` with `group_by` set to `{'creative', 'country'}`. 270 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @rtbhouse-apps/backend-developers 2 | /.github/ @rtbhouse-apps/devops 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.6-slim-buster 2 | 3 | ARG UNAME=apps 4 | ARG UID=1000 5 | ARG GID=1000 6 | ENV POETRY_HOME=/opt/poetry 7 | ENV WORKDIR=/home/$UNAME/code 8 | ENV PATH=$PATH:/home/$UNAME/.local/bin/ 9 | 10 | RUN apt-get update \ 11 | && apt-get install -y --no-install-recommends curl \ 12 | && rm -fr /var/lib/apt/lists/* 13 | 14 | RUN python -m pip install --upgrade --no-cache-dir pip==23.3.1 15 | 16 | # Install Poetry 17 | RUN curl -sSl https://install.python-poetry.org | python - --version 1.7.1 \ 18 | && ln -s ${POETRY_HOME}/bin/poetry /usr/local/bin/poetry 19 | 20 | RUN groupadd -g $GID $UNAME \ 21 | && useradd -m -u $UID -g $GID -s /bin/bash $UNAME \ 22 | && mkdir -p $WORKDIR \ 23 | && chown $UNAME:$UNAME $WORKDIR 24 | 25 | USER $UNAME 26 | WORKDIR $WORKDIR 27 | 28 | COPY --chown=apps ./ $WORKDIR 29 | RUN poetry install --no-ansi --no-interaction --no-root 30 | CMD ["poetry", "run", "python", "-m", "pytest","--color=no", \ 31 | "--cov-report=term-missing", "--cov=rtbhouse_sdk", "tests/"] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 RTB House S.A. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | RTB House SDK 2 | ============= 3 | 4 | Overview 5 | -------- 6 | 7 | This library provides an easy-to-use Python interface to RTB House API. It allows you to read and manage you campaigns settings, browse offers, download statistics etc. 8 | 9 | API docs: https://api.panel.rtbhouse.com/api/docs 10 | 11 | Installation 12 | ------------ 13 | 14 | RTB House SDK can be installed with `pip `_: :: 15 | 16 | $ pip install rtbhouse_sdk 17 | 18 | 19 | Usage example 20 | ------------- 21 | 22 | Let's write a script which fetches campaign stats (imps, clicks, postclicks) and shows the result as a table (using ``tabulate`` library). 23 | 24 | First, create ``config.py`` file with your credentials: :: 25 | 26 | USERNAME = 'jdoe' 27 | PASSWORD = 'abcd1234' 28 | 29 | 30 | Set up virtualenv and install requirements: :: 31 | 32 | $ pip install rtbhouse_sdk tabulate 33 | 34 | 35 | .. code-block:: python 36 | 37 | from datetime import date, timedelta 38 | from operator import attrgetter 39 | 40 | from rtbhouse_sdk.client import BasicAuth, Client 41 | from rtbhouse_sdk.schema import CountConvention, StatsGroupBy, StatsMetric 42 | from tabulate import tabulate 43 | 44 | from config import PASSWORD, USERNAME 45 | 46 | if __name__ == "__main__": 47 | with Client(auth=BasicAuth(USERNAME, PASSWORD)) as api: 48 | advertisers = api.get_advertisers() 49 | day_to = date.today() 50 | day_from = day_to - timedelta(days=30) 51 | group_by = [StatsGroupBy.DAY] 52 | metrics = [ 53 | StatsMetric.IMPS_COUNT, 54 | StatsMetric.CLICKS_COUNT, 55 | StatsMetric.CAMPAIGN_COST, 56 | StatsMetric.CONVERSIONS_COUNT, 57 | StatsMetric.CTR 58 | ] 59 | stats = api.get_rtb_stats( 60 | advertisers[0].hash, 61 | day_from, 62 | day_to, 63 | group_by, 64 | metrics, 65 | count_convention=CountConvention.ATTRIBUTED_POST_CLICK, 66 | ) 67 | columns = group_by + metrics 68 | data_frame = [ 69 | [getattr(row, c.name.lower()) for c in columns] 70 | for row in reversed(sorted(stats, key=attrgetter("day"))) 71 | ] 72 | print(tabulate(data_frame, headers=columns)) 73 | 74 | 75 | License 76 | ------- 77 | 78 | `MIT `_ 79 | -------------------------------------------------------------------------------- /bin/install_env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -o pipefail 5 | 6 | cd "`dirname $0`/.." 7 | 8 | rm -rf ./.venv 9 | 10 | poetry install 11 | -------------------------------------------------------------------------------- /bin/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -o pipefail 5 | 6 | cd "`dirname $0`/.." 7 | 8 | exit_code=0 9 | 10 | if poetry show | cut -f 1 -d ' ' | grep \^black\$ > /dev/null; then 11 | echo -e "\nRunning black..." 12 | poetry run black --check . || exit_code=1 13 | fi 14 | 15 | if poetry show | cut -f 1 -d ' ' | grep \^isort\$ > /dev/null; then 16 | echo -e "\nRunning isort..." 17 | poetry run isort -c -q . || exit_code=1 18 | fi 19 | 20 | if poetry show | cut -f 1 -d ' ' | grep \^flake8\$ > /dev/null; then 21 | echo -e "\nRunning flake8..." 22 | poetry run flake8 || exit_code=1 23 | fi 24 | 25 | if poetry show | cut -f 1 -d ' ' | grep \^mypy\$ > /dev/null; then 26 | echo -e "\nRunning mypy..." 27 | poetry run mypy . || exit_code=1 28 | fi 29 | 30 | if poetry show | cut -f 1 -d ' ' | grep \^pylint\$ > /dev/null; then 31 | echo -e "\nRunning pylint..." 32 | poetry run pylint rtbhouse_sdk tests || exit_code=1 33 | fi 34 | 35 | exit $exit_code 36 | -------------------------------------------------------------------------------- /bin/publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -o pipefail 5 | 6 | cd "`dirname $0`/.." 7 | 8 | if [[ -z $PYPI_TOKEN ]]; then 9 | echo -e "Error: please provide PYPI_TOKEN env var" 10 | exit 1 11 | fi 12 | 13 | poetry publish --build --no-interaction -u __token__ -p $PYPI_TOKEN 14 | -------------------------------------------------------------------------------- /bin/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -o pipefail 5 | 6 | cd "`dirname $0`/.." 7 | 8 | 9 | poetry run python -m pytest --color=no --cov-report=term-missing --cov=rtbhouse_sdk/ tests/ 10 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | version: "3.2" 4 | 5 | services: 6 | tests: 7 | build: 8 | context: . 9 | args: 10 | UID: "${UID}" 11 | GID: "${UID}" 12 | container_name: rtbhouse-python-sdk 13 | volumes: 14 | - ./:/home/apps/code -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "annotated-types" 5 | version = "0.7.0" 6 | description = "Reusable constraint types to use with typing.Annotated" 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, 11 | {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, 12 | ] 13 | 14 | [[package]] 15 | name = "anyio" 16 | version = "4.9.0" 17 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 18 | optional = false 19 | python-versions = ">=3.9" 20 | files = [ 21 | {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, 22 | {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, 23 | ] 24 | 25 | [package.dependencies] 26 | exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} 27 | idna = ">=2.8" 28 | sniffio = ">=1.1" 29 | typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} 30 | 31 | [package.extras] 32 | doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] 33 | test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] 34 | trio = ["trio (>=0.26.1)"] 35 | 36 | [[package]] 37 | name = "astroid" 38 | version = "3.3.9" 39 | description = "An abstract syntax tree for Python with inference support." 40 | optional = false 41 | python-versions = ">=3.9.0" 42 | files = [ 43 | {file = "astroid-3.3.9-py3-none-any.whl", hash = "sha256:d05bfd0acba96a7bd43e222828b7d9bc1e138aaeb0649707908d3702a9831248"}, 44 | {file = "astroid-3.3.9.tar.gz", hash = "sha256:622cc8e3048684aa42c820d9d218978021c3c3d174fb03a9f0d615921744f550"}, 45 | ] 46 | 47 | [package.dependencies] 48 | typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} 49 | 50 | [[package]] 51 | name = "black" 52 | version = "25.1.0" 53 | description = "The uncompromising code formatter." 54 | optional = false 55 | python-versions = ">=3.9" 56 | files = [ 57 | {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, 58 | {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, 59 | {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, 60 | {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, 61 | {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, 62 | {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, 63 | {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, 64 | {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, 65 | {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, 66 | {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, 67 | {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, 68 | {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, 69 | {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, 70 | {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, 71 | {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, 72 | {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, 73 | {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, 74 | {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, 75 | {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, 76 | {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, 77 | {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, 78 | {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, 79 | ] 80 | 81 | [package.dependencies] 82 | click = ">=8.0.0" 83 | mypy-extensions = ">=0.4.3" 84 | packaging = ">=22.0" 85 | pathspec = ">=0.9.0" 86 | platformdirs = ">=2" 87 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 88 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 89 | 90 | [package.extras] 91 | colorama = ["colorama (>=0.4.3)"] 92 | d = ["aiohttp (>=3.10)"] 93 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 94 | uvloop = ["uvloop (>=0.15.2)"] 95 | 96 | [[package]] 97 | name = "certifi" 98 | version = "2025.4.26" 99 | description = "Python package for providing Mozilla's CA Bundle." 100 | optional = false 101 | python-versions = ">=3.6" 102 | files = [ 103 | {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, 104 | {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, 105 | ] 106 | 107 | [[package]] 108 | name = "click" 109 | version = "8.1.8" 110 | description = "Composable command line interface toolkit" 111 | optional = false 112 | python-versions = ">=3.7" 113 | files = [ 114 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 115 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 116 | ] 117 | 118 | [package.dependencies] 119 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 120 | 121 | [[package]] 122 | name = "colorama" 123 | version = "0.4.6" 124 | description = "Cross-platform colored terminal text." 125 | optional = false 126 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 127 | files = [ 128 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 129 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 130 | ] 131 | 132 | [[package]] 133 | name = "coverage" 134 | version = "7.8.0" 135 | description = "Code coverage measurement for Python" 136 | optional = false 137 | python-versions = ">=3.9" 138 | files = [ 139 | {file = "coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe"}, 140 | {file = "coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28"}, 141 | {file = "coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3"}, 142 | {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676"}, 143 | {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d"}, 144 | {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a"}, 145 | {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c"}, 146 | {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f"}, 147 | {file = "coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f"}, 148 | {file = "coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23"}, 149 | {file = "coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27"}, 150 | {file = "coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea"}, 151 | {file = "coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7"}, 152 | {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040"}, 153 | {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543"}, 154 | {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2"}, 155 | {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318"}, 156 | {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9"}, 157 | {file = "coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c"}, 158 | {file = "coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78"}, 159 | {file = "coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc"}, 160 | {file = "coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6"}, 161 | {file = "coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d"}, 162 | {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05"}, 163 | {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a"}, 164 | {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6"}, 165 | {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47"}, 166 | {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe"}, 167 | {file = "coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545"}, 168 | {file = "coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b"}, 169 | {file = "coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd"}, 170 | {file = "coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00"}, 171 | {file = "coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64"}, 172 | {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067"}, 173 | {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008"}, 174 | {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733"}, 175 | {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323"}, 176 | {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3"}, 177 | {file = "coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d"}, 178 | {file = "coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487"}, 179 | {file = "coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25"}, 180 | {file = "coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42"}, 181 | {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502"}, 182 | {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1"}, 183 | {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4"}, 184 | {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73"}, 185 | {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a"}, 186 | {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883"}, 187 | {file = "coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada"}, 188 | {file = "coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257"}, 189 | {file = "coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f"}, 190 | {file = "coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a"}, 191 | {file = "coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82"}, 192 | {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814"}, 193 | {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c"}, 194 | {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd"}, 195 | {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4"}, 196 | {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899"}, 197 | {file = "coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f"}, 198 | {file = "coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3"}, 199 | {file = "coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd"}, 200 | {file = "coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7"}, 201 | {file = "coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501"}, 202 | ] 203 | 204 | [package.dependencies] 205 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 206 | 207 | [package.extras] 208 | toml = ["tomli"] 209 | 210 | [[package]] 211 | name = "dill" 212 | version = "0.4.0" 213 | description = "serialize all of Python" 214 | optional = false 215 | python-versions = ">=3.8" 216 | files = [ 217 | {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, 218 | {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, 219 | ] 220 | 221 | [package.extras] 222 | graph = ["objgraph (>=1.7.2)"] 223 | profile = ["gprof2dot (>=2022.7.29)"] 224 | 225 | [[package]] 226 | name = "exceptiongroup" 227 | version = "1.2.2" 228 | description = "Backport of PEP 654 (exception groups)" 229 | optional = false 230 | python-versions = ">=3.7" 231 | files = [ 232 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 233 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 234 | ] 235 | 236 | [package.extras] 237 | test = ["pytest (>=6)"] 238 | 239 | [[package]] 240 | name = "flake8" 241 | version = "7.2.0" 242 | description = "the modular source code checker: pep8 pyflakes and co" 243 | optional = false 244 | python-versions = ">=3.9" 245 | files = [ 246 | {file = "flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343"}, 247 | {file = "flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426"}, 248 | ] 249 | 250 | [package.dependencies] 251 | mccabe = ">=0.7.0,<0.8.0" 252 | pycodestyle = ">=2.13.0,<2.14.0" 253 | pyflakes = ">=3.3.0,<3.4.0" 254 | 255 | [[package]] 256 | name = "h11" 257 | version = "0.16.0" 258 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 259 | optional = false 260 | python-versions = ">=3.8" 261 | files = [ 262 | {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, 263 | {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, 264 | ] 265 | 266 | [[package]] 267 | name = "httpcore" 268 | version = "1.0.9" 269 | description = "A minimal low-level HTTP client." 270 | optional = false 271 | python-versions = ">=3.8" 272 | files = [ 273 | {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, 274 | {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, 275 | ] 276 | 277 | [package.dependencies] 278 | certifi = "*" 279 | h11 = ">=0.16" 280 | 281 | [package.extras] 282 | asyncio = ["anyio (>=4.0,<5.0)"] 283 | http2 = ["h2 (>=3,<5)"] 284 | socks = ["socksio (==1.*)"] 285 | trio = ["trio (>=0.22.0,<1.0)"] 286 | 287 | [[package]] 288 | name = "httpx" 289 | version = "0.28.1" 290 | description = "The next generation HTTP client." 291 | optional = false 292 | python-versions = ">=3.8" 293 | files = [ 294 | {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, 295 | {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, 296 | ] 297 | 298 | [package.dependencies] 299 | anyio = "*" 300 | certifi = "*" 301 | httpcore = "==1.*" 302 | idna = "*" 303 | 304 | [package.extras] 305 | brotli = ["brotli", "brotlicffi"] 306 | cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] 307 | http2 = ["h2 (>=3,<5)"] 308 | socks = ["socksio (==1.*)"] 309 | zstd = ["zstandard (>=0.18.0)"] 310 | 311 | [[package]] 312 | name = "idna" 313 | version = "3.10" 314 | description = "Internationalized Domain Names in Applications (IDNA)" 315 | optional = false 316 | python-versions = ">=3.6" 317 | files = [ 318 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, 319 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, 320 | ] 321 | 322 | [package.extras] 323 | all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] 324 | 325 | [[package]] 326 | name = "iniconfig" 327 | version = "2.1.0" 328 | description = "brain-dead simple config-ini parsing" 329 | optional = false 330 | python-versions = ">=3.8" 331 | files = [ 332 | {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, 333 | {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, 334 | ] 335 | 336 | [[package]] 337 | name = "isort" 338 | version = "6.0.1" 339 | description = "A Python utility / library to sort Python imports." 340 | optional = false 341 | python-versions = ">=3.9.0" 342 | files = [ 343 | {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, 344 | {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, 345 | ] 346 | 347 | [package.extras] 348 | colors = ["colorama"] 349 | plugins = ["setuptools"] 350 | 351 | [[package]] 352 | name = "mccabe" 353 | version = "0.7.0" 354 | description = "McCabe checker, plugin for flake8" 355 | optional = false 356 | python-versions = ">=3.6" 357 | files = [ 358 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 359 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 360 | ] 361 | 362 | [[package]] 363 | name = "mypy" 364 | version = "1.15.0" 365 | description = "Optional static typing for Python" 366 | optional = false 367 | python-versions = ">=3.9" 368 | files = [ 369 | {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, 370 | {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, 371 | {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, 372 | {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, 373 | {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, 374 | {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, 375 | {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, 376 | {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, 377 | {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, 378 | {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, 379 | {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, 380 | {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, 381 | {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, 382 | {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, 383 | {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, 384 | {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, 385 | {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, 386 | {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, 387 | {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, 388 | {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, 389 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, 390 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, 391 | {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, 392 | {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, 393 | {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, 394 | {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, 395 | {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, 396 | {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, 397 | {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, 398 | {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, 399 | {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, 400 | {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, 401 | ] 402 | 403 | [package.dependencies] 404 | mypy_extensions = ">=1.0.0" 405 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 406 | typing_extensions = ">=4.6.0" 407 | 408 | [package.extras] 409 | dmypy = ["psutil (>=4.0)"] 410 | faster-cache = ["orjson"] 411 | install-types = ["pip"] 412 | mypyc = ["setuptools (>=50)"] 413 | reports = ["lxml"] 414 | 415 | [[package]] 416 | name = "mypy-extensions" 417 | version = "1.1.0" 418 | description = "Type system extensions for programs checked with the mypy type checker." 419 | optional = false 420 | python-versions = ">=3.8" 421 | files = [ 422 | {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, 423 | {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, 424 | ] 425 | 426 | [[package]] 427 | name = "packaging" 428 | version = "25.0" 429 | description = "Core utilities for Python packages" 430 | optional = false 431 | python-versions = ">=3.8" 432 | files = [ 433 | {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, 434 | {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, 435 | ] 436 | 437 | [[package]] 438 | name = "pathspec" 439 | version = "0.12.1" 440 | description = "Utility library for gitignore style pattern matching of file paths." 441 | optional = false 442 | python-versions = ">=3.8" 443 | files = [ 444 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 445 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 446 | ] 447 | 448 | [[package]] 449 | name = "platformdirs" 450 | version = "4.3.8" 451 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 452 | optional = false 453 | python-versions = ">=3.9" 454 | files = [ 455 | {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, 456 | {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, 457 | ] 458 | 459 | [package.extras] 460 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] 461 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] 462 | type = ["mypy (>=1.14.1)"] 463 | 464 | [[package]] 465 | name = "pluggy" 466 | version = "1.5.0" 467 | description = "plugin and hook calling mechanisms for python" 468 | optional = false 469 | python-versions = ">=3.8" 470 | files = [ 471 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 472 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 473 | ] 474 | 475 | [package.extras] 476 | dev = ["pre-commit", "tox"] 477 | testing = ["pytest", "pytest-benchmark"] 478 | 479 | [[package]] 480 | name = "pycodestyle" 481 | version = "2.13.0" 482 | description = "Python style guide checker" 483 | optional = false 484 | python-versions = ">=3.9" 485 | files = [ 486 | {file = "pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9"}, 487 | {file = "pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae"}, 488 | ] 489 | 490 | [[package]] 491 | name = "pydantic" 492 | version = "2.11.4" 493 | description = "Data validation using Python type hints" 494 | optional = false 495 | python-versions = ">=3.9" 496 | files = [ 497 | {file = "pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb"}, 498 | {file = "pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d"}, 499 | ] 500 | 501 | [package.dependencies] 502 | annotated-types = ">=0.6.0" 503 | pydantic-core = "2.33.2" 504 | typing-extensions = ">=4.12.2" 505 | typing-inspection = ">=0.4.0" 506 | 507 | [package.extras] 508 | email = ["email-validator (>=2.0.0)"] 509 | timezone = ["tzdata"] 510 | 511 | [[package]] 512 | name = "pydantic-core" 513 | version = "2.33.2" 514 | description = "Core functionality for Pydantic validation and serialization" 515 | optional = false 516 | python-versions = ">=3.9" 517 | files = [ 518 | {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, 519 | {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, 520 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, 521 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, 522 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, 523 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, 524 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, 525 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, 526 | {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, 527 | {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, 528 | {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, 529 | {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, 530 | {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, 531 | {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, 532 | {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, 533 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, 534 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, 535 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, 536 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, 537 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, 538 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, 539 | {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, 540 | {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, 541 | {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, 542 | {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, 543 | {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, 544 | {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, 545 | {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, 546 | {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, 547 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, 548 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, 549 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, 550 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, 551 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, 552 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, 553 | {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, 554 | {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, 555 | {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, 556 | {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, 557 | {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, 558 | {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, 559 | {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, 560 | {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, 561 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, 562 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, 563 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, 564 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, 565 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, 566 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, 567 | {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, 568 | {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, 569 | {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, 570 | {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, 571 | {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, 572 | {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, 573 | {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, 574 | {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, 575 | {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, 576 | {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, 577 | {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, 578 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, 579 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, 580 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, 581 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, 582 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, 583 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, 584 | {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, 585 | {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, 586 | {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, 587 | {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, 588 | {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, 589 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, 590 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, 591 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, 592 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, 593 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, 594 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, 595 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, 596 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, 597 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, 598 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, 599 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, 600 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, 601 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, 602 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, 603 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, 604 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, 605 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, 606 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, 607 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, 608 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, 609 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, 610 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, 611 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, 612 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, 613 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, 614 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, 615 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, 616 | {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, 617 | ] 618 | 619 | [package.dependencies] 620 | typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" 621 | 622 | [[package]] 623 | name = "pyflakes" 624 | version = "3.3.2" 625 | description = "passive checker of Python programs" 626 | optional = false 627 | python-versions = ">=3.9" 628 | files = [ 629 | {file = "pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a"}, 630 | {file = "pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b"}, 631 | ] 632 | 633 | [[package]] 634 | name = "pylint" 635 | version = "3.3.7" 636 | description = "python code static checker" 637 | optional = false 638 | python-versions = ">=3.9.0" 639 | files = [ 640 | {file = "pylint-3.3.7-py3-none-any.whl", hash = "sha256:43860aafefce92fca4cf6b61fe199cdc5ae54ea28f9bf4cd49de267b5195803d"}, 641 | {file = "pylint-3.3.7.tar.gz", hash = "sha256:2b11de8bde49f9c5059452e0c310c079c746a0a8eeaa789e5aa966ecc23e4559"}, 642 | ] 643 | 644 | [package.dependencies] 645 | astroid = ">=3.3.8,<=3.4.0.dev0" 646 | colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} 647 | dill = [ 648 | {version = ">=0.2", markers = "python_version < \"3.11\""}, 649 | {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, 650 | {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, 651 | ] 652 | isort = ">=4.2.5,<5.13 || >5.13,<7" 653 | mccabe = ">=0.6,<0.8" 654 | platformdirs = ">=2.2" 655 | tomli = {version = ">=1.1", markers = "python_version < \"3.11\""} 656 | tomlkit = ">=0.10.1" 657 | typing-extensions = {version = ">=3.10", markers = "python_version < \"3.10\""} 658 | 659 | [package.extras] 660 | spelling = ["pyenchant (>=3.2,<4.0)"] 661 | testutils = ["gitpython (>3)"] 662 | 663 | [[package]] 664 | name = "pytest" 665 | version = "8.3.5" 666 | description = "pytest: simple powerful testing with Python" 667 | optional = false 668 | python-versions = ">=3.8" 669 | files = [ 670 | {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, 671 | {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, 672 | ] 673 | 674 | [package.dependencies] 675 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 676 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 677 | iniconfig = "*" 678 | packaging = "*" 679 | pluggy = ">=1.5,<2" 680 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 681 | 682 | [package.extras] 683 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 684 | 685 | [[package]] 686 | name = "pytest-asyncio" 687 | version = "0.26.0" 688 | description = "Pytest support for asyncio" 689 | optional = false 690 | python-versions = ">=3.9" 691 | files = [ 692 | {file = "pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0"}, 693 | {file = "pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f"}, 694 | ] 695 | 696 | [package.dependencies] 697 | pytest = ">=8.2,<9" 698 | typing-extensions = {version = ">=4.12", markers = "python_version < \"3.10\""} 699 | 700 | [package.extras] 701 | docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] 702 | testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] 703 | 704 | [[package]] 705 | name = "pytest-cov" 706 | version = "6.1.1" 707 | description = "Pytest plugin for measuring coverage." 708 | optional = false 709 | python-versions = ">=3.9" 710 | files = [ 711 | {file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"}, 712 | {file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"}, 713 | ] 714 | 715 | [package.dependencies] 716 | coverage = {version = ">=7.5", extras = ["toml"]} 717 | pytest = ">=4.6" 718 | 719 | [package.extras] 720 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] 721 | 722 | [[package]] 723 | name = "respx" 724 | version = "0.22.0" 725 | description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." 726 | optional = false 727 | python-versions = ">=3.8" 728 | files = [ 729 | {file = "respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0"}, 730 | {file = "respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91"}, 731 | ] 732 | 733 | [package.dependencies] 734 | httpx = ">=0.25.0" 735 | 736 | [[package]] 737 | name = "sniffio" 738 | version = "1.3.1" 739 | description = "Sniff out which async library your code is running under" 740 | optional = false 741 | python-versions = ">=3.7" 742 | files = [ 743 | {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, 744 | {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, 745 | ] 746 | 747 | [[package]] 748 | name = "tomli" 749 | version = "2.2.1" 750 | description = "A lil' TOML parser" 751 | optional = false 752 | python-versions = ">=3.8" 753 | files = [ 754 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 755 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 756 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 757 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 758 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 759 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 760 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 761 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 762 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 763 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 764 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 765 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 766 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 767 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 768 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 769 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 770 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 771 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 772 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 773 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 774 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 775 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 776 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 777 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 778 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 779 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 780 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 781 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 782 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 783 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 784 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 785 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 786 | ] 787 | 788 | [[package]] 789 | name = "tomlkit" 790 | version = "0.13.2" 791 | description = "Style preserving TOML library" 792 | optional = false 793 | python-versions = ">=3.8" 794 | files = [ 795 | {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, 796 | {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, 797 | ] 798 | 799 | [[package]] 800 | name = "typing-extensions" 801 | version = "4.13.2" 802 | description = "Backported and Experimental Type Hints for Python 3.8+" 803 | optional = false 804 | python-versions = ">=3.8" 805 | files = [ 806 | {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, 807 | {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, 808 | ] 809 | 810 | [[package]] 811 | name = "typing-inspection" 812 | version = "0.4.0" 813 | description = "Runtime typing introspection tools" 814 | optional = false 815 | python-versions = ">=3.9" 816 | files = [ 817 | {file = "typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f"}, 818 | {file = "typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122"}, 819 | ] 820 | 821 | [package.dependencies] 822 | typing-extensions = ">=4.12.0" 823 | 824 | [metadata] 825 | lock-version = "2.0" 826 | python-versions = ">=3.9, <4.0" 827 | content-hash = "6845c0890bbe148fdef10448ec01aade62e0f6223830ed6b8310908f9840aa10" 828 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "rtbhouse-sdk" 3 | version = "14.2.0" 4 | description = "RTB House SDK" 5 | authors = ["RTB House Apps Team "] 6 | license = "BSD License" 7 | readme = "README.rst" 8 | classifiers = [ 9 | "Development Status :: 5 - Production/Stable", 10 | "Intended Audience :: Developers", 11 | "Natural Language :: English", 12 | "License :: OSI Approved :: BSD License", 13 | "Programming Language :: Python", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3.9", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | ] 21 | repository = "https://github.com/rtbhouse-apps/rtbhouse-python-sdk" 22 | 23 | [[tool.poetry.source]] 24 | name = "PyPI" 25 | priority = "primary" 26 | 27 | [tool.poetry.dependencies] 28 | python = ">=3.9, <4.0" 29 | 30 | httpx = "^0.28.0" 31 | pydantic = ">=1.9, <3.0" 32 | 33 | [tool.poetry.group.dev.dependencies] 34 | pydantic = "^2.0.0" # required for tests 35 | 36 | black = "^25.0.0" 37 | flake8 = "^7.0.0" 38 | isort = "^6.0.0" 39 | mypy = "^1.0" 40 | pylint = "^3.0.0" 41 | pytest = "^8.0.0" 42 | pytest-asyncio = "^0.26.0" 43 | pytest-cov = "^6.0.0" 44 | respx = "^0.22.0" 45 | 46 | [tool.black] 47 | line-length = 120 48 | target-version = ["py39", "py310", "py311", "py312", "py313"] 49 | 50 | [tool.coverage.run] 51 | branch = true 52 | omit = ["*/tests/*"] 53 | 54 | [tool.coverage.report] 55 | show_missing = true 56 | 57 | [tool.isort] 58 | line_length = 120 59 | profile = "black" 60 | 61 | [tool.mypy] 62 | python_version = "3.9" 63 | strict = true 64 | plugins = ["pydantic.mypy"] 65 | 66 | [tool.pydantic-mypy] 67 | init_forbid_extra = true 68 | init_typed = true 69 | warn_required_dynamic_aliases = false 70 | warn_untyped_fields = true 71 | 72 | [tool.pylint.main] 73 | py-version = "3.9" 74 | load-plugins = """ 75 | pylint.extensions.check_elif, 76 | pylint.extensions.confusing_elif, 77 | pylint.extensions.consider_ternary_expression, 78 | pylint.extensions.empty_comment, 79 | pylint.extensions.eq_without_hash, 80 | pylint.extensions.for_any_all, 81 | pylint.extensions.redefined_loop_name, 82 | pylint.extensions.redefined_variable_type, 83 | pylint.extensions.set_membership, 84 | pylint.extensions.typing, 85 | """ 86 | 87 | [tool.pylint.format] 88 | max-line-length = "120" 89 | 90 | [tool.pylint.message_control] 91 | enable = "all" 92 | disable = """ 93 | duplicate-code, 94 | invalid-name, 95 | locally-disabled, 96 | missing-class-docstring, 97 | missing-function-docstring, 98 | suppressed-message, 99 | too-many-positional-arguments, 100 | use-implicit-booleaness-not-comparison-to-zero, 101 | use-implicit-booleaness-not-comparison, 102 | """ 103 | extension-pkg-whitelist = "pydantic" 104 | 105 | [tool.pytest.ini_options] 106 | addopts = "--cov=rtbhouse_sdk/ --cov-report=term" 107 | asyncio_mode = "auto" 108 | asyncio_default_fixture_loop_scope = "session" 109 | asyncio_default_test_loop_scope = "session" 110 | 111 | [build-system] 112 | requires = ["poetry-core"] 113 | build-backend = "poetry.core.masonry.api" 114 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>rtbhouse-apps/renovate-config-public" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /rtbhouse_sdk/__init__.py: -------------------------------------------------------------------------------- 1 | """RTB House Python SDK.""" 2 | 3 | __version__ = "14.0.0" 4 | -------------------------------------------------------------------------------- /rtbhouse_sdk/_utils.py: -------------------------------------------------------------------------------- 1 | """Utils used in SDK.""" 2 | 3 | import re 4 | 5 | from pydantic.version import VERSION as PYDANTIC_VERSION 6 | 7 | PYDANTIC_V1 = PYDANTIC_VERSION[0] == "1" 8 | 9 | 10 | def camelize(word: str, uppercase_first_letter: bool = True) -> str: 11 | """ 12 | Convert under_scored string to CamelCase 13 | """ 14 | if not word: 15 | return "" 16 | 17 | result = re.sub(r"(?:^|_)(.)", lambda m: m.group(1).upper(), word) 18 | if result and not uppercase_first_letter: 19 | result = result[0].lower() + result[1:] 20 | return result 21 | 22 | 23 | def underscore(word: str) -> str: 24 | """ 25 | Make an underscored, lowercase form from the expression in the string. 26 | """ 27 | word = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", word) 28 | word = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", word) 29 | word = word.replace("-", "_") 30 | return word.lower() 31 | -------------------------------------------------------------------------------- /rtbhouse_sdk/client.py: -------------------------------------------------------------------------------- 1 | """Contains definitions of standard (sync) client as well as async client.""" 2 | 3 | # pylint: disable=too-many-arguments 4 | import dataclasses 5 | import warnings 6 | from collections.abc import AsyncIterable, Generator, Iterable 7 | from datetime import date, timedelta 8 | from json import JSONDecodeError 9 | from types import TracebackType 10 | from typing import Any, Optional, Union 11 | 12 | import httpx 13 | 14 | from . import __version__ as sdk_version 15 | from . import schema 16 | from .exceptions import ( 17 | ApiException, 18 | ApiRateLimitException, 19 | ApiRequestException, 20 | ApiVersionMismatchException, 21 | ErrorDetails, 22 | ) 23 | 24 | API_BASE_URL = "https://api.panel.rtbhouse.com" 25 | API_VERSION = "v5" 26 | 27 | DEFAULT_TIMEOUT = timedelta(seconds=60.0) 28 | MAX_CURSOR_ROWS = 10000 29 | 30 | 31 | @dataclasses.dataclass 32 | class BasicAuth: 33 | username: str 34 | password: str 35 | 36 | 37 | @dataclasses.dataclass 38 | class BasicTokenAuth: 39 | token: str 40 | 41 | 42 | class Client: 43 | """ 44 | A standard synchronous API client. 45 | 46 | The simplest way is to use it like: 47 | ``` 48 | cli = Client(...) 49 | info = cli.get_user_info() 50 | adv = cli.get_advertiser(hash) 51 | cli.close() 52 | ``` 53 | 54 | It's also possible to use it as context manager: 55 | ``` 56 | with Client(...) as cli: 57 | info = cli.get_user_info() 58 | adv = cli.get_advertiser(hash) 59 | ``` 60 | """ 61 | 62 | def __init__( 63 | self, 64 | auth: Union[BasicAuth, BasicTokenAuth], 65 | timeout: timedelta = DEFAULT_TIMEOUT, 66 | ): 67 | self._httpx_client = httpx.Client( 68 | base_url=build_base_url(), 69 | auth=_choose_auth_backend(auth), 70 | headers=_build_headers(), 71 | timeout=timeout.total_seconds(), 72 | ) 73 | 74 | def close(self) -> None: 75 | self._httpx_client.close() 76 | 77 | def __enter__(self) -> "Client": 78 | self._httpx_client.__enter__() 79 | return self 80 | 81 | def __exit__( 82 | self, 83 | exc_type: type[BaseException], 84 | exc_value: BaseException, 85 | traceback: TracebackType, 86 | ) -> None: 87 | self._httpx_client.__exit__(exc_type, exc_value, traceback) 88 | 89 | def _get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any: 90 | response = self._httpx_client.get(path, params=params) 91 | _validate_response(response) 92 | try: 93 | resp_json = response.json() 94 | return resp_json["data"] 95 | except (ValueError, KeyError) as exc: 96 | raise ApiException("Invalid response format") from exc 97 | 98 | def _get_dict(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: 99 | data = self._get(path, params) 100 | if not isinstance(data, dict): 101 | raise ValueError("Result is not a dict") 102 | return data 103 | 104 | def _get_list_of_dicts(self, path: str, params: Optional[dict[str, Any]] = None) -> list[dict[str, Any]]: 105 | data = self._get(path, params) 106 | if not isinstance(data, list) or not all(isinstance(item, dict) for item in data): 107 | raise ValueError("Result is not a list of dicts") 108 | return data 109 | 110 | def _get_list_of_dicts_from_cursor(self, path: str, params: dict[str, Any]) -> Iterable[dict[str, Any]]: 111 | request_params = { 112 | "limit": MAX_CURSOR_ROWS, 113 | } 114 | request_params.update(params or {}) 115 | 116 | while True: 117 | resp_data = self._get_dict(path, params=request_params) 118 | yield from resp_data["rows"] 119 | next_cursor = resp_data["nextCursor"] 120 | if next_cursor is None: 121 | break 122 | request_params["nextCursor"] = next_cursor 123 | 124 | def get_user_info(self) -> schema.UserInfo: 125 | data = self._get_dict("/user/info") 126 | return schema.UserInfo(**data) 127 | 128 | def get_advertisers(self) -> list[schema.Advertiser]: 129 | data = self._get_list_of_dicts("/advertisers") 130 | return [schema.Advertiser(**adv) for adv in data] 131 | 132 | def get_advertiser(self, adv_hash: str) -> schema.Advertiser: 133 | data = self._get_dict(f"/advertisers/{adv_hash}") 134 | return schema.Advertiser(**data) 135 | 136 | def get_invoicing_data(self, adv_hash: str) -> schema.InvoiceData: 137 | data = self._get_dict(f"/advertisers/{adv_hash}/client") 138 | return schema.InvoiceData(**data["invoicing"]) 139 | 140 | def get_offer_categories(self, adv_hash: str) -> list[schema.Category]: 141 | data = self._get_list_of_dicts(f"/advertisers/{adv_hash}/offer-categories") 142 | return [schema.Category(**cat) for cat in data] 143 | 144 | def get_offers(self, adv_hash: str) -> list[schema.Offer]: 145 | data = self._get_list_of_dicts(f"/advertisers/{adv_hash}/offers") 146 | return [schema.Offer(**offer) for offer in data] 147 | 148 | def get_advertiser_campaigns(self, adv_hash: str, exclude_archived: bool = False) -> list[schema.Campaign]: 149 | data = self._get_list_of_dicts(f"/advertisers/{adv_hash}/campaigns", {"excludeArchived": exclude_archived}) 150 | return [schema.Campaign(**camp) for camp in data] 151 | 152 | def get_billing( 153 | self, 154 | adv_hash: str, 155 | day_from: date, 156 | day_to: date, 157 | ) -> schema.Billing: 158 | data = self._get_dict(f"/advertisers/{adv_hash}/billing", {"dayFrom": day_from, "dayTo": day_to}) 159 | return schema.Billing(**data) 160 | 161 | def get_rtb_creatives( 162 | self, 163 | adv_hash: str, 164 | subcampaigns: Union[None, list[str], schema.SubcampaignsFilter] = None, 165 | active_only: Optional[bool] = None, 166 | ) -> list[schema.Creative]: 167 | params = _build_rtb_creatives_params(subcampaigns, active_only) 168 | data = self._get_list_of_dicts(f"/advertisers/{adv_hash}/rtb-creatives", params=params) 169 | return [schema.Creative(**cr) for cr in data] 170 | 171 | def get_rtb_conversions( 172 | self, 173 | adv_hash: str, 174 | day_from: date, 175 | day_to: date, 176 | convention_type: schema.CountConvention = schema.CountConvention.ATTRIBUTED_POST_CLICK, 177 | ) -> Iterable[schema.Conversion]: 178 | rows = self._get_list_of_dicts_from_cursor( 179 | f"/advertisers/{adv_hash}/conversions", 180 | params={ 181 | "dayFrom": day_from, 182 | "dayTo": day_to, 183 | "countConvention": convention_type.value, 184 | }, 185 | ) 186 | for conv in rows: 187 | yield schema.Conversion(**conv) 188 | 189 | def get_rtb_stats( 190 | self, 191 | adv_hash: str, 192 | day_from: date, 193 | day_to: date, 194 | group_by: list[schema.StatsGroupBy], 195 | metrics: list[schema.StatsMetric], 196 | count_convention: Optional[schema.CountConvention] = None, 197 | utc_offset_hours: int = 0, 198 | subcampaigns: Optional[list[str]] = None, 199 | user_segments: Optional[list[schema.UserSegment]] = None, 200 | device_types: Optional[list[schema.DeviceType]] = None, 201 | ) -> list[schema.Stats]: 202 | params = _build_rtb_stats_params( 203 | day_from, 204 | day_to, 205 | group_by, 206 | metrics, 207 | count_convention, 208 | utc_offset_hours, 209 | subcampaigns, 210 | user_segments, 211 | device_types, 212 | ) 213 | 214 | data = self._get_list_of_dicts(f"/advertisers/{adv_hash}/rtb-stats", params) 215 | return [schema.Stats(**st) for st in data] 216 | 217 | def get_summary_stats( 218 | self, 219 | adv_hash: str, 220 | day_from: date, 221 | day_to: date, 222 | group_by: list[schema.StatsGroupBy], 223 | metrics: list[schema.StatsMetric], 224 | count_convention: Optional[schema.CountConvention] = None, 225 | utc_offset_hours: int = 0, 226 | subcampaigns: Optional[list[str]] = None, 227 | ) -> list[schema.Stats]: 228 | params = _build_summary_stats_params( 229 | day_from, day_to, group_by, metrics, count_convention, utc_offset_hours, subcampaigns 230 | ) 231 | 232 | data = self._get_list_of_dicts(f"/advertisers/{adv_hash}/summary-stats", params) 233 | return [schema.Stats(**st) for st in data] 234 | 235 | 236 | class AsyncClient: 237 | """ 238 | An asynchronous API client. 239 | 240 | Usage is the same as with synchronous client with the only difference of `await` keyword. 241 | ``` 242 | cli = AsyncClient(...) 243 | info = await cli.get_user_info() 244 | await cli.close() 245 | ``` 246 | """ 247 | 248 | def __init__( 249 | self, 250 | auth: Union[BasicAuth, BasicTokenAuth], 251 | timeout: timedelta = DEFAULT_TIMEOUT, 252 | ) -> None: 253 | self._httpx_client = httpx.AsyncClient( 254 | base_url=build_base_url(), 255 | auth=_choose_auth_backend(auth), 256 | headers=_build_headers(), 257 | timeout=timeout.total_seconds(), 258 | ) 259 | 260 | async def close(self) -> None: 261 | await self._httpx_client.aclose() 262 | 263 | async def __aenter__(self) -> "AsyncClient": 264 | await self._httpx_client.__aenter__() 265 | return self 266 | 267 | async def __aexit__( 268 | self, 269 | exc_type: type[BaseException], 270 | exc_value: BaseException, 271 | traceback: TracebackType, 272 | ) -> None: 273 | await self._httpx_client.__aexit__(exc_type, exc_value, traceback) 274 | 275 | async def _get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any: 276 | response = await self._httpx_client.get(path, params=params) 277 | _validate_response(response) 278 | try: 279 | resp_json = response.json() 280 | return resp_json["data"] 281 | except (ValueError, KeyError) as exc: 282 | raise ApiException("Invalid response format") from exc 283 | 284 | async def _get_dict(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: 285 | data = await self._get(path, params) 286 | if not isinstance(data, dict): 287 | raise ValueError("Result is not a dict") 288 | return data 289 | 290 | async def _get_list_of_dicts(self, path: str, params: Optional[dict[str, Any]] = None) -> list[dict[str, Any]]: 291 | data = await self._get(path, params) 292 | if not isinstance(data, list) or not all(isinstance(item, dict) for item in data): 293 | raise ValueError("Result is not of a list of dicts") 294 | return data 295 | 296 | async def _get_list_of_dicts_from_cursor(self, path: str, params: dict[str, Any]) -> AsyncIterable[dict[str, Any]]: 297 | request_params = { 298 | "limit": MAX_CURSOR_ROWS, 299 | } 300 | request_params.update(params or {}) 301 | 302 | while True: 303 | resp_data = await self._get_dict(path, params=request_params) 304 | for row in resp_data["rows"]: 305 | yield row 306 | next_cursor = resp_data["nextCursor"] 307 | if next_cursor is None: 308 | break 309 | request_params["nextCursor"] = next_cursor 310 | 311 | async def get_user_info(self) -> schema.UserInfo: 312 | data = await self._get_dict("/user/info") 313 | return schema.UserInfo(**data) 314 | 315 | async def get_advertisers(self) -> list[schema.Advertiser]: 316 | data = await self._get_list_of_dicts("/advertisers") 317 | return [schema.Advertiser(**adv) for adv in data] 318 | 319 | async def get_advertiser(self, adv_hash: str) -> schema.Advertiser: 320 | data = await self._get_dict(f"/advertisers/{adv_hash}") 321 | return schema.Advertiser(**data) 322 | 323 | async def get_invoicing_data(self, adv_hash: str) -> schema.InvoiceData: 324 | data = await self._get_dict(f"/advertisers/{adv_hash}/client") 325 | return schema.InvoiceData(**data["invoicing"]) 326 | 327 | async def get_offer_categories(self, adv_hash: str) -> list[schema.Category]: 328 | data = await self._get_list_of_dicts(f"/advertisers/{adv_hash}/offer-categories") 329 | return [schema.Category(**cat) for cat in data] 330 | 331 | async def get_offers(self, adv_hash: str) -> list[schema.Offer]: 332 | data = await self._get_list_of_dicts(f"/advertisers/{adv_hash}/offers") 333 | return [schema.Offer(**offer) for offer in data] 334 | 335 | async def get_advertiser_campaigns(self, adv_hash: str, exclude_archived: bool = False) -> list[schema.Campaign]: 336 | data = await self._get_list_of_dicts( 337 | f"/advertisers/{adv_hash}/campaigns", {"excludeArchived": exclude_archived} 338 | ) 339 | return [schema.Campaign(**camp) for camp in data] 340 | 341 | async def get_billing( 342 | self, 343 | adv_hash: str, 344 | day_from: date, 345 | day_to: date, 346 | ) -> schema.Billing: 347 | data = await self._get_dict(f"/advertisers/{adv_hash}/billing", {"dayFrom": day_from, "dayTo": day_to}) 348 | return schema.Billing(**data) 349 | 350 | async def get_rtb_creatives( 351 | self, 352 | adv_hash: str, 353 | subcampaigns: Union[None, list[str], schema.SubcampaignsFilter] = None, 354 | active_only: Optional[bool] = None, 355 | ) -> list[schema.Creative]: 356 | params = _build_rtb_creatives_params(subcampaigns, active_only) 357 | data = await self._get_list_of_dicts(f"/advertisers/{adv_hash}/rtb-creatives", params=params) 358 | return [schema.Creative(**cr) for cr in data] 359 | 360 | async def get_rtb_conversions( 361 | self, 362 | adv_hash: str, 363 | day_from: date, 364 | day_to: date, 365 | convention_type: schema.CountConvention = schema.CountConvention.ATTRIBUTED_POST_CLICK, 366 | ) -> AsyncIterable[schema.Conversion]: 367 | rows = self._get_list_of_dicts_from_cursor( 368 | f"/advertisers/{adv_hash}/conversions", 369 | params={ 370 | "dayFrom": day_from, 371 | "dayTo": day_to, 372 | "countConvention": convention_type.value, 373 | }, 374 | ) 375 | async for conv in rows: 376 | yield schema.Conversion(**conv) 377 | 378 | async def get_rtb_stats( 379 | self, 380 | adv_hash: str, 381 | day_from: date, 382 | day_to: date, 383 | group_by: list[schema.StatsGroupBy], 384 | metrics: list[schema.StatsMetric], 385 | count_convention: Optional[schema.CountConvention] = None, 386 | utc_offset_hours: int = 0, 387 | subcampaigns: Optional[list[str]] = None, 388 | user_segments: Optional[list[schema.UserSegment]] = None, 389 | device_types: Optional[list[schema.DeviceType]] = None, 390 | ) -> list[schema.Stats]: 391 | params = _build_rtb_stats_params( 392 | day_from, 393 | day_to, 394 | group_by, 395 | metrics, 396 | count_convention, 397 | utc_offset_hours, 398 | subcampaigns, 399 | user_segments, 400 | device_types, 401 | ) 402 | 403 | data = await self._get_list_of_dicts(f"/advertisers/{adv_hash}/rtb-stats", params) 404 | return [schema.Stats(**st) for st in data] 405 | 406 | async def get_summary_stats( 407 | self, 408 | adv_hash: str, 409 | day_from: date, 410 | day_to: date, 411 | group_by: list[schema.StatsGroupBy], 412 | metrics: list[schema.StatsMetric], 413 | count_convention: Optional[schema.CountConvention] = None, 414 | utc_offset_hours: int = 0, 415 | subcampaigns: Optional[list[str]] = None, 416 | ) -> list[schema.Stats]: 417 | params = _build_summary_stats_params( 418 | day_from, day_to, group_by, metrics, count_convention, utc_offset_hours, subcampaigns 419 | ) 420 | 421 | data = await self._get_list_of_dicts(f"/advertisers/{adv_hash}/summary-stats", params) 422 | return [schema.Stats(**st) for st in data] 423 | 424 | 425 | class _HttpxBasicTokenAuth(httpx.Auth): 426 | """Basic token auth backend.""" 427 | 428 | def __init__(self, token: str): 429 | self._token = token 430 | 431 | def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: 432 | request.headers["Authorization"] = f"Token {self._token}" 433 | yield request 434 | 435 | 436 | def build_base_url() -> str: 437 | return f"{API_BASE_URL}/{API_VERSION}" 438 | 439 | 440 | def _build_headers() -> dict[str, str]: 441 | return { 442 | "user-agent": f"rtbhouse-python-sdk/{sdk_version}", 443 | } 444 | 445 | 446 | def _choose_auth_backend(auth: Union[BasicAuth, BasicTokenAuth]) -> httpx.Auth: 447 | if isinstance(auth, BasicAuth): 448 | return httpx.BasicAuth(auth.username, auth.password) 449 | if isinstance(auth, BasicTokenAuth): 450 | return _HttpxBasicTokenAuth(auth.token) 451 | raise ValueError("Unknown auth method") 452 | 453 | 454 | def _validate_response(response: httpx.Response) -> None: 455 | try: 456 | response_data = response.json() 457 | except JSONDecodeError: 458 | error_details = None 459 | else: 460 | error_details = ErrorDetails( 461 | app_code=response_data.get("appCode"), 462 | errors=response_data.get("errors"), 463 | message=response_data.get("message"), 464 | ) 465 | 466 | if response.status_code == 410: 467 | newest_version = response.headers.get("X-Current-Api-Version") 468 | raise ApiVersionMismatchException( 469 | f"Unsupported api version ({API_VERSION}), use newest version ({newest_version}) " 470 | f"by updating rtbhouse_sdk package." 471 | ) 472 | 473 | if response.status_code == 429: 474 | raise ApiRateLimitException( 475 | "Resource usage limits reached", 476 | details=error_details, 477 | usage_header=response.headers.get("X-Resource-Usage"), 478 | ) 479 | 480 | if response.is_error: 481 | raise ApiRequestException( 482 | error_details.message if error_details else "Unexpected error", 483 | details=error_details, 484 | ) 485 | 486 | current_version = response.headers.get("X-Current-Api-Version") 487 | if current_version is not None and current_version != API_VERSION: 488 | warnings.warn( 489 | f"Used api version ({API_VERSION}) is outdated, use newest version ({current_version}) " 490 | f"by updating rtbhouse_sdk package." 491 | ) 492 | 493 | 494 | def _build_rtb_creatives_params( 495 | subcampaigns: Union[None, list[str], schema.SubcampaignsFilter] = None, 496 | active_only: Optional[bool] = None, 497 | ) -> dict[str, Any]: 498 | params: dict[str, Any] = {} 499 | if subcampaigns: 500 | if isinstance(subcampaigns, schema.SubcampaignsFilter): 501 | params["subcampaigns"] = subcampaigns.value 502 | elif isinstance(subcampaigns, (list, tuple, set)): 503 | params["subcampaigns"] = "-".join(str(sub) for sub in subcampaigns) 504 | if active_only is not None: 505 | params["activeOnly"] = active_only 506 | 507 | return params 508 | 509 | 510 | def _build_rtb_stats_params( 511 | day_from: date, 512 | day_to: date, 513 | group_by: list[schema.StatsGroupBy], 514 | metrics: list[schema.StatsMetric], 515 | count_convention: Optional[schema.CountConvention] = None, 516 | utc_offset_hours: int = 0, 517 | subcampaigns: Optional[list[str]] = None, 518 | user_segments: Optional[list[schema.UserSegment]] = None, 519 | device_types: Optional[list[schema.DeviceType]] = None, 520 | ) -> dict[str, Any]: 521 | params: dict[str, Any] = { 522 | "dayFrom": day_from, 523 | "dayTo": day_to, 524 | "groupBy": "-".join(gb.value for gb in group_by), 525 | "metrics": "-".join(m.value for m in metrics), 526 | } 527 | if count_convention is not None: 528 | params["countConvention"] = count_convention.value 529 | if utc_offset_hours != 0: 530 | params["utcOffsetHours"] = utc_offset_hours 531 | if subcampaigns is not None: 532 | params["subcampaigns"] = "-".join(str(sub) for sub in subcampaigns) 533 | if user_segments is not None: 534 | params["userSegments"] = "-".join(us.value for us in user_segments) 535 | if device_types is not None: 536 | params["deviceTypes"] = "-".join(dt.value for dt in device_types) 537 | 538 | return params 539 | 540 | 541 | def _build_summary_stats_params( 542 | day_from: date, 543 | day_to: date, 544 | group_by: list[schema.StatsGroupBy], 545 | metrics: list[schema.StatsMetric], 546 | count_convention: Optional[schema.CountConvention] = None, 547 | utc_offset_hours: int = 0, 548 | subcampaigns: Optional[list[str]] = None, 549 | ) -> dict[str, Any]: 550 | params: dict[str, Any] = { 551 | "dayFrom": day_from, 552 | "dayTo": day_to, 553 | "groupBy": "-".join(gb.value for gb in group_by), 554 | "metrics": "-".join(m.value for m in metrics), 555 | } 556 | if count_convention is not None: 557 | params["countConvention"] = count_convention.value 558 | if utc_offset_hours != 0: 559 | params["utcOffsetHours"] = utc_offset_hours 560 | if subcampaigns is not None: 561 | params["subcampaigns"] = "-".join(str(sub) for sub in subcampaigns) 562 | 563 | return params 564 | -------------------------------------------------------------------------------- /rtbhouse_sdk/exceptions.py: -------------------------------------------------------------------------------- 1 | """Definitions of exceptions used in SDK.""" 2 | 3 | import dataclasses 4 | from typing import Any, Optional 5 | 6 | 7 | @dataclasses.dataclass 8 | class ErrorDetails: 9 | app_code: str 10 | message: str 11 | errors: Optional[dict[str, Any]] 12 | 13 | 14 | class ApiException(Exception): 15 | """Base API Exception.""" 16 | 17 | message: str 18 | error_details: Optional[ErrorDetails] 19 | 20 | def __init__(self, message: str, details: Optional[ErrorDetails] = None) -> None: 21 | super().__init__(message) 22 | self.message = message 23 | self.error_details = details 24 | 25 | def __str__(self) -> str: 26 | return self.message 27 | 28 | 29 | class ApiVersionMismatchException(ApiException): 30 | """Indicates SDK version is behind API version and needs to be updated.""" 31 | 32 | 33 | class ApiRequestException(ApiException): 34 | """Indicates there's something wrong with request.""" 35 | 36 | 37 | class ApiRateLimitException(ApiRequestException): 38 | """Indicates that rate limit was exceeded.""" 39 | 40 | limits: dict[str, dict[str, dict[str, float]]] 41 | 42 | def __init__( 43 | self, 44 | message: str, 45 | details: Optional[ErrorDetails], 46 | usage_header: Optional[str], 47 | ) -> None: 48 | super().__init__(message, details) 49 | self.limits = _parse_resource_usage_header(usage_header) 50 | 51 | 52 | def _parse_resource_usage_header(header: Optional[str]) -> dict[str, dict[str, dict[str, float]]]: 53 | """parse string like WORKER_TIME-3600=11.7/10000000;BQ_TB_BILLED-21600=4.62/2000 into dict""" 54 | if not header: 55 | return {} 56 | result: dict[str, dict[str, dict[str, float]]] = {} 57 | try: 58 | for line in header.split(";"): 59 | right, left = line.split("=") 60 | metric, time_span = right.split("-") 61 | used, limit = left.split("/") 62 | result.setdefault(metric, {}).setdefault(time_span, {})[limit] = float(used) 63 | except ValueError: 64 | return {} 65 | return result 66 | -------------------------------------------------------------------------------- /rtbhouse_sdk/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rtbhouse-apps/rtbhouse-python-sdk/4e5334bac23222b6c71caedff44d90f6eb595cc0/rtbhouse_sdk/py.typed -------------------------------------------------------------------------------- /rtbhouse_sdk/schema.py: -------------------------------------------------------------------------------- 1 | """Schemas and enums for API data.""" 2 | 3 | # pylint: disable=too-few-public-methods 4 | from datetime import date, datetime 5 | from enum import Enum 6 | from functools import partial 7 | from typing import Any, Optional 8 | 9 | from pydantic import BaseModel 10 | 11 | from ._utils import PYDANTIC_V1, camelize 12 | 13 | if not PYDANTIC_V1: 14 | from pydantic import ConfigDict 15 | 16 | 17 | to_camel_case = partial( 18 | camelize, 19 | uppercase_first_letter=False, 20 | ) 21 | 22 | 23 | class CountConvention(str, Enum): 24 | """Holds possible values of count convention parameter.""" 25 | 26 | ATTRIBUTED_POST_CLICK = "ATTRIBUTED" 27 | ATTRIBUTED_POST_VIEW = "POST_VIEW" 28 | ALL_POST_CLICK = "ALL_POST_CLICK" 29 | ALL_CONVERSIONS = "ALL_CONVERSIONS" 30 | 31 | 32 | class UserSegment(str, Enum): 33 | """Holds possible values of user segment parameter.""" 34 | 35 | NEW = "NEW" 36 | VISITORS = "VISITORS" 37 | SHOPPERS = "SHOPPERS" 38 | BUYERS = "BUYERS" 39 | 40 | 41 | class DeviceType(str, Enum): 42 | """Holds possible values of device type parameter.""" 43 | 44 | PC = "PC" 45 | MOBILE = "MOBILE" 46 | PHONE = "PHONE" 47 | TABLET = "TABLET" 48 | TV = "TV" 49 | GAME_CONSOLE = "GAME_CONSOLE" 50 | OTHER = "OTHER" 51 | UNKNOWN = "UNKNOWN" 52 | 53 | 54 | class StatsGroupBy(str, Enum): 55 | """Holds possible values of group by parameter.""" 56 | 57 | HOUR = "hour" 58 | DAY = "day" 59 | WEEK = "week" 60 | MONTH = "month" 61 | YEAR = "year" 62 | 63 | ADVERTISER = "advertiser" 64 | SUBCAMPAIGN = "subcampaign" 65 | USER_SEGMENT = "userSegment" 66 | DEVICE_TYPE = "deviceType" 67 | CREATIVE = "creative" 68 | CATEGORY = "category" 69 | COUNTRY = "country" 70 | PLACEMENT = "placement" 71 | 72 | 73 | class StatsMetric(str, Enum): 74 | """Holds possible values of metric parameter.""" 75 | 76 | CAMPAIGN_COST = "campaignCost" 77 | IMPS_COUNT = "impsCount" 78 | ECPM = "ecpm" 79 | CLICKS_COUNT = "clicksCount" 80 | ECPC = "ecpc" 81 | CTR = "ctr" 82 | CONVERSIONS_COUNT = "conversionsCount" 83 | ECPA = "ecpa" 84 | CR = "cr" 85 | CONVERSIONS_VALUE = "conversionsValue" 86 | ROAS = "roas" 87 | ECPS = "ecps" 88 | VIDEO_COMPLETE_VIEWS = "videoCompleteViews" 89 | ECPV = "ecpv" 90 | VCR = "vcr" 91 | AUDIO_COMPLETE_LISTENS = "audioCompleteListens" 92 | ECPL = "ecpl" 93 | ACR = "acr" 94 | VIEWABILITY_MEASURABILITY = "viewabilityMeasurability" 95 | VIEWABILITY_VIEWABILITY = "viewabilityViewability" 96 | EVCPM = "evcpm" 97 | SSP_VIEWABILITY = "sspViewability" 98 | VISITS_COUNT = "visitsCount" 99 | CPVISIT = "cpvisit" 100 | USER_FREQUENCY = "userFrequency" 101 | USER_REACH = "userReach" 102 | 103 | 104 | class SubcampaignsFilter(str, Enum): 105 | """Holds possible values of subcampaigns parameter.""" 106 | 107 | ANY = "ANY" 108 | ACTIVE = "ACTIVE" 109 | 110 | 111 | class CamelizedBaseModel(BaseModel): 112 | if PYDANTIC_V1: 113 | 114 | class Config: 115 | alias_generator = to_camel_case 116 | 117 | else: 118 | model_config = ConfigDict( # pylint: disable=possibly-used-before-assignment # pyright: ignore 119 | alias_generator=to_camel_case, 120 | ) 121 | 122 | 123 | class UserInfo(CamelizedBaseModel): 124 | hash_id: str 125 | login: str 126 | email: str 127 | is_client_user: bool 128 | permissions: list[str] 129 | 130 | 131 | class Advertiser(CamelizedBaseModel): 132 | hash: str 133 | status: str 134 | name: str 135 | currency: str 136 | url: str 137 | created_at: datetime 138 | properties: dict[str, Any] 139 | 140 | 141 | class Campaign(CamelizedBaseModel): 142 | hash: str 143 | name: str 144 | creative_ids: list[int] 145 | status: str 146 | updated_at: Optional[datetime] 147 | rate_card_id: str 148 | is_editable: bool 149 | advertiser_limits: Optional[dict[str, Optional[int]]] = None 150 | 151 | 152 | class InvoiceData(BaseModel): 153 | vat_number: str 154 | company_name: str 155 | street1: str 156 | street2: Optional[str] = None 157 | postal_code: str 158 | city: str 159 | country: str 160 | email: str 161 | 162 | 163 | class Category(CamelizedBaseModel): 164 | category_id: str 165 | identifier: str 166 | name: str 167 | active_offers_number: int 168 | 169 | 170 | class Image(CamelizedBaseModel): 171 | width: str 172 | height: str 173 | url: str 174 | added: str 175 | hash: str 176 | 177 | 178 | class Offer(CamelizedBaseModel): 179 | url: str 180 | full_name: str 181 | identifier: str 182 | id: str 183 | images: list[Image] 184 | name: str 185 | price: float 186 | category_name: str 187 | custom_properties: dict[str, str] 188 | updated_at: str 189 | status: str 190 | 191 | 192 | class Bill(CamelizedBaseModel): 193 | day: date 194 | operation: str 195 | position: int 196 | credit: float 197 | debit: float 198 | balance: float 199 | record_number: int 200 | 201 | 202 | class Billing(CamelizedBaseModel): 203 | initial_balance: float 204 | bills: list[Bill] 205 | 206 | 207 | class CreativePreview(CamelizedBaseModel): 208 | width: int 209 | height: int 210 | offers_number: int 211 | preview_url: str 212 | 213 | 214 | class Creative(CamelizedBaseModel): 215 | hash: str 216 | previews: list[CreativePreview] 217 | 218 | 219 | class Conversion(CamelizedBaseModel): 220 | conversion_identifier: str 221 | conversion_hash: str 222 | conversion_class: Optional[str] 223 | conversion_value: float 224 | commission_value: float 225 | cookie_hash: Optional[str] 226 | conversion_time: datetime 227 | last_click_time: Optional[datetime] 228 | last_impression_time: Optional[datetime] 229 | 230 | 231 | class Stats(CamelizedBaseModel): 232 | # from GroupBy 233 | hour: Optional[int] = None 234 | day: Optional[date] = None 235 | week: Optional[str] = None 236 | month: Optional[str] = None 237 | year: Optional[str] = None 238 | advertiser: Optional[str] = None 239 | subcampaign: Optional[str] = None 240 | subcampaign_hash: Optional[str] = None 241 | user_segment: Optional[str] = None 242 | device_type: Optional[str] = None 243 | creative: Optional[str] = None 244 | category: Optional[str] = None 245 | category_name: Optional[str] = None 246 | country: Optional[str] = None 247 | placement: Optional[str] = None 248 | 249 | # from Metric 250 | campaign_cost: Optional[float] = None 251 | imps_count: Optional[float] = None 252 | ecpm: Optional[float] = None 253 | clicks_count: Optional[float] = None 254 | ecpc: Optional[float] = None 255 | ctr: Optional[float] = None 256 | conversions_count: Optional[float] = None 257 | ecpa: Optional[float] = None 258 | cr: Optional[float] = None 259 | conversions_value: Optional[float] = None 260 | roas: Optional[float] = None 261 | ecps: Optional[float] = None 262 | video_complete_views: Optional[float] = None 263 | ecpv: Optional[float] = None 264 | vcr: Optional[float] = None 265 | audio_complete_listens: Optional[float] = None 266 | ecpl: Optional[float] = None 267 | acr: Optional[float] = None 268 | viewability_measurability: Optional[float] = None 269 | viewability_viewability: Optional[float] = None 270 | evcpm: Optional[float] = None 271 | ssp_viewability: Optional[float] = None 272 | visits_count: Optional[float] = None 273 | cpvisit: Optional[float] = None 274 | user_frequency: Optional[float] = None 275 | user_reach: Optional[float] = None 276 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = W503 3 | max-line-length = 120 4 | exclude = 5 | venv, 6 | .venv 7 | per-file-ignores = 8 | **/__init__.py:F401,F403 9 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rtbhouse-apps/rtbhouse-python-sdk/4e5334bac23222b6c71caedff44d90f6eb595cc0/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Common fixtures.""" 2 | 3 | import copy 4 | from collections.abc import Iterator 5 | from datetime import date 6 | from typing import Any 7 | 8 | import pytest 9 | import respx 10 | 11 | from rtbhouse_sdk.client import build_base_url 12 | 13 | 14 | @pytest.fixture 15 | def adv_hash() -> str: 16 | return "advhash" 17 | 18 | 19 | @pytest.fixture 20 | def day_from() -> date: 21 | return date(2020, 9, 1) 22 | 23 | 24 | @pytest.fixture 25 | def day_to() -> date: 26 | return date(2020, 9, 1) 27 | 28 | 29 | @pytest.fixture(autouse=True) 30 | def api_mock() -> Iterator[respx.MockRouter]: 31 | """Keep all httpx calls in a sandbox even when respx was not specifically requested.""" 32 | with respx.mock(base_url=build_base_url()) as mock: 33 | yield mock 34 | 35 | 36 | @pytest.fixture 37 | def user_info_response() -> dict[str, Any]: 38 | return { 39 | "status": "ok", 40 | "data": { 41 | "hashId": "hid", 42 | "login": "john", 43 | "email": "em", 44 | "isClientUser": True, 45 | "isDemoUser": False, 46 | "isForceLoggedIn": False, 47 | "permissions": ["abc"], 48 | "countConvention": None, 49 | }, 50 | } 51 | 52 | 53 | @pytest.fixture 54 | def advertisers_response() -> dict[str, Any]: 55 | return { 56 | "status": "ok", 57 | "data": [ 58 | { 59 | "hash": "hash", 60 | "status": "ACTIVE", 61 | "name": "Adv", 62 | "currency": "USD", 63 | "url": "url", 64 | "createdAt": "2020-10-15T12:58:19.509985+00:00", 65 | "features": {"enabled": ["abc"]}, 66 | "properties": {"key": "val"}, 67 | } 68 | ], 69 | } 70 | 71 | 72 | @pytest.fixture 73 | def advertiser_response() -> dict[str, Any]: 74 | return { 75 | "data": { 76 | "hash": "hash", 77 | "status": "ACTIVE", 78 | "name": "Adv", 79 | "currency": "USD", 80 | "url": "url", 81 | "createdAt": "2020-10-15T12:58:19.509985+00:00", 82 | "properties": {"key": "val"}, 83 | "version": "2020-10-15T12:58:19.509985+00:00", 84 | "feedIdentifier": "xyz", 85 | "country": "US", 86 | } 87 | } 88 | 89 | 90 | @pytest.fixture 91 | def invoice_data_response() -> dict[str, Any]: 92 | return { 93 | "status": "ok", 94 | "data": { 95 | "invoicing": { 96 | "vat_number": "123", 97 | "company_name": "Ltd", 98 | "street1": "St", 99 | "postal_code": "321", 100 | "city": "Rotterdam", 101 | "country": "Netherlands", 102 | "email": "em", 103 | }, 104 | }, 105 | } 106 | 107 | 108 | @pytest.fixture 109 | def offer_categories_response() -> dict[str, Any]: 110 | return { 111 | "status": "ok", 112 | "data": [ 113 | { 114 | "categoryId": "123", 115 | "identifier": "id56", 116 | "name": "full cat", 117 | "activeOffersNumber": 0, 118 | } 119 | ], 120 | } 121 | 122 | 123 | @pytest.fixture 124 | def offers_response() -> dict[str, Any]: 125 | return { 126 | "data": [ 127 | { 128 | "url": "url", 129 | "fullName": "FN", 130 | "identifier": "ident", 131 | "id": "id", 132 | "images": [ 133 | { 134 | "added": "123", 135 | "width": "700", 136 | "height": "800", 137 | "url": "url", 138 | "hash": "hash", 139 | } 140 | ], 141 | "name": "name", 142 | "price": 99.99, 143 | "categoryName": "cat", 144 | "customProperties": {"prop": "val"}, 145 | "updatedAt": "2020-10-15T22:26:49.511000+00:00", 146 | "status": "ACTIVE", 147 | } 148 | ] 149 | } 150 | 151 | 152 | @pytest.fixture 153 | def advertiser_campaigns_response() -> dict[str, Any]: 154 | return { 155 | "status": "ok", 156 | "data": [ 157 | { 158 | "hash": "hash", 159 | "name": "Campaign", 160 | "creativeIds": [543], 161 | "status": "PAUSED", 162 | "updatedAt": "2020-10-15T08:53:15.940369+00:00", 163 | "rateCardId": "E76", 164 | "isEditable": True, 165 | "advertiserLimits": {"budgetDaily": 1, "budgetMonthly": 10}, 166 | } 167 | ], 168 | } 169 | 170 | 171 | @pytest.fixture 172 | def billing_response() -> dict[str, Any]: 173 | return { 174 | "status": "ok", 175 | "data": { 176 | "initialBalance": -100, 177 | "bills": [ 178 | { 179 | "day": "2020-11-25", 180 | "operation": "Cost of campaign", 181 | "position": 2, 182 | "credit": 0, 183 | "debit": -102, 184 | "balance": -200, 185 | "recordNumber": 1, 186 | } 187 | ], 188 | }, 189 | } 190 | 191 | 192 | @pytest.fixture 193 | def rtb_creatives_response() -> dict[str, Any]: 194 | return { 195 | "status": "ok", 196 | "data": [ 197 | { 198 | "hash": "hash", 199 | "status": "ACTIVE", 200 | "previews": [ 201 | { 202 | "width": 300, 203 | "height": 200, 204 | "offersNumber": 4, 205 | "previewUrl": "url", 206 | } 207 | ], 208 | } 209 | ], 210 | } 211 | 212 | 213 | @pytest.fixture(name="conversions_with_next_cursor_response") 214 | def conversions_with_next_cursor_response_fixture() -> dict[str, Any]: 215 | return { 216 | "status": "ok", 217 | "data": { 218 | "rows": [ 219 | { 220 | "conversionTime": "2020-01-02T21:51:57.686000+00:00", 221 | "conversionIdentifier": "226", 222 | "conversionHash": "chash", 223 | "conversionClass": None, 224 | "cookieHash": "hash", 225 | "conversionValue": 13.3, 226 | "commissionValue": 3.0, 227 | "lastClickTime": "2020-01-02T21:35:06.279000+00:00", 228 | "lastImpressionTime": "2020-01-02T21:38:13.346000+00:00", 229 | } 230 | ] 231 | * 3, 232 | "nextCursor": "123", 233 | "total": 1, 234 | }, 235 | } 236 | 237 | 238 | @pytest.fixture 239 | def conversions_without_next_cursor_response(conversions_with_next_cursor_response: dict[str, Any]) -> dict[str, Any]: 240 | data = copy.deepcopy(conversions_with_next_cursor_response) 241 | data["data"]["nextCursor"] = None 242 | return data 243 | -------------------------------------------------------------------------------- /tests/test_async_client.py: -------------------------------------------------------------------------------- 1 | """Tests for async client.""" 2 | 3 | # pylint: disable=too-many-arguments 4 | from collections.abc import AsyncIterator 5 | from datetime import date 6 | from typing import Any 7 | 8 | import pytest 9 | import respx 10 | from httpx import Response 11 | 12 | from rtbhouse_sdk.client import AsyncClient, BasicAuth 13 | from rtbhouse_sdk.schema import StatsGroupBy, StatsMetric 14 | 15 | 16 | @pytest.fixture(name="api") 17 | async def api_client() -> AsyncIterator[AsyncClient]: 18 | async with AsyncClient(auth=BasicAuth("test", "test")) as cli: 19 | yield cli 20 | 21 | 22 | async def test_client_close() -> None: 23 | cli = AsyncClient(auth=BasicAuth("test", "test")) 24 | await cli.close() 25 | 26 | 27 | async def test_client_as_context_manager() -> None: 28 | async with AsyncClient(auth=BasicAuth("test", "test")): 29 | pass 30 | 31 | 32 | async def test_get_user_info( 33 | api: AsyncClient, 34 | api_mock: respx.MockRouter, 35 | user_info_response: dict[str, Any], 36 | ) -> None: 37 | api_mock.get("/user/info").respond(200, json=user_info_response) 38 | 39 | data = await api.get_user_info() 40 | 41 | assert data.hash_id == "hid" 42 | 43 | 44 | async def test_get_advertisers( 45 | api: AsyncClient, 46 | api_mock: respx.MockRouter, 47 | advertisers_response: dict[str, Any], 48 | ) -> None: 49 | api_mock.get("/advertisers").respond(200, json=advertisers_response) 50 | 51 | (advertiser,) = await api.get_advertisers() 52 | 53 | assert advertiser.name == "Adv" 54 | 55 | 56 | async def test_get_advertiser( 57 | api: AsyncClient, 58 | api_mock: respx.MockRouter, 59 | adv_hash: str, 60 | advertiser_response: dict[str, Any], 61 | ) -> None: 62 | api_mock.get(f"/advertisers/{adv_hash}").respond(200, json=advertiser_response) 63 | 64 | advertiser = await api.get_advertiser(adv_hash) 65 | 66 | assert advertiser.name == "Adv" 67 | 68 | 69 | async def test_get_invoicing_data( 70 | api: AsyncClient, 71 | api_mock: respx.MockRouter, 72 | adv_hash: str, 73 | invoice_data_response: dict[str, Any], 74 | ) -> None: 75 | api_mock.get(f"/advertisers/{adv_hash}/client").respond(200, json=invoice_data_response) 76 | 77 | invoice_data = await api.get_invoicing_data(adv_hash) 78 | 79 | assert invoice_data.company_name == "Ltd" 80 | 81 | 82 | async def test_get_offer_categories( 83 | api: AsyncClient, 84 | api_mock: respx.MockRouter, 85 | adv_hash: str, 86 | offer_categories_response: dict[str, Any], 87 | ) -> None: 88 | api_mock.get(f"/advertisers/{adv_hash}/offer-categories").respond(200, json=offer_categories_response) 89 | 90 | (offer_cat,) = await api.get_offer_categories(adv_hash) 91 | 92 | assert offer_cat.name == "full cat" 93 | 94 | 95 | async def test_get_offers( 96 | api: AsyncClient, 97 | api_mock: respx.MockRouter, 98 | adv_hash: str, 99 | offers_response: dict[str, Any], 100 | ) -> None: 101 | api_mock.get(f"/advertisers/{adv_hash}/offers").respond(200, json=offers_response) 102 | 103 | (offer,) = await api.get_offers(adv_hash) 104 | 105 | assert offer.full_name == "FN" 106 | assert offer.images[0].width == "700" 107 | 108 | 109 | async def test_get_advertiser_campaigns( 110 | api: AsyncClient, 111 | api_mock: respx.MockRouter, 112 | adv_hash: str, 113 | advertiser_campaigns_response: dict[str, Any], 114 | ) -> None: 115 | api_mock.get(f"/advertisers/{adv_hash}/campaigns").respond(200, json=advertiser_campaigns_response) 116 | 117 | (campaign,) = await api.get_advertiser_campaigns(adv_hash) 118 | 119 | assert campaign.name == "Campaign" 120 | 121 | 122 | async def test_get_billing( 123 | api: AsyncClient, 124 | api_mock: respx.MockRouter, 125 | adv_hash: str, 126 | day_from: date, 127 | day_to: date, 128 | billing_response: dict[str, Any], 129 | ) -> None: 130 | api_mock.get(f"/advertisers/{adv_hash}/billing").respond(200, json=billing_response) 131 | 132 | billing = await api.get_billing(adv_hash, day_from, day_to) 133 | 134 | (bill,) = billing.bills 135 | assert billing.initial_balance == -100 136 | assert bill.day == date(2020, 11, 25) 137 | 138 | 139 | async def test_get_rtb_creatives( 140 | api: AsyncClient, 141 | api_mock: respx.MockRouter, 142 | adv_hash: str, 143 | rtb_creatives_response: dict[str, Any], 144 | ) -> None: 145 | api_mock.get(f"/advertisers/{adv_hash}/rtb-creatives").respond(200, json=rtb_creatives_response) 146 | 147 | (rtb_creative,) = await api.get_rtb_creatives(adv_hash) 148 | 149 | (call,) = api_mock.calls 150 | assert dict(call.request.url.params) == {} 151 | assert rtb_creative.hash == "hash" 152 | assert len(rtb_creative.previews) == 1 153 | 154 | 155 | async def test_get_rtb_conversions( 156 | api: AsyncClient, 157 | api_mock: respx.MockRouter, 158 | adv_hash: str, 159 | day_from: date, 160 | day_to: date, 161 | conversions_with_next_cursor_response: dict[str, Any], 162 | conversions_without_next_cursor_response: dict[str, Any], 163 | ) -> None: 164 | api_mock.get(f"/advertisers/{adv_hash}/conversions").mock( 165 | side_effect=[ 166 | Response(200, json=conversions_with_next_cursor_response), 167 | Response(200, json=conversions_without_next_cursor_response), 168 | ] 169 | ) 170 | 171 | conversions = [] 172 | async for conv in api.get_rtb_conversions(adv_hash, day_from, day_to): 173 | conversions.append(conv) 174 | 175 | assert len(conversions) == 6 176 | assert conversions[0].conversion_hash == "chash" 177 | 178 | 179 | async def test_get_rtb_stats( 180 | api: AsyncClient, 181 | api_mock: respx.MockRouter, 182 | adv_hash: str, 183 | day_from: date, 184 | day_to: date, 185 | ) -> None: 186 | api_mock.get(f"/advertisers/{adv_hash}/rtb-stats").respond( 187 | 200, 188 | json={"status": "ok", "data": [{"day": "2022-01-01", "advertiser": "xyz", "campaignCost": 51.0}]}, 189 | ) 190 | 191 | (stats,) = await api.get_rtb_stats( 192 | adv_hash, 193 | day_from, 194 | day_to, 195 | [StatsGroupBy.ADVERTISER, StatsGroupBy.DAY, StatsGroupBy.HOUR], 196 | [StatsMetric.CAMPAIGN_COST, StatsMetric.CR], 197 | ) 198 | 199 | (call,) = api_mock.calls 200 | assert dict(call.request.url.params) == { 201 | "dayFrom": "2020-09-01", 202 | "dayTo": "2020-09-01", 203 | "groupBy": "advertiser-day-hour", 204 | "metrics": "campaignCost-cr", 205 | } 206 | assert stats.advertiser == "xyz" 207 | 208 | 209 | async def test_get_summary_stats( 210 | api: AsyncClient, 211 | api_mock: respx.MockRouter, 212 | adv_hash: str, 213 | day_from: date, 214 | day_to: date, 215 | ) -> None: 216 | api_mock.get(f"/advertisers/{adv_hash}/summary-stats").respond( 217 | 200, 218 | json={"status": "ok", "data": [{"day": "2022-01-01", "advertiser": "xyz", "campaignCost": 108.0}]}, 219 | ) 220 | 221 | (stats,) = await api.get_summary_stats( 222 | adv_hash, 223 | day_from, 224 | day_to, 225 | [StatsGroupBy.ADVERTISER, StatsGroupBy.DAY], 226 | [StatsMetric.CAMPAIGN_COST, StatsMetric.CR], 227 | ) 228 | 229 | (call,) = api_mock.calls 230 | assert dict(call.request.url.params) == { 231 | "dayFrom": "2020-09-01", 232 | "dayTo": "2020-09-01", 233 | "groupBy": "advertiser-day", 234 | "metrics": "campaignCost-cr", 235 | } 236 | assert stats.advertiser == "xyz" 237 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | """Tests for auth methods.""" 2 | 3 | from typing import Union 4 | 5 | import pytest 6 | import respx 7 | 8 | from rtbhouse_sdk.client import BasicAuth, BasicTokenAuth, Client 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "auth_backend", 13 | ( 14 | BasicAuth("user", "pwd"), 15 | BasicTokenAuth("token"), 16 | ), 17 | ) 18 | def test_auth_backend_is_supported(auth_backend: Union[BasicAuth, BasicTokenAuth]) -> None: 19 | Client(auth=auth_backend) 20 | 21 | 22 | def test_basic_token_auth_flow(api_mock: respx.MockRouter) -> None: 23 | api_mock.get("/example-endpoint").respond(200, json={"data": {}}) 24 | 25 | auth = BasicTokenAuth("abc") 26 | with Client(auth=auth) as cli: 27 | cli._get("/example-endpoint") # pylint: disable=protected-access 28 | 29 | (call,) = api_mock.calls 30 | assert call.request.headers["authorization"] == "Token abc" 31 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | """Tests for client.""" 2 | 3 | # pylint: disable=too-many-arguments 4 | from collections.abc import Iterator 5 | from datetime import date 6 | from typing import Any, Optional 7 | 8 | import pytest 9 | import respx 10 | from httpx import Response 11 | 12 | from rtbhouse_sdk.client import API_VERSION, BasicAuth, Client 13 | from rtbhouse_sdk.exceptions import ApiRateLimitException, ApiVersionMismatchException 14 | from rtbhouse_sdk.schema import ( 15 | CountConvention, 16 | DeviceType, 17 | StatsGroupBy, 18 | StatsMetric, 19 | SubcampaignsFilter, 20 | UserSegment, 21 | to_camel_case, 22 | ) 23 | 24 | 25 | @pytest.fixture(name="api") 26 | def api_client() -> Iterator[Client]: 27 | with Client(auth=BasicAuth("test", "test")) as api: 28 | yield api 29 | 30 | 31 | def test_client_close() -> None: 32 | cli = Client(auth=BasicAuth("test", "test")) 33 | cli.close() 34 | 35 | 36 | def test_client_as_context_manager() -> None: 37 | with Client(auth=BasicAuth("test", "test")): 38 | pass 39 | 40 | 41 | def test_validate_response_raises_error_on_too_old_api_version(api: Client, api_mock: respx.MockRouter) -> None: 42 | newest_version = int(API_VERSION.strip("v")) + 2 43 | api_mock.get("/example-endpoint").respond(410, headers={"X-Current-Api-Version": f"v{newest_version}"}) 44 | 45 | with pytest.raises(ApiVersionMismatchException) as cm: 46 | api._get("/example-endpoint") # pylint: disable=protected-access 47 | 48 | assert cm.value.message.startswith("Unsupported api version") 49 | 50 | 51 | def test_validate_response_warns_on_not_the_newest_api_version(api: Client, api_mock: respx.MockRouter) -> None: 52 | newest_version = f'v{int(API_VERSION.strip("v")) + 1}' 53 | api_mock.get("/example-endpoint").respond(200, json={"data": {}}, headers={"X-Current-Api-Version": newest_version}) 54 | 55 | with pytest.warns(Warning) as cm: 56 | api._get("/example-endpoint") # pylint: disable=protected-access 57 | 58 | msg = ( 59 | f"Used api version ({API_VERSION}) is outdated, use newest version ({newest_version}) " 60 | f"by updating rtbhouse_sdk package." 61 | ) 62 | assert str(cm[0].message) == msg 63 | 64 | 65 | def test_validate_response_raises_error_on_resource_usage_limit_reached( 66 | api: Client, api_mock: respx.MockRouter 67 | ) -> None: 68 | header = ";".join( 69 | [ 70 | "WORKER_TIME-3600=11.78/10000000", 71 | "BQ_TB_BILLED-3600=4.62/500", 72 | "BQ_TB_BILLED-86400=17.995/5000", 73 | ] 74 | ) 75 | api_mock.get("/example-endpoint").respond(429, headers={"X-Resource-Usage": header}) 76 | 77 | with pytest.raises(ApiRateLimitException) as cm: 78 | api._get("/example-endpoint") # pylint: disable=protected-access 79 | 80 | data = cm.value.limits 81 | assert data["WORKER_TIME"]["3600"]["10000000"] == 11.78 82 | assert data["BQ_TB_BILLED"]["3600"]["500"] == 4.62 83 | assert data["BQ_TB_BILLED"]["86400"]["5000"] == 17.995 84 | 85 | 86 | def test_get_user_info( 87 | api: Client, 88 | api_mock: respx.MockRouter, 89 | user_info_response: dict[str, Any], 90 | ) -> None: 91 | api_mock.get("/user/info").respond(200, json=user_info_response) 92 | 93 | data = api.get_user_info() 94 | 95 | assert data.hash_id == "hid" 96 | 97 | 98 | def test_get_advertisers( 99 | api: Client, 100 | api_mock: respx.MockRouter, 101 | advertisers_response: dict[str, Any], 102 | ) -> None: 103 | api_mock.get("/advertisers").respond(200, json=advertisers_response) 104 | 105 | (advertiser,) = api.get_advertisers() 106 | 107 | assert advertiser.name == "Adv" 108 | 109 | 110 | def test_get_advertiser( 111 | api: Client, 112 | api_mock: respx.MockRouter, 113 | adv_hash: str, 114 | advertiser_response: dict[str, Any], 115 | ) -> None: 116 | api_mock.get(f"/advertisers/{adv_hash}").respond(200, json=advertiser_response) 117 | 118 | advertiser = api.get_advertiser(adv_hash) 119 | 120 | assert advertiser.name == "Adv" 121 | 122 | 123 | def test_get_invoicing_data( 124 | api: Client, 125 | api_mock: respx.MockRouter, 126 | adv_hash: str, 127 | invoice_data_response: dict[str, Any], 128 | ) -> None: 129 | api_mock.get(f"/advertisers/{adv_hash}/client").respond(200, json=invoice_data_response) 130 | 131 | invoice_data = api.get_invoicing_data(adv_hash) 132 | 133 | assert invoice_data.company_name == "Ltd" 134 | 135 | 136 | def test_get_offer_categories( 137 | api: Client, 138 | api_mock: respx.MockRouter, 139 | adv_hash: str, 140 | offer_categories_response: dict[str, Any], 141 | ) -> None: 142 | api_mock.get(f"/advertisers/{adv_hash}/offer-categories").respond(200, json=offer_categories_response) 143 | 144 | (offer_cat,) = api.get_offer_categories(adv_hash) 145 | 146 | assert offer_cat.name == "full cat" 147 | 148 | 149 | def test_get_offers( 150 | api: Client, 151 | api_mock: respx.MockRouter, 152 | adv_hash: str, 153 | offers_response: dict[str, Any], 154 | ) -> None: 155 | api_mock.get(f"/advertisers/{adv_hash}/offers").respond(200, json=offers_response) 156 | 157 | (offer,) = api.get_offers(adv_hash) 158 | 159 | assert offer.full_name == "FN" 160 | assert offer.images[0].width == "700" 161 | 162 | 163 | def test_get_advertiser_campaigns( 164 | api: Client, 165 | api_mock: respx.MockRouter, 166 | adv_hash: str, 167 | advertiser_campaigns_response: dict[str, Any], 168 | ) -> None: 169 | api_mock.get(f"/advertisers/{adv_hash}/campaigns").respond(200, json=advertiser_campaigns_response) 170 | 171 | (campaign,) = api.get_advertiser_campaigns(adv_hash) 172 | 173 | assert campaign.name == "Campaign" 174 | 175 | 176 | def test_get_billing( 177 | api: Client, 178 | api_mock: respx.MockRouter, 179 | adv_hash: str, 180 | day_from: date, 181 | day_to: date, 182 | billing_response: dict[str, Any], 183 | ) -> None: 184 | api_mock.get(f"/advertisers/{adv_hash}/billing").respond(200, json=billing_response) 185 | 186 | billing = api.get_billing(adv_hash, day_from, day_to) 187 | 188 | (bill,) = billing.bills 189 | assert billing.initial_balance == -100 190 | assert bill.day == date(2020, 11, 25) 191 | 192 | 193 | def test_get_rtb_creatives( 194 | api: Client, 195 | api_mock: respx.MockRouter, 196 | adv_hash: str, 197 | rtb_creatives_response: dict[str, Any], 198 | ) -> None: 199 | api_mock.get(f"/advertisers/{adv_hash}/rtb-creatives").respond(200, json=rtb_creatives_response) 200 | 201 | (rtb_creative,) = api.get_rtb_creatives(adv_hash) 202 | 203 | (call,) = api_mock.calls 204 | assert dict(call.request.url.params) == {} 205 | assert rtb_creative.hash == "hash" 206 | assert len(rtb_creative.previews) == 1 207 | 208 | 209 | @pytest.mark.parametrize( 210 | "subcampaigns,active_only,params", 211 | [ 212 | ( 213 | ["abc", "def"], 214 | True, 215 | { 216 | "subcampaigns": "abc-def", 217 | "activeOnly": "true", 218 | }, 219 | ), 220 | ( 221 | SubcampaignsFilter.ACTIVE, 222 | False, 223 | { 224 | "subcampaigns": "ACTIVE", 225 | "activeOnly": "false", 226 | }, 227 | ), 228 | ( 229 | SubcampaignsFilter.ANY, 230 | None, 231 | { 232 | "subcampaigns": "ANY", 233 | }, 234 | ), 235 | ], 236 | ) 237 | def test_get_rtb_creatives_with_extra_params( 238 | api: Client, 239 | api_mock: respx.MockRouter, 240 | adv_hash: str, 241 | subcampaigns: SubcampaignsFilter, 242 | active_only: Optional[bool], 243 | params: dict[str, str], 244 | ) -> None: 245 | api_mock.get(f"/advertisers/{adv_hash}/rtb-creatives").respond(200, json={"status": "ok", "data": []}) 246 | 247 | api.get_rtb_creatives(adv_hash, subcampaigns=subcampaigns, active_only=active_only) 248 | 249 | (call,) = api_mock.calls 250 | assert dict(call.request.url.params) == params 251 | 252 | 253 | def test_get_rtb_conversions( 254 | api: Client, 255 | api_mock: respx.MockRouter, 256 | adv_hash: str, 257 | day_from: date, 258 | day_to: date, 259 | conversions_with_next_cursor_response: dict[str, Any], 260 | conversions_without_next_cursor_response: dict[str, Any], 261 | ) -> None: 262 | api_mock.get(f"/advertisers/{adv_hash}/conversions").mock( 263 | side_effect=[ 264 | Response(200, json=conversions_with_next_cursor_response), 265 | Response(200, json=conversions_without_next_cursor_response), 266 | ] 267 | ) 268 | 269 | conversions = list(api.get_rtb_conversions(adv_hash, day_from, day_to)) 270 | 271 | call1, call2 = api_mock.calls 272 | assert set(call1.request.url.params.keys()) == {"dayFrom", "dayTo", "countConvention", "limit"} 273 | assert set(call2.request.url.params.keys()) == {"dayFrom", "dayTo", "countConvention", "limit", "nextCursor"} 274 | assert len(conversions) == 6 275 | assert conversions[0].conversion_hash == "chash" 276 | 277 | 278 | def test_get_rtb_stats( 279 | api: Client, 280 | api_mock: respx.MockRouter, 281 | adv_hash: str, 282 | day_from: date, 283 | day_to: date, 284 | ) -> None: 285 | api_mock.get(f"/advertisers/{adv_hash}/rtb-stats").respond( 286 | 200, 287 | json={"status": "ok", "data": [{"day": "2022-01-01", "advertiser": "xyz", "campaignCost": 51.0}]}, 288 | ) 289 | 290 | (stats,) = api.get_rtb_stats( 291 | adv_hash, 292 | day_from, 293 | day_to, 294 | [StatsGroupBy.ADVERTISER, StatsGroupBy.DAY], 295 | [StatsMetric.CAMPAIGN_COST, StatsMetric.CR], 296 | ) 297 | 298 | (call,) = api_mock.calls 299 | assert dict(call.request.url.params) == { 300 | "dayFrom": "2020-09-01", 301 | "dayTo": "2020-09-01", 302 | "groupBy": "advertiser-day", 303 | "metrics": "campaignCost-cr", 304 | } 305 | assert stats.advertiser == "xyz" 306 | assert stats.campaign_cost == 51.0 307 | 308 | 309 | @pytest.mark.parametrize( 310 | "param,value,query_value", 311 | [ 312 | ("count_convention", CountConvention.ATTRIBUTED_POST_CLICK, "ATTRIBUTED"), 313 | ("subcampaigns", ["hash1", "hash2"], "hash1-hash2"), 314 | ("user_segments", [UserSegment.BUYERS, UserSegment.SHOPPERS], "BUYERS-SHOPPERS"), 315 | ("device_types", [DeviceType.PC, DeviceType.MOBILE], "PC-MOBILE"), 316 | ], 317 | ) 318 | def test_get_rtb_stats_extra_params( 319 | api: Client, 320 | api_mock: respx.MockRouter, 321 | adv_hash: str, 322 | day_from: date, 323 | day_to: date, 324 | param: str, 325 | value: Any, 326 | query_value: str, 327 | ) -> None: 328 | api_mock.get(f"/advertisers/{adv_hash}/rtb-stats").respond( 329 | 200, 330 | json={"status": "ok", "data": []}, 331 | ) 332 | 333 | extra_params = {param: value} 334 | list( 335 | api.get_rtb_stats( 336 | adv_hash, day_from, day_to, [StatsGroupBy.ADVERTISER], [StatsMetric.CAMPAIGN_COST], **extra_params 337 | ) 338 | ) 339 | 340 | (call,) = api_mock.calls 341 | assert call.request.url.params[to_camel_case(param)] == query_value 342 | 343 | 344 | def test_get_summary_stats( 345 | api: Client, 346 | api_mock: respx.MockRouter, 347 | adv_hash: str, 348 | day_from: date, 349 | day_to: date, 350 | ) -> None: 351 | api_mock.get(f"/advertisers/{adv_hash}/summary-stats").respond( 352 | 200, 353 | json={"status": "ok", "data": [{"day": "2022-01-01", "advertiser": "xyz", "campaignCost": 108.0}]}, 354 | ) 355 | 356 | (stats,) = api.get_summary_stats( 357 | adv_hash, 358 | day_from, 359 | day_to, 360 | [StatsGroupBy.ADVERTISER, StatsGroupBy.DAY], 361 | [StatsMetric.CAMPAIGN_COST, StatsMetric.CR], 362 | ) 363 | 364 | (call,) = api_mock.calls 365 | assert dict(call.request.url.params) == { 366 | "dayFrom": "2020-09-01", 367 | "dayTo": "2020-09-01", 368 | "groupBy": "advertiser-day", 369 | "metrics": "campaignCost-cr", 370 | } 371 | assert stats.advertiser == "xyz" 372 | 373 | 374 | @pytest.mark.parametrize( 375 | "param,value,query_value", 376 | [ 377 | ("count_convention", CountConvention.ATTRIBUTED_POST_CLICK, "ATTRIBUTED"), 378 | ("subcampaigns", ["hash1", "hash2"], "hash1-hash2"), 379 | ], 380 | ) 381 | def test_get_summary_stats_extra_params( 382 | api: Client, 383 | api_mock: respx.MockRouter, 384 | adv_hash: str, 385 | day_from: date, 386 | day_to: date, 387 | param: str, 388 | value: Any, 389 | query_value: str, 390 | ) -> None: 391 | api_mock.get(f"/advertisers/{adv_hash}/summary-stats").respond( 392 | 200, 393 | json={"status": "ok", "data": []}, 394 | ) 395 | 396 | extra_params = {param: value} 397 | api.get_summary_stats( 398 | adv_hash, 399 | day_from, 400 | day_to, 401 | [StatsGroupBy.ADVERTISER, StatsGroupBy.DAY], 402 | [StatsMetric.CAMPAIGN_COST, StatsMetric.CR], 403 | **extra_params, 404 | ) 405 | 406 | (call,) = api_mock.calls 407 | assert call.request.url.params[to_camel_case(param)] == query_value 408 | -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | """Tests for schema and it's utilities.""" 2 | 3 | from itertools import chain 4 | 5 | from rtbhouse_sdk._utils import underscore 6 | from rtbhouse_sdk.schema import Stats, StatsGroupBy, StatsMetric 7 | 8 | 9 | def test_stats_schema_is_up_to_date() -> None: 10 | """In case Metric or GroupBy gets updated we need to update Stats as well.""" 11 | metric_plus_groupby_fields = {underscore(f) for f in chain(StatsMetric, StatsGroupBy)} 12 | stats_fields = set(Stats.model_json_schema(False).get("properties").keys()) # type: ignore 13 | assert metric_plus_groupby_fields < stats_fields, "`Stats` schema needs an update" 14 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for utilities""" 2 | 3 | import pytest 4 | 5 | from rtbhouse_sdk._utils import camelize, underscore 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ["input_string", "uppercase_first_letter", "expected_result"], 10 | [ 11 | ("FooBar", True, "FooBar"), 12 | ("FooBar", False, "fooBar"), 13 | ("foo_bar", True, "FooBar"), 14 | ("foo_bar", False, "fooBar"), 15 | ("foo bar", True, "Foo bar"), 16 | ("foo bar", False, "foo bar"), 17 | ("a1b2", True, "A1b2"), 18 | ("FOOBAR", True, "FOOBAR"), 19 | ("foobar", False, "foobar"), 20 | ("FOOBAR", False, "fOOBAR"), 21 | ("_foo_bar_", True, "_fooBar_"), 22 | ("_foo_bar_", False, "_fooBar_"), 23 | ("___foo___bar___", True, "__foo_Bar__"), 24 | ("___foo___bar___", False, "__foo_Bar__"), 25 | ("_", True, "_"), 26 | ("_", False, "_"), 27 | ("", True, ""), 28 | ("", False, ""), 29 | ("ab_12_c2__d", True, "Ab12C2_d"), 30 | ( 31 | "ThisIsOne___messed up string. Can we Really camel-case It ?##", 32 | True, 33 | "ThisIsOne_Messed up string. Can we Really camel-case It ?##", 34 | ), 35 | ( 36 | "ThisIsOne___messed up string. Can we Really camel-case It ?##", 37 | False, 38 | "thisIsOne_Messed up string. Can we Really camel-case It ?##", 39 | ), 40 | ], 41 | ) 42 | def test_camelize(input_string: str, uppercase_first_letter: bool, expected_result: str) -> None: 43 | assert camelize(input_string, uppercase_first_letter=uppercase_first_letter) == expected_result 44 | 45 | 46 | @pytest.mark.parametrize( 47 | ["input_string", "expected_result"], 48 | [ 49 | ("FooBar", "foo_bar"), 50 | ("foo_bar", "foo_bar"), 51 | ("Foo bar", "foo bar"), 52 | ], 53 | ) 54 | def test_underscore(input_string: str, expected_result: str) -> None: 55 | assert underscore(input_string) == expected_result 56 | --------------------------------------------------------------------------------