├── .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 |
--------------------------------------------------------------------------------