From c57c79d4cd03355b8f749d10a9eb720d918e767d Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Tue, 5 May 2026 17:53:24 +0300 Subject: [PATCH 01/26] =?UTF-8?q?=D0=94=D0=B5=D0=BB=D0=B0=D1=8E=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B8=20async=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 8 +- todo.md | 515 ++++++++++++++++++++++++++++++++++++ 2 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 todo.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2277b12..a27daf9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -53,7 +53,13 @@ "Bash(grep -E \"\\\\.json$|\\\\.lock$|todo\\\\.md|action_plan\\\\.md|usability_scorecard\\\\.md\")", "Bash(echo \"---EXISTS:$?\")", "Bash(git mv *)", - "Bash(make swagger-lint *)" + "Bash(make swagger-lint *)", + "Bash(git -C /Users/n.baryshnikov/Projects/avito_python_api branch -a)", + "Bash(git -C /Users/n.baryshnikov/Projects/avito_python_api log --oneline async..HEAD)", + "Bash(grep -v \":0$\")", + "Bash(grep -E \"\\\\\\\\.py$\")", + "Bash(git -C /Users/n.baryshnikov/Projects/avito_python_api log --oneline async..main)", + "Bash(git -C /Users/n.baryshnikov/Projects/avito_python_api log --oneline main..async)" ] } } diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..04657ad --- /dev/null +++ b/todo.md @@ -0,0 +1,515 @@ +# Двухрежимный SDK (sync + async) + +## Контекст + +SDK сейчас полностью синхронный: `AvitoClient` → `Transport` (`httpx.Client` + `time.sleep`) → +`AuthProvider` (`TokenClient` поверх sync-transport) → `DomainObject` подклассы (12 пакетов, +~204 swagger-операций) → `PaginatedList[T]` (наследник `list`). Цель — добавить вторую, +асинхронную, поверхность по образцу `httpx.Client`/`httpx.AsyncClient`, без слома sync-API, +с переиспользованием `OperationSpec`, моделей, request/query DTO, swagger-инвариантов и +ошибок. + +## Принятые решения + +| Вопрос | Решение | +|---|---| +| Стиль | Параллельные классы вручную: рядом с каждым sync-слоем кладём `Async*` класс. Codegen не используем. | +| Размещение | `avito//async_domain.py` рядом с `domain.py`. | +| Swagger-binding | `@swagger_operation(..., variant="sync"\|"async")`. Уникальный ключ линтера — `(operation_key, variant)`. | +| Sequencing | M1 — фундамент с тестами; M2…M13 — порт каждого домена отдельным PR. До появления первого `AsyncX` класса strict-coverage по `variant="async"` пуст и не падает. | +| Pagination | `AsyncPaginatedList[ItemT]` — отдельный класс (не наследник `list`), без list-API parity (только `__aiter__` / `materialize` / `loaded_count` / `is_materialized` / `known_total` / `source_total`). | + +## Архитектура: что общее, что дублируем + +``` + ┌────────── shared (без изменений по семантике) ─────────────┐ + │ │ + │ OperationSpec, models, request/query DTO, ApiTimeouts, │ + │ RequestContext, JsonPage, exceptions, RetryPolicy, │ + │ RateLimiter (логика "ждать сколько"), retries.RetryDecision│ + │ │ + └─────────────────────┬──────────────────────────────────────┘ + │ используется обоими + ┌───────────────┴───────────────┐ + ▼ ▼ + ┌──────── SYNC (как есть) ───┐ ┌──────── ASYNC (новое) ─────┐ + │ Transport │ │ AsyncTransport │ + │ ↓ httpx.Client │ │ ↓ httpx.AsyncClient │ + │ ↓ time.sleep │ │ ↓ asyncio.sleep │ + │ OperationExecutor │ │ AsyncOperationExecutor │ + │ AuthProvider/TokenClient │ │ AsyncAuthProvider/ │ + │ │ │ AsyncTokenClient/ │ + │ │ │ AsyncAlternateTokenClient│ + │ PaginatedList[T] (list-sub)│ │ AsyncPaginatedList[T] │ + │ DomainObject │ │ AsyncDomainObject │ + │ ├─ Account │ │ ├─ AsyncAccount │ + │ ├─ Ad … │ │ ├─ AsyncAd … │ + │ AvitoClient │ │ AsyncAvitoClient │ + └────────────────────────────┘ └────────────────────────────┘ + + Swagger binding: variant="sync" variant="async" + ↓ ↓ + swagger_discovery + linter + (per-variant ключи) +``` + +Чтобы не разойтись retry-логике и маппингу ошибок, выносим в `avito/core/_transport_shared.py` +IO-agnostic вычисления (без httpx-вызова и sleep): `_decide_transport_retry`, +`_decide_http_retry`, `_is_retryable_request`, `_get_retry_after_seconds`, `_map_http_error`, +`_safe_payload`, `_extract_message`, `_extract_error_code`, `_extract_error_details`, +`_extract_request_id`, `_normalize_path`, `_normalize_params`, `_normalize_files`, +`_merge_headers`, `_build_user_agent`, `_extract_filename`, `build_httpx_timeout`, +`_safe_endpoint`. `Transport` и `AsyncTransport` остаются тонкими обёртками с двумя различиями: +формой sleep и формой client.request. + +Аналогично: `avito/auth/_cache.py` содержит in-memory state (поля `_access_token`, +`_refresh_token`, `_autoteka_access_token`) и чистые helpers (`_is_token_fresh`, +`_map_token_response` уже в provider.py — переедет туда). `AuthProvider` и `AsyncAuthProvider` +делегируют кешу, сами добавляют только sync/async lock + IO. + +### Порядок зависимостей в M1 + +``` + Phase 1 _transport_shared.py ◀── рефактор Transport (без поведенческих изменений) + _cache.py ◀── рефактор AuthProvider (без поведенческих изменений) + ↓ + Phase 2 AsyncTransport, AsyncOperationTransport, AsyncOperationExecutor + AsyncAuthProvider, AsyncTokenClient, AsyncAlternateTokenClient + AsyncPaginatedList + ↓ + Phase 3 variant="async" в swagger декораторе/discovery/linter + AsyncAvitoClient (без factory-методов; только lifecycle) + tests/async_fake_transport.py + ↓ + Phase 4 тесты + docs +``` + +## Ключевые файлы и точки соединения + +### Существующие, изменяются в M1 + +| Файл | Что меняем | +|---|---| +| `avito/core/transport.py` | Извлекаем IO-agnostic helpers в `_transport_shared.py` и переиспользуем. Поведение sync — без изменений. | +| `avito/core/operations.py` | + `AsyncOperationTransport` (Protocol, async зеркало `OperationTransport`), + `AsyncOperationExecutor` (async зеркало `OperationExecutor.execute`). Helpers `render_path`, `_serialize_query`, `_serialize_request`, `_merge_content_type`, `_extract_filename` уже module-level — переиспользуем без копий. | +| `avito/core/swagger.py` | + поле `variant: Literal["sync","async"] = "sync"` в `SwaggerOperationBinding`. + параметр `variant` в `swagger_operation(...)`. Ошибка `ConfigurationError` при двойном декоре одной функции — без изменений. | +| `avito/core/swagger_discovery.py` | `_iter_domain_modules` дополнительно ищет `.async_domain` (рядом с `.domain`). `DiscoveredSwaggerBinding` получает `variant`. `canonical_map` — ключ `(operation_key, variant)`. | +| `avito/core/swagger_linter.py` | `_validate_duplicate_bindings` группирует по `(operation_key, variant)`. `_validate_complete_bindings` запускается per-variant; для `variant="async"` ожидаемое множество ограничено доменами, у которых уже найден `Async*` класс (class-gated coverage). `_validate_no_unbound_operation_specs` остаётся по `OperationSpec` (sync OperationSpec реюзается обоими режимами — счётчик использований единый). | +| `avito/auth/provider.py` | Извлекаем shared cache state в `_cache.py`. Сам `AuthProvider` остаётся sync. | +| `avito/__init__.py` | + экспорт `AsyncAvitoClient`. | +| `avito/core/__init__.py` | + экспорт `AsyncTransport`, `AsyncOperationExecutor`, `AsyncOperationTransport`, `AsyncPaginatedList`. | +| `avito/core/domain.py` | + `AsyncDomainObject` с async `_execute` и async `_resolve_user_id`. Sync `DomainObject` — без изменений. | +| `pyproject.toml` | + `pytest-asyncio = "^0.24"` в dev-deps. + `[tool.pytest.ini_options] asyncio_mode = "strict"`. | +| `Makefile` | Без новых целей; `make check` после M1 должен оставаться зелёным. | +| `scripts/lint_architecture.py` | `LEGACY_FILENAMES` не трогаем (там `client.py`, `mappers.py`, `enums.py` — `async_domain.py` не пересекается). | +| `scripts/lint_swagger_bindings.py` | Без изменений в CLI (логика вынесена в `swagger_linter.py`). | +| `docs/site/explanations/swagger-binding-subsystem.md` | Раздел про `variant` и class-gated coverage. | +| `docs/site/explanations/domain-architecture-v2.md` | Параграф про `async_domain.py` как разрешённый файл, парный к `domain.py`. | + +### Новые файлы (M1) + +``` +avito/core/_transport_shared.py # IO-agnostic helpers, retry/error mapping/headers +avito/core/async_transport.py # AsyncTransport (httpx.AsyncClient) +avito/core/async_pagination.py # AsyncPaginatedList, AsyncPaginator, AsyncPageFetcher +avito/auth/_cache.py # TokenCache + _map_token_response +avito/auth/async_provider.py # AsyncAuthProvider (asyncio.Lock на refresh) +avito/auth/async_token_client.py # AsyncTokenClient, AsyncAlternateTokenClient + # (со @swagger_operation(..., variant="async")) +avito/async_client.py # AsyncAvitoClient (lifecycle + factory-методы пустые в M1) +avito/testing/async_fake_transport.py # AsyncFakeTransport (httpx.MockTransport+AsyncClient) +tests/async_fake_transport.py # тонкий re-export с DeprecationWarning (как у sync) +tests/core/test_async_transport.py +tests/core/test_async_pagination.py +tests/core/test_async_executor.py +tests/core/test_async_client_lifecycle.py +tests/auth/test_async_provider.py +tests/contracts/test_async_parity.py # инвариант "Async ↔ X" для всех портированных доменов +``` + +### Новые файлы (M2…M13, на каждый домен) + +``` +avito//async_domain.py +tests/domains//test__async.py +``` + +## Контракты новых классов + +### `avito/core/async_transport.py` + +```python +class AsyncTransport: + def __init__( + self, + settings: AvitoSettings, + *, + auth_provider: AsyncAuthProvider | None = None, + client: httpx.AsyncClient | None = None, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + ) -> None: ... + + async def request(self, method, path, *, context, params=None, json_body=None, + data=None, files=None, headers=None, content=None, + idempotency_key=None) -> httpx.Response: ... + async def request_json(...) -> object: ... + async def download_binary(...) -> BinaryResponse: ... + async def aclose(self) -> None: ... + async def __aenter__(self) -> AsyncTransport: ... + async def __aexit__(self, *exc) -> None: ... + @property + def auth_provider(self) -> AsyncAuthProvider | None: ... + def debug_info(self) -> TransportDebugInfo: ... +``` + +Реализует `AsyncOperationTransport` (Protocol, async-зеркало `OperationTransport` из +`avito/core/operations.py`). + +### `avito/core/operations.py` (расширение) + +```python +class AsyncOperationTransport(Protocol): + async def request(...) -> httpx.Response: ... + async def request_json(...) -> object: ... + +class AsyncOperationExecutor: + def __init__(self, transport: AsyncOperationTransport) -> None: ... + async def execute[ResponseT](self, spec: OperationSpec[ResponseT], *, + path_params=None, query=None, request=None, + headers=None, idempotency_key=None, + data=None, files=None, timeout=None, + retry=None) -> ResponseT: ... +``` + +`render_path`, `_serialize_query`, `_serialize_request`, `_merge_content_type`, +`_extract_filename` — общие, переиспользуются обоими executor'ами без копирования. + +### `avito/core/domain.py` (расширение) + +```python +@dataclass(slots=True, frozen=True) +class AsyncDomainObject: + transport: AsyncTransport + + async def _execute[ResponseT](self, spec: OperationSpec[ResponseT], *, + path_params=..., query=..., request=..., + headers=..., idempotency_key=..., data=..., + files=..., timeout=..., retry=...) -> ResponseT: ... + async def _resolve_user_id(self, user_id: int | str | None = None) -> int: ... +``` + +Async-двойник sync-`DomainObject._resolve_user_id`: тот же fallback-порядок (аргумент → +`AvitoSettings.user_id` → `await self.transport.request_json("GET", "/core/v1/accounts/self")`). + +### `avito/core/async_pagination.py` + +```python +class AsyncPaginatedList[ItemT]: + def __init__(self, fetch_page: AsyncPageFetcher[ItemT], *, + start_page: int = 1, + first_page: JsonPage[ItemT] | None = None) -> None: ... + def __aiter__(self) -> AsyncIterator[ItemT]: ... + async def materialize(self) -> list[ItemT]: ... + async def aload_until(self, index: int) -> None: ... + @property + def loaded_count(self) -> int: ... + @property + def known_total(self) -> int | None: ... + @property + def source_total(self) -> int | None: ... + @property + def is_materialized(self) -> bool: ... + +type AsyncPageFetcher[ItemT] = Callable[[int | None, str | None], + Awaitable[JsonPage[ItemT]]] +``` + +`AsyncPaginatedList` **не** наследует `list[T]` — async-итерация и list-индексация +несовместимы. Документируем это явно в docstring и в `pagination` how-to. Семантика +страничного перехода идентична sync `PaginatedList._consume_page` (включая `next_cursor`, +`page+per_page`, `has_next_page`). + +### `avito/auth/_cache.py` + +```python +@dataclass(slots=True) +class TokenCache: + access_token: AccessToken | None = None + refresh_token: str | None = None + autoteka_access_token: AccessToken | None = None + def access_is_fresh(self, now: datetime) -> bool: ... + def autoteka_is_fresh(self, now: datetime) -> bool: ... + def reset_access(self) -> None: ... + def reset_autoteka(self) -> None: ... + +def map_token_response(payload: object, *, now: datetime | None = None) -> TokenResponse: ... +``` + +`AuthProvider` и `AsyncAuthProvider` хранят `TokenCache` и используют общий `map_token_response`. + +### `avito/auth/async_provider.py` + +```python +@dataclass(slots=True) +class AsyncAuthProvider: + settings: AuthSettings + token_client: AsyncTokenClient | None = None + alternate_token_client: AsyncAlternateTokenClient | None = None + autoteka_token_client: AsyncTokenClient | None = None + token_fetcher: AsyncTokenFetcher | None = None + _cache: TokenCache = field(default_factory=TokenCache, init=False, repr=False) + _refresh_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False) + + async def get_access_token(self) -> str: ... # double-checked + asyncio.Lock + async def refresh_access_token(self) -> TokenResponse: ... + def invalidate_token(self) -> None: ... # неблокирующая операция + async def aclose(self) -> None: ... + async def get_autoteka_access_token(self) -> str: ... +``` + +### `avito/auth/async_token_client.py` + +```python +@dataclass(slots=True, frozen=True) +class AsyncTokenClient: + __swagger_domain__ = "auth" + settings: AuthSettings + token_url: str | None = None + client: httpx.AsyncClient | None = None + sdk_settings: AvitoSettings | None = None + + async def aclose(self) -> None: ... + + @swagger_operation("POST", "/token", spec="Авторизация.json", + operation_id="getAccessToken", + method_args={"request": "body"}, + variant="async") + async def request_client_credentials_token(self, request) -> TokenResponse: ... + + @swagger_operation("POST", "/token", spec="Автотека.json", + operation_id="getAccessToken", + method_args={"request": "query.grant_type"}, + variant="async") + async def request_autoteka_client_credentials_token(self, request) -> TokenResponse: ... + + async def request_refresh_token(self, request) -> TokenResponse: ... # без binding (sync тоже без) +``` + +`AsyncAlternateTokenClient` — зеркало sync-аналога с `variant="async"` на двух методах +(`getAccessTokenAuthorizationCode`, `refreshAccessTokenAuthorizationCode`). + +`avito/core/swagger_discovery.py._NON_DOMAIN_BINDING_MODULES` дополняем +`"avito.auth.async_provider"` (или `async_token_client`, в зависимости от того, где живут +классы) — иначе async-bindings auth-домена не попадут в discovery. + +### `avito/async_client.py` + +```python +class AsyncAvitoClient: + def __init__(self, settings: AvitoSettings | None = None, *, + client_id: str | None = None, + client_secret: str | None = None, + http_client: httpx.AsyncClient | None = None) -> None: ... + + @classmethod + def from_env(cls, *, env_file=...) -> AsyncAvitoClient: ... + @classmethod + def _from_transport(cls, settings, *, transport, auth_provider) -> AsyncAvitoClient: ... + + @property + def settings(self) -> AvitoSettings: ... + @property + def auth_provider(self) -> AsyncAuthProvider: ... + @property + def transport(self) -> AsyncTransport: ... + + async def aclose(self) -> None: ... + async def __aenter__(self) -> AsyncAvitoClient: ... + async def __aexit__(self, *exc) -> None: ... + + # M2+ постепенно добавляются (один factory на этап): + # def account(self, user_id=None) -> AsyncAccount: ... + # ... +``` + +В M1 `AsyncAvitoClient` без factory-методов — только lifecycle и smoke-вызов через сырой +`transport.request_json(...)` в тесте. **Convenience методы `account_health`, +`business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, +`promotion_summary`, `capabilities`** на `AsyncAvitoClient` — отдельный (последний) +этап M-final, потому что они комбинируют 5+ доменов и не нужны до того, как все домены +портированы. + +### `avito/testing/async_fake_transport.py` + +```python +class AsyncFakeTransport: + def __init__(self, *, base_url: str = "https://api.avito.ru") -> None: ... + def add(self, method, path, *responses) -> AsyncFakeTransport: ... + def add_json(self, method, path, payload, *, status_code=200, headers=None) -> AsyncFakeTransport: ... + def build(self, *, retry_policy=None, user_id=None) -> AsyncTransport: ... + def as_client(self, *, user_id=None, retry_policy=None) -> AsyncAvitoClient: ... + def count(self, *, method=None, path=None) -> int: ... + def last(self, *, method=None, path=None) -> RecordedRequest: ... + requests: list[RecordedRequest] +``` + +Зеркало sync `FakeTransport` (`avito/testing/fake_transport.py`). Использует +`httpx.MockTransport(self._handle)` поверх `httpx.AsyncClient`. `RecordedRequest`, +`JsonValue`, `json_response`, `route_sequence` — переиспользуем без копий из sync. +`sleep` — `lambda _: asyncio.sleep(0)`. + +## Swagger binding — детали изменений + +1. `SwaggerOperationBinding` (`avito/core/swagger.py`): + - `variant: Literal["sync","async"] = "sync"` (frozen field, нормализация в `__post_init__` не нужна). + - Декоратор `swagger_operation(..., variant: Literal["sync","async"] = "sync")`. + - Двойной декор одной функции остаётся `ConfigurationError`. + +2. `DiscoveredSwaggerBinding` (`avito/core/swagger_discovery.py`): + - `variant: Literal["sync","async"]` копируется из `SwaggerOperationBinding`. + - `_iter_domain_modules` ищет в каждом пакете оба модуля: `.domain` и `.async_domain`. Если `async_domain` нет — игнорируем (это нормальная стадия миграции). + - `canonical_map` — ключ `f"{operation_key}\t{variant}"` (или вложенный mapping). + +3. `swagger_linter.py`: + - `_validate_single_binding_per_sdk_method` — без изменений: ключ `binding.sdk_method` уникален даже в async (т.к. `module.class.method` отличается). + - `_validate_duplicate_bindings` — ключ `(operation_key, variant)` вместо `operation_key`. Допустимо иметь две независимые цепочки (sync + async) на одну swagger-операцию. + - `_validate_complete_bindings(operations, bindings)` → `_validate_complete_bindings(operations, bindings, variant)`. Запускается дважды: + - для `variant="sync"`: ожидаемое множество = все `operations` (как сейчас). + - для `variant="async"`: ожидаемое множество = только операции из доменов, у которых найден хотя бы один `Async*` discovery binding (class-gated). Помимо `_API_DOMAINS`, для `domain == "auth"` берём операции из `Авторизация.json` и `Автотека.json`, если найден `AsyncTokenClient` / `AsyncAlternateTokenClient`. + - `_validate_operation_spec_coverage` — без изменений (sync OperationSpec — единый источник истины для обоих режимов; реюз спеки между sync и async-методами не запрещён). + - `_validate_json_body_model_coverage` — без изменений (контрактные схемы общие). + +4. `tests/contracts/test_async_parity.py` — новый тест, проверяет для каждого Async-класса: + - имя `Async` ↔ существует sync `` в том же пакете; + - множество публичных async-методов (`async def` без префикса `_`) совпадает с sync-методами; + - для каждой пары `(sync_method, async_method)`: + - `inspect.signature(sync).parameters` (без `self`) == `inspect.signature(async).parameters`; + - аннотация возврата либо совпадает, либо `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`; + - оба декорированы `@swagger_operation` на ту же `(spec, method, path, operation_id)`, отличаясь только `variant`. + +## Этапы + +### M1 — Фундамент (1 PR) + +DoD: +- [ ] `make check` зелёный: test, typecheck (mypy strict), lint (ruff), swagger-lint --strict, architecture-lint, docstring-lint, build. +- [ ] Покрытие тестами фундамента не ниже sync-аналогов (sample проверка по `coverage report`). +- [ ] Smoke-тест: `AsyncAvitoClient` через `AsyncFakeTransport` + respx делает один авторизованный запрос; токен рефрешится после 401; retry на 429 срабатывает; `Idempotency-Key` пробрасывается. +- [ ] Документация `swagger-binding-subsystem.md` отражает variant и class-gated coverage. +- [ ] Публичная sync-поверхность не изменилась (тесты sync без правок проходят). + +### M2…M13 — Этапы по доменам (по PR на домен) + +Порядок (нарастающая сложность; пилот на самом простом): + +| # | Домен | Sync-методов с binding | Особенности | +|---|---|---|---| +| M2 | `tariffs` | 1 | пилот — обкатка шаблона | +| M3 | `ratings` | 4 | без пагинации | +| M4 | `accounts` | 8 | первая `AsyncPaginatedList` (`get_operations_history`, `list_items_by_employee`); async `_resolve_account_user_id` | +| M5 | `realty` | 7 | без пагинации | +| M6 | `cpa` | 14 | без пагинации | +| M7 | `messenger` | 18 | без пагинации | +| M8 | `jobs` | 25 | webhook-методы (REST) | +| M9 | `promotion` | 24 | без пагинации | +| M10 | `autoteka` | 26 | использует autoteka token flow → проверить `AsyncAuthProvider.get_autoteka_access_token` | +| M11 | `ads` | 28 | вторая и третья `AsyncPaginatedList` (`Ad.list`, `AutoloadProfile`/`AutoloadReport.list`) | +| M12 | `orders` | 45 | самый большой | +| M13 | M-final | — | convenience-методы `AsyncAvitoClient` (`account_health`, `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`); финальный hardening; `docs/site/how-to/async.md`; CHANGELOG → 2.1.0 | + +Содержимое каждого M2…M12: + +1. `avito//async_domain.py` с `Async(AsyncDomainObject)` для каждого sync-``. + Импортирует те же `OperationSpec` из `avito//operations.py`. +2. Каждый публичный метод декорируется `@swagger_operation(..., variant="async")` теми же + аргументами `(method, path, spec, operation_id, factory, factory_args, method_args, + deprecated, legacy)`, что и sync. +3. Регистрация `Async` в `AsyncAvitoClient` (factory-метод по имени, идентичному sync). +4. `tests/domains//test__async.py` — зеркало + `tests/domains//test_.py`, через `AsyncFakeTransport`. Тесты помечаем + `@pytest.mark.asyncio`. +5. Если в домене есть пагинация — соответствующие методы возвращают `AsyncPaginatedList[T]`. +6. `docs/site/reference/.md` дополняется async-секцией (или второй колонкой). +7. `make check` после этапа: swagger-lint --strict теперь требует async-coverage 1:1 для + этого домена (class-gated rule увидит свежий `Async` класс). +8. `tests/contracts/test_async_parity.py` зелёный для всех уже портированных доменов. + +### Definition of done на каждом этапе + +- [ ] Все sync-методы домена имеют async-двойников (parity-тест зелёный). +- [ ] Все async-методы покрыты тестами с теми же сценариями, что sync (golden path, + ошибки 401/403/422/429, пагинация если есть, idempotency для write-методов, + `dry_run` если есть). +- [ ] `make check` зелёный. +- [ ] Документация и `make docs-strict` зелёные. + +## Верификация (как проверить, что план сработал) + +### M1 +```bash +poetry install +make test # sync + новые async unit-тесты +make typecheck # mypy strict — все Awaitable[T], AsyncPaginatedList[T] корректны +make lint # ruff +make swagger-lint # 1) sync coverage 1:1 как сейчас; 2) async coverage пуст и не падает +make check # финальный гейт +poetry run pytest tests/core/test_async_transport.py tests/core/test_async_pagination.py \ + tests/core/test_async_executor.py tests/core/test_async_client_lifecycle.py \ + tests/auth/test_async_provider.py tests/contracts/test_async_parity.py +``` + +Ручной smoke (M1, в тесте — не на проде): +```python +import asyncio, httpx, respx +from avito.async_client import AsyncAvitoClient +from avito.config import AvitoSettings +from avito.auth.settings import AuthSettings + +async def main(): + async with AsyncAvitoClient(AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(client_id="x", client_secret="y"), + )) as client: + with respx.mock(base_url="https://api.avito.ru") as mock: + mock.post("/token").respond(json={"access_token":"t","expires_in":3600}) + mock.get("/core/v1/accounts/self").respond(json={"id": 1}) + payload = await client.transport.request_json( + "GET", "/core/v1/accounts/self", + context=RequestContext("smoke"), + ) + assert payload == {"id": 1} + +asyncio.run(main()) +``` + +### Каждый M2…M12 +```bash +poetry run pytest tests/domains// # sync + async +poetry run pytest tests/contracts/test_async_parity.py # инвариант parity +make swagger-lint # async-coverage 1:1 для этого домена +make check +``` + +### M-final +```bash +make check +make docs-strict +poetry run pytest # полный набор +``` + +После M-final: +- swagger-lint --strict требует обоюдное 1:1 покрытие (sync + async) для всех 12 доменов и + auth-bindings; +- `tests/contracts/test_async_parity.py` зелёный для всех доменов; +- релиз 2.1.0 с CHANGELOG: «двухрежимный SDK, AsyncAvitoClient». + +## Риски и mitigations + +| Риск | Mitigation | +|---|---| +| Расхождение retry/auth-логики sync vs async | Вся не-IO логика — в `_transport_shared.py` и `_cache.py`, обе обёртки делегируют. | +| `AsyncPaginatedList` не наследует `list` → ломаются ожидания сервисов | Документируем в docstring; parity-test допускает `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`. List-API не реплицируется намеренно. | +| Auth-bindings не попадают в async-coverage | `_NON_DOMAIN_BINDING_MODULES` дополнен async-модулем; class-gated coverage гейтится по присутствию `AsyncTokenClient`/`AsyncAlternateTokenClient`. | +| Двойной декор одной функции | Текущая защита `__swagger_binding__` остаётся; sync и async — разные функции. | +| Гонка на refresh-токене в async | `asyncio.Lock` в `AsyncAuthProvider` + double-checked pattern (как sync, но через `await`). | +| Convenience-методы (`account_health`, …) расходятся между sync/async | Делаем их в M-final, когда все домены уже портированы; реализация буквально awaits то же, что sync вызывает напрямую. | From b5ec0f73506fb2edccd177c8d3ac5b9647fd8892 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Wed, 6 May 2026 14:24:29 +0300 Subject: [PATCH 02/26] =?UTF-8?q?=D0=94=D0=B5=D0=BB=D0=B0=D1=8E=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B8=20async=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- todo.md | 231 ++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 183 insertions(+), 48 deletions(-) diff --git a/todo.md b/todo.md index 04657ad..9d8a206 100644 --- a/todo.md +++ b/todo.md @@ -59,8 +59,17 @@ IO-agnostic вычисления (без httpx-вызова и sleep): `_decide_ `_safe_payload`, `_extract_message`, `_extract_error_code`, `_extract_error_details`, `_extract_request_id`, `_normalize_path`, `_normalize_params`, `_normalize_files`, `_merge_headers`, `_build_user_agent`, `_extract_filename`, `build_httpx_timeout`, -`_safe_endpoint`. `Transport` и `AsyncTransport` остаются тонкими обёртками с двумя различиями: -формой sleep и формой client.request. +`_safe_endpoint`, `_log_http_exchange`, `_log_retry`, `_elapsed_ms`. `Transport` и +`AsyncTransport` остаются тонкими обёртками с двумя различиями: формой sleep и формой +client.request. + +**Важное уточнение по `_merge_headers`.** Текущая реализация +(`avito/core/transport.py:410-428`) внутри себя делает синхронный вызов +`self._auth_provider.get_access_token()` — то есть couples token retrieval с merge. +Чтобы helper стал IO-agnostic, рефакторим его контракт: shared `_merge_headers` +принимает уже резолвнутый `bearer_token: str | None`, а резолв (включая `await` в +async-варианте) выполняют сами `Transport`/`AsyncTransport` отдельно. Это первый шаг +Phase 1 (без поведенческих изменений sync), и он blocking для всего остального M1. Аналогично: `avito/auth/_cache.py` содержит in-memory state (поля `_access_token`, `_refresh_token`, `_autoteka_access_token`) и чистые helpers (`_is_token_fresh`, @@ -70,18 +79,28 @@ IO-agnostic вычисления (без httpx-вызова и sleep): `_decide_ ### Порядок зависимостей в M1 ``` - Phase 1 _transport_shared.py ◀── рефактор Transport (без поведенческих изменений) - _cache.py ◀── рефактор AuthProvider (без поведенческих изменений) + Phase 0 pre-flight (см. раздел "Pre-flight для PR M1") + ↓ + Phase 1a рефактор Transport._merge_headers → принимает резолвнутый bearer_token + (sync без поведенческих изменений; baseline тестов pass/fail идентичен) + ↓ + Phase 1b _transport_shared.py ◀── остальной IO-agnostic экстракт из Transport + _cache.py ◀── TokenCache + map_token_response, AuthProvider + хранит TokenCache + property-shim'ы для + _access_token/_refresh_token/_autoteka_access_token + (ради существующих тестов) ↓ Phase 2 AsyncTransport, AsyncOperationTransport, AsyncOperationExecutor - AsyncAuthProvider, AsyncTokenClient, AsyncAlternateTokenClient - AsyncPaginatedList + AsyncAuthProvider (с asyncio.Lock на refresh + отдельным autoteka lock) + AsyncTokenClient, AsyncAlternateTokenClient + AsyncPaginatedList, AsyncPaginator ↓ Phase 3 variant="async" в swagger декораторе/discovery/linter AsyncAvitoClient (без factory-методов; только lifecycle) - tests/async_fake_transport.py + avito/testing/async_fake_transport.py + tests/async_fake_transport.py + (re-export с DeprecationWarning) ↓ - Phase 4 тесты + docs + Phase 4 тесты + docs (включая baseline-diff prove sync без изменений) ``` ## Ключевые файлы и точки соединения @@ -95,7 +114,8 @@ IO-agnostic вычисления (без httpx-вызова и sleep): `_decide_ | `avito/core/swagger.py` | + поле `variant: Literal["sync","async"] = "sync"` в `SwaggerOperationBinding`. + параметр `variant` в `swagger_operation(...)`. Ошибка `ConfigurationError` при двойном декоре одной функции — без изменений. | | `avito/core/swagger_discovery.py` | `_iter_domain_modules` дополнительно ищет `.async_domain` (рядом с `.domain`). `DiscoveredSwaggerBinding` получает `variant`. `canonical_map` — ключ `(operation_key, variant)`. | | `avito/core/swagger_linter.py` | `_validate_duplicate_bindings` группирует по `(operation_key, variant)`. `_validate_complete_bindings` запускается per-variant; для `variant="async"` ожидаемое множество ограничено доменами, у которых уже найден `Async*` класс (class-gated coverage). `_validate_no_unbound_operation_specs` остаётся по `OperationSpec` (sync OperationSpec реюзается обоими режимами — счётчик использований единый). | -| `avito/auth/provider.py` | Извлекаем shared cache state в `_cache.py`. Сам `AuthProvider` остаётся sync. | +| `avito/auth/provider.py` | Извлекаем shared cache state в `_cache.py`. Сам `AuthProvider` остаётся sync. Сохраняем `_access_token`/`_refresh_token`/`_autoteka_access_token` как `@property` shim'ы поверх `TokenCache` (с сеттерами), потому что `tests/core/test_authentication.py:122-127` мутирует поле напрямую через `replace()`. | +| `avito/core/transport.py` (отдельно) | Phase 1a: `_merge_headers` рефакторится первым — принимает уже резолвнутый bearer-token, резолв вызывается отдельной строкой выше. Все остальные shared helpers — Phase 1b. | | `avito/__init__.py` | + экспорт `AsyncAvitoClient`. | | `avito/core/__init__.py` | + экспорт `AsyncTransport`, `AsyncOperationExecutor`, `AsyncOperationTransport`, `AsyncPaginatedList`. | | `avito/core/domain.py` | + `AsyncDomainObject` с async `_execute` и async `_resolve_user_id`. Sync `DomainObject` — без изменений. | @@ -110,15 +130,18 @@ IO-agnostic вычисления (без httpx-вызова и sleep): `_decide_ ``` avito/core/_transport_shared.py # IO-agnostic helpers, retry/error mapping/headers + # (_merge_headers принимает bearer_token: str | None) avito/core/async_transport.py # AsyncTransport (httpx.AsyncClient) avito/core/async_pagination.py # AsyncPaginatedList, AsyncPaginator, AsyncPageFetcher -avito/auth/_cache.py # TokenCache + _map_token_response -avito/auth/async_provider.py # AsyncAuthProvider (asyncio.Lock на refresh) +avito/auth/_cache.py # TokenCache + map_token_response +avito/auth/async_provider.py # AsyncAuthProvider (отдельные asyncio.Lock для + # основного и autoteka токенов) avito/auth/async_token_client.py # AsyncTokenClient, AsyncAlternateTokenClient # (со @swagger_operation(..., variant="async")) avito/async_client.py # AsyncAvitoClient (lifecycle + factory-методы пустые в M1) avito/testing/async_fake_transport.py # AsyncFakeTransport (httpx.MockTransport+AsyncClient) -tests/async_fake_transport.py # тонкий re-export с DeprecationWarning (как у sync) +tests/async_fake_transport.py # тонкий re-export с DeprecationWarning (как у sync; + # шаблон скопирован 1:1 с tests/fake_transport.py) tests/core/test_async_transport.py tests/core/test_async_pagination.py tests/core/test_async_executor.py @@ -165,11 +188,19 @@ class AsyncTransport: Реализует `AsyncOperationTransport` (Protocol, async-зеркало `OperationTransport` из `avito/core/operations.py`). +`AsyncTransport.request()` внутри: + +1. вызывает `bearer_token = await self._auth_provider.get_access_token()` (если требуется); +2. передаёт `bearer_token` в shared `_merge_headers(...)` — строго pure-функция; +3. петля retry-decisions делегирует в shared `_decide_*_retry`; +4. при 401 — `self._auth_provider.invalidate_token()` (sync-операция clear cache), + повторный `await self._auth_provider.get_access_token()`, один retry. + ### `avito/core/operations.py` (расширение) ```python class AsyncOperationTransport(Protocol): - async def request(...) -> httpx.Response: ... + async def request(...) -> httpx.Response: ... # async def, не Awaitable[T] async def request_json(...) -> object: ... class AsyncOperationExecutor: @@ -184,6 +215,10 @@ class AsyncOperationExecutor: `render_path`, `_serialize_query`, `_serialize_request`, `_merge_content_type`, `_extract_filename` — общие, переиспользуются обоими executor'ами без копирования. +Замечание по типизации Protocol: для async-методов в `Protocol` используем `async def`, а +не `Awaitable[T]` в return-аннотации синхронной сигнатуры. Это даёт mypy strict корректный +runtime-protocol matching и избавляет от двойной оборачивания. + ### `avito/core/domain.py` (расширение) ```python @@ -222,6 +257,18 @@ class AsyncPaginatedList[ItemT]: type AsyncPageFetcher[ItemT] = Callable[[int | None, str | None], Awaitable[JsonPage[ItemT]]] + + +class AsyncPaginator[ItemT]: + def __init__(self, fetch_page: AsyncPageFetcher[ItemT]) -> None: ... + def iter_pages(self, *, start_page: int = 1) -> AsyncIterator[JsonPage[ItemT]]: ... + async def collect(self, *, start_page: int = 1) -> list[ItemT]: ... + def as_list( + self, + *, + start_page: int = 1, + first_page: JsonPage[ItemT] | None = None, + ) -> AsyncPaginatedList[ItemT]: ... ``` `AsyncPaginatedList` **не** наследует `list[T]` — async-итерация и list-индексация @@ -229,6 +276,13 @@ type AsyncPageFetcher[ItemT] = Callable[[int | None, str | None], страничного перехода идентична sync `PaginatedList._consume_page` (включая `next_cursor`, `page+per_page`, `has_next_page`). +`AsyncPaginator` обязателен: sync-домены используют его в 5 местах +(`avito/ads/domain.py:266,1183`, `avito/accounts/domain.py:170,383`), включая один кейс, +где возвращается `Paginator` напрямую (без `as_list()`) — async-двойник такого метода +вернёт `AsyncPaginator`. У него тот же контракт, что и у sync `Paginator`, но +`iter_pages()` — `AsyncIterator`, `collect()` — корутина. Внутри `as_list()` создаёт +`AsyncPaginatedList`, передавая `first_page` как и sync-аналог. + ### `avito/auth/_cache.py` ```python @@ -247,6 +301,23 @@ def map_token_response(payload: object, *, now: datetime | None = None) -> Token `AuthProvider` и `AsyncAuthProvider` хранят `TokenCache` и используют общий `map_token_response`. +**Compat-shim для существующих тестов.** `tests/core/test_authentication.py:122-127` +напрямую читает и присваивает `provider._access_token` через `dataclasses.replace(...)`. +Чтобы не трогать тесты в M1 PR (риск scope-creep), `AuthProvider` сохраняет три +атрибут-shim'а через `@property`/setter: + +```python +@property +def _access_token(self) -> AccessToken | None: return self._cache.access_token +@_access_token.setter +def _access_token(self, value: AccessToken | None) -> None: + self._cache.access_token = value +# аналогично _refresh_token, _autoteka_access_token +``` + +Shim-ы помечены `# legacy private accessor — see PR M1` и удаляются позже отдельным PR +с миграцией тестов. + ### `avito/auth/async_provider.py` ```python @@ -259,14 +330,25 @@ class AsyncAuthProvider: token_fetcher: AsyncTokenFetcher | None = None _cache: TokenCache = field(default_factory=TokenCache, init=False, repr=False) _refresh_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False) + _autoteka_refresh_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False) - async def get_access_token(self) -> str: ... # double-checked + asyncio.Lock + async def get_access_token(self) -> str: ... # double-checked + _refresh_lock async def refresh_access_token(self) -> TokenResponse: ... - def invalidate_token(self) -> None: ... # неблокирующая операция + def invalidate_token(self) -> None: ... # sync clear cache, без await async def aclose(self) -> None: ... - async def get_autoteka_access_token(self) -> str: ... + async def get_autoteka_access_token(self) -> str: ... # double-checked + _autoteka_refresh_lock ``` +**Lock lifecycle.** В Python 3.10+ `asyncio.Lock()`, созданный вне event loop, +лениво биндится к loop'у при первом `await`. Чтобы не получить cross-loop UB: +`AsyncAuthProvider` создаётся внутри `AsyncAvitoClient.__aenter__` (или `_from_transport`), +и не переиспользуется между разными event loop'ами. Документируем это в docstring +`AsyncAvitoClient` и в risk-секции. + +Отдельный `_autoteka_refresh_lock` нужен потому, что concurrent first-touch +`get_autoteka_access_token()` вызывал бы дублирующиеся OAuth-запросы Автотеки. Sync-провайдер +этой защиты не имеет (GIL не помогает между потоками), но в async это уже явная гонка. + ### `avito/auth/async_token_client.py` ```python @@ -298,9 +380,15 @@ class AsyncTokenClient: `AsyncAlternateTokenClient` — зеркало sync-аналога с `variant="async"` на двух методах (`getAccessTokenAuthorizationCode`, `refreshAccessTokenAuthorizationCode`). -`avito/core/swagger_discovery.py._NON_DOMAIN_BINDING_MODULES` дополняем -`"avito.auth.async_provider"` (или `async_token_client`, в зависимости от того, где живут -классы) — иначе async-bindings auth-домена не попадут в discovery. +Внутри `AsyncTokenClient._request_token` создаётся **отдельный `AsyncTransport`** с +`auth_provider=None` (зеркало sync `TokenClient._build_transport()`, см. +`avito/auth/provider.py:345-350`). Использование основного `AsyncTransport` через +`AsyncAuthProvider` запрещено — это закольцует OAuth-запрос через сам же auth-провайдер. + +`avito/core/swagger_discovery.py._NON_DOMAIN_BINDING_MODULES` дополняем строго +`"avito.auth.async_token_client"` (а не `async_provider`) — потому что классы со swagger +binding-ами (`AsyncTokenClient`, `AsyncAlternateTokenClient`) живут именно там. Иначе +async-bindings auth-домена не попадут в discovery. ### `avito/async_client.py` @@ -337,7 +425,11 @@ class AsyncAvitoClient: `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`** на `AsyncAvitoClient` — отдельный (последний) этап M-final, потому что они комбинируют 5+ доменов и не нужны до того, как все домены -портированы. +портированы. Реализация **не** должна буквально повторять sync последовательно: каждый +такой метод запускает независимые подзапросы через `asyncio.gather(...)` — это и есть +основной user-value async-режима для агрегационных операций. Ошибки per-секция +обрабатываются `return_exceptions=True` + конверсия `AvitoError → SummaryUnavailableSection`, +аналогично sync-вспомогательной `_safe_summary` (`avito/client.py:91-98`). ### `avito/testing/async_fake_transport.py` @@ -376,27 +468,52 @@ class AsyncFakeTransport: - `_validate_complete_bindings(operations, bindings)` → `_validate_complete_bindings(operations, bindings, variant)`. Запускается дважды: - для `variant="sync"`: ожидаемое множество = все `operations` (как сейчас). - для `variant="async"`: ожидаемое множество = только операции из доменов, у которых найден хотя бы один `Async*` discovery binding (class-gated). Помимо `_API_DOMAINS`, для `domain == "auth"` берём операции из `Авторизация.json` и `Автотека.json`, если найден `AsyncTokenClient` / `AsyncAlternateTokenClient`. - - `_validate_operation_spec_coverage` — без изменений (sync OperationSpec — единый источник истины для обоих режимов; реюз спеки между sync и async-методами не запрещён). + - `_validate_operation_spec_coverage` — без изменений (sync OperationSpec — единый источник истины для обоих режимов; реюз спеки между sync и async-методами не запрещён). `used_specs` — `set[id(spec)]`, поэтому одна и та же `OperationSpec` от sync и async binding'ов не дублируется и не теряется. + - `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) — резолвит spec через `unwrapped_method.__globals__`. Async-методы должны импортировать spec явно (`from avito..operations import LIST_SPEC`), иначе резолв вернёт `()` и spec будет считаться unbound. Pre-flight тест проверяет, что это работает; если нет — расширяем функцию в Phase 1b. - `_validate_json_body_model_coverage` — без изменений (контрактные схемы общие). 4. `tests/contracts/test_async_parity.py` — новый тест, проверяет для каждого Async-класса: - имя `Async` ↔ существует sync `` в том же пакете; - множество публичных async-методов (`async def` без префикса `_`) совпадает с sync-методами; + - перебор методов фильтруется по `func.__qualname__.startswith(cls.__name__ + ".")`, + чтобы не учитывать унаследованные от `AsyncDomainObject` (`_execute`, `_resolve_user_id`) + или `object` методы; - для каждой пары `(sync_method, async_method)`: - `inspect.signature(sync).parameters` (без `self`) == `inspect.signature(async).parameters`; - - аннотация возврата либо совпадает, либо `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`; + - аннотация возврата либо совпадает, либо `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`, + либо `Paginator[T]` ↔ `AsyncPaginator[T]`; - оба декорированы `@swagger_operation` на ту же `(spec, method, path, operation_id)`, отличаясь только `variant`. ## Этапы +### Pre-flight для PR M1 + +До открытия PR M1 (всё это делается локально и валидируется до коммита): + +- [ ] `grep -rn "\._access_token\|\._refresh_token\|\._autoteka_access_token" tests/` — + зафиксировать все private probes; убедиться, что compat-shim в `AuthProvider` + покроет каждый. Найденный сейчас кейс: `tests/core/test_authentication.py:122-127`. +- [ ] `grep -rn "\bPaginator\b" avito/` — зафиксировать все домены-потребители + (`avito/ads/domain.py:266,1183`, `avito/accounts/domain.py:170,383`); они + определяют, нужен ли `AsyncPaginator.iter_pages()` и/или `as_list()` уже в M1 + или доставляется в первом домене с пагинацией (M4 `accounts`). +- [ ] Прогон `pytest -q` на чистом `main` — сохранить файл baseline-теста pass/fail + статусов (`pytest --tb=no -q > /tmp/baseline_main.txt`). Используется в DoD M1. +- [ ] Проверить, что `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) + работает с `async_domain.py`: тест-стаб с `async def m(self): return self._execute(SOME_SPEC)` + и `from ...operations import SOME_SPEC` — функция должна найти `SOME_SPEC` через + `unwrapped_method.__globals__`. Если не работает — расширить функцию (Phase 1b), + иначе оставить без изменений. + ### M1 — Фундамент (1 PR) DoD: - [ ] `make check` зелёный: test, typecheck (mypy strict), lint (ruff), swagger-lint --strict, architecture-lint, docstring-lint, build. - [ ] Покрытие тестами фундамента не ниже sync-аналогов (sample проверка по `coverage report`). -- [ ] Smoke-тест: `AsyncAvitoClient` через `AsyncFakeTransport` + respx делает один авторизованный запрос; токен рефрешится после 401; retry на 429 срабатывает; `Idempotency-Key` пробрасывается. +- [ ] Smoke-тест: `AsyncAvitoClient` через `AsyncFakeTransport` (без respx) делает один авторизованный запрос; токен рефрешится после 401; retry на 429 срабатывает; `Idempotency-Key` пробрасывается; `aclose()` корректно закрывает `httpx.AsyncClient` и `AsyncAuthProvider`. - [ ] Документация `swagger-binding-subsystem.md` отражает variant и class-gated coverage. -- [ ] Публичная sync-поверхность не изменилась (тесты sync без правок проходят). +- [ ] Публичная sync-поверхность не изменилась — formal: `pytest -q tests/core/ tests/auth/ tests/domains/ tests/contracts/ --tb=no` имеет идентичный список pass/fail с baseline-теста с `main` (см. pre-flight). Любое расхождение = blocker, до выяснения причины PR не мерджится. +- [ ] Phase 1a (`_merge_headers` рефакторинг) выделен отдельным коммитом внутри PR — для bisect-friendly history. ### M2…M13 — Этапы по доменам (по PR на домен) @@ -415,12 +532,15 @@ DoD: | M10 | `autoteka` | 26 | использует autoteka token flow → проверить `AsyncAuthProvider.get_autoteka_access_token` | | M11 | `ads` | 28 | вторая и третья `AsyncPaginatedList` (`Ad.list`, `AutoloadProfile`/`AutoloadReport.list`) | | M12 | `orders` | 45 | самый большой | -| M13 | M-final | — | convenience-методы `AsyncAvitoClient` (`account_health`, `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`); финальный hardening; `docs/site/how-to/async.md`; CHANGELOG → 2.1.0 | +| M13 | M-final | — | convenience-методы `AsyncAvitoClient` (`account_health`, `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`) — выполняют независимые подзапросы через `asyncio.gather(...)` (sync делает их последовательно); per-section error handling — как в sync `_safe_summary`. Финальный hardening; `docs/site/how-to/async.md`; CHANGELOG → 2.1.0 | Содержимое каждого M2…M12: 1. `avito//async_domain.py` с `Async(AsyncDomainObject)` для каждого sync-``. - Импортирует те же `OperationSpec` из `avito//operations.py`. + Импортирует те же `OperationSpec` из `avito//operations.py` **явно по именам** + (`from avito..operations import LIST_SPEC, GET_SPEC, ...`) — иначе + `_operation_specs_for_sdk_method` не сможет резолвнуть spec через `__globals__` + и swagger-lint выдаст `SWAGGER_OPERATION_SPEC_MISSING`. 2. Каждый публичный метод декорируется `@swagger_operation(..., variant="async")` теми же аргументами `(method, path, spec, operation_id, factory, factory_args, method_args, deprecated, legacy)`, что и sync. @@ -428,7 +548,11 @@ DoD: 4. `tests/domains//test__async.py` — зеркало `tests/domains//test_.py`, через `AsyncFakeTransport`. Тесты помечаем `@pytest.mark.asyncio`. -5. Если в домене есть пагинация — соответствующие методы возвращают `AsyncPaginatedList[T]`. +5. Если в домене есть пагинация — соответствующие методы возвращают `AsyncPaginatedList[T]` + или `AsyncPaginator[T]` (зеркально sync — выбор делается из sync-контракта). M4 + `accounts` — первый домен, использующий `AsyncPaginatedList`; M11 `ads` — первый + домен, где может понадобиться возврат `AsyncPaginator` напрямую (см. + `avito/ads/domain.py:266`). 6. `docs/site/reference/.md` дополняется async-секцией (или второй колонкой). 7. `make check` после этапа: swagger-lint --strict теперь требует async-coverage 1:1 для этого домена (class-gated rule увидит свежий `Async` класс). @@ -458,30 +582,33 @@ poetry run pytest tests/core/test_async_transport.py tests/core/test_async_pagin tests/auth/test_async_provider.py tests/contracts/test_async_parity.py ``` -Ручной smoke (M1, в тесте — не на проде): +Ручной smoke (M1, в тесте — не на проде; через `AsyncFakeTransport`, без `respx`): ```python -import asyncio, httpx, respx -from avito.async_client import AsyncAvitoClient -from avito.config import AvitoSettings -from avito.auth.settings import AuthSettings +import asyncio +from avito.testing.async_fake_transport import AsyncFakeTransport +from avito.core.types import RequestContext async def main(): - async with AsyncAvitoClient(AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(client_id="x", client_secret="y"), - )) as client: - with respx.mock(base_url="https://api.avito.ru") as mock: - mock.post("/token").respond(json={"access_token":"t","expires_in":3600}) - mock.get("/core/v1/accounts/self").respond(json={"id": 1}) - payload = await client.transport.request_json( - "GET", "/core/v1/accounts/self", - context=RequestContext("smoke"), - ) - assert payload == {"id": 1} + async with ( + AsyncFakeTransport() + .add_json("POST", "/token", {"access_token": "t", "expires_in": 3600}) + .add_json("GET", "/core/v1/accounts/self", {"id": 1}) + .as_client() + ) as client: + payload = await client.transport.request_json( + "GET", "/core/v1/accounts/self", + context=RequestContext("smoke"), + ) + assert payload == {"id": 1} asyncio.run(main()) ``` +`AsyncFakeTransport` строится на `httpx.MockTransport(self._handle)` поверх +`httpx.AsyncClient` — это уже самодостаточный механизм перехвата; `respx` поверх него +избыточен. Использовать `respx` стоит только если в smoke нужен уникальный матчер, +которого `add_json`/`add` не покрывает (на текущем этапе таких нет). + ### Каждый M2…M12 ```bash poetry run pytest tests/domains// # sync + async @@ -508,8 +635,16 @@ poetry run pytest # полный наб | Риск | Mitigation | |---|---| | Расхождение retry/auth-логики sync vs async | Вся не-IO логика — в `_transport_shared.py` и `_cache.py`, обе обёртки делегируют. | -| `AsyncPaginatedList` не наследует `list` → ломаются ожидания сервисов | Документируем в docstring; parity-test допускает `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`. List-API не реплицируется намеренно. | -| Auth-bindings не попадают в async-coverage | `_NON_DOMAIN_BINDING_MODULES` дополнен async-модулем; class-gated coverage гейтится по присутствию `AsyncTokenClient`/`AsyncAlternateTokenClient`. | +| `_merge_headers` срытно делает sync IO (`get_access_token()`) | Phase 1a первым шагом рефакторит контракт: helper принимает уже резолвнутый `bearer_token: str | None`. Без этого shared слой не IO-agnostic, и vary-логика расползётся. | +| `AsyncPaginatedList` не наследует `list` → ломаются ожидания сервисов | Документируем в docstring; parity-test допускает `PaginatedList[T]` ↔ `AsyncPaginatedList[T]` и `Paginator[T]` ↔ `AsyncPaginator[T]`. List-API не реплицируется намеренно. | +| `AsyncPaginator` не покрывает кейс прямого возврата `Paginator` без `as_list()` | Контракт `AsyncPaginator` симметричен sync (`iter_pages`/`collect`/`as_list`); все 5 текущих usage-сайтов покрыты. | +| Auth-bindings не попадают в async-coverage | `_NON_DOMAIN_BINDING_MODULES` дополнен строго `"avito.auth.async_token_client"`; class-gated coverage гейтится по присутствию `AsyncTokenClient`/`AsyncAlternateTokenClient`. | | Двойной декор одной функции | Текущая защита `__swagger_binding__` остаётся; sync и async — разные функции. | -| Гонка на refresh-токене в async | `asyncio.Lock` в `AsyncAuthProvider` + double-checked pattern (как sync, но через `await`). | -| Convenience-методы (`account_health`, …) расходятся между sync/async | Делаем их в M-final, когда все домены уже портированы; реализация буквально awaits то же, что sync вызывает напрямую. | +| Гонка на основном refresh-токене в async | `asyncio.Lock` (`_refresh_lock`) в `AsyncAuthProvider` + double-checked pattern (как sync, но через `await`). | +| Гонка на autoteka-токене в async | Отдельный `_autoteka_refresh_lock` + double-checked в `get_autoteka_access_token()`. Sync аналога не имел, потому что в sync GIL предотвращает деление instruction stream между потоками; в async это явная race-condition. | +| `asyncio.Lock` создан вне event loop'а → cross-loop UB | `AsyncAuthProvider` создаётся внутри `AsyncAvitoClient` (через `__aenter__` или `_from_transport`); в docstring явное предупреждение «не переиспользовать между event loop'ами». Python 3.10+ лениво биндит lock к loop'у при первом `await`. | +| Миграция `_access_token` в `TokenCache` ломает `tests/core/test_authentication.py:122-127` | `AuthProvider` сохраняет `@property`/setter shim'ы для всех трёх частных полей; шим помечен legacy-комментом и удаляется в отдельном PR. | +| `_operation_specs_for_sdk_method` не находит spec из `async_domain.py` | Pre-flight smoke-тест с async-методом + явным импортом spec; текущая реализация через `unwrapped_method.__globals__` (`swagger_linter.py:578-601`) обязана работать, потому что `from ...operations import SOME_SPEC` ставит spec в `__globals__` модуля. Если не работает — фикс в Phase 1b. | +| Convenience-методы (`account_health`, …) теряют main user-value async (параллелизм) | M-final требует `asyncio.gather(...)` для независимых подзапросов + `return_exceptions=True` + конверсия per-секция как в sync `_safe_summary`. Запрещено реализовывать «sync, обмазанный await». | +| `AsyncTokenClient._request_token` закольцован через основной auth-провайдер | Внутри создаётся независимый `AsyncTransport` с `auth_provider=None` (зеркало sync `TokenClient._build_transport()`). | +| Sync поведение незаметно изменилось в Phase 1 | DoD M1 включает baseline-diff: `pytest --tb=no -q` до и после M1 даёт идентичный список pass/fail. Любое расхождение блокирует merge. Phase 1a — отдельный коммит для bisect. | From 57c275ad658301dd2bfe467c2629008c41433c35 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Thu, 7 May 2026 09:47:42 +0300 Subject: [PATCH 03/26] =?UTF-8?q?=D0=93=D0=BE=D1=82=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BC=20=D0=BF=D0=BB=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- todo.md | 173 +++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 133 insertions(+), 40 deletions(-) diff --git a/todo.md b/todo.md index 9d8a206..21fff21 100644 --- a/todo.md +++ b/todo.md @@ -16,7 +16,7 @@ SDK сейчас полностью синхронный: `AvitoClient` → `Tra | Стиль | Параллельные классы вручную: рядом с каждым sync-слоем кладём `Async*` класс. Codegen не используем. | | Размещение | `avito//async_domain.py` рядом с `domain.py`. | | Swagger-binding | `@swagger_operation(..., variant="sync"\|"async")`. Уникальный ключ линтера — `(operation_key, variant)`. | -| Sequencing | M1 — фундамент с тестами; M2…M13 — порт каждого домена отдельным PR. До появления первого `AsyncX` класса strict-coverage по `variant="async"` пуст и не падает. | +| Sequencing | M1 — фундамент с тестами; M2-PoC — proof-of-concept шаблона на `tariffs` (валидация фундамента, может вернуть feedback); M3…M12 — закрытие каждого домена отдельным PR на 100%; M-final — convenience-методы и релиз. До появления первого `AsyncX` класса strict-coverage по `variant="async"` пуст и не падает. | | Pagination | `AsyncPaginatedList[ItemT]` — отдельный класс (не наследник `list`), без list-API parity (только `__aiter__` / `materialize` / `loaded_count` / `is_materialized` / `known_total` / `source_total`). | ## Архитектура: что общее, что дублируем @@ -150,7 +150,7 @@ tests/auth/test_async_provider.py tests/contracts/test_async_parity.py # инвариант "Async ↔ X" для всех портированных доменов ``` -### Новые файлы (M2…M13, на каждый домен) +### Новые файлы (M2-PoC + M3…M12, на каждый домен) ``` avito//async_domain.py @@ -415,8 +415,10 @@ class AsyncAvitoClient: async def __aenter__(self) -> AsyncAvitoClient: ... async def __aexit__(self, *exc) -> None: ... - # M2+ постепенно добавляются (один factory на этап): - # def account(self, user_id=None) -> AsyncAccount: ... + # M2-PoC: tariff() добавляется как валидация шаблона + # M3+: на каждом этапе добавляются ВСЕ factory-методы домена сразу + # def tariff(self) -> AsyncTariff: ... # M2-PoC + # def account(self, user_id=None) -> AsyncAccount: ...# M4 # ... ``` @@ -515,13 +517,43 @@ DoD: - [ ] Публичная sync-поверхность не изменилась — formal: `pytest -q tests/core/ tests/auth/ tests/domains/ tests/contracts/ --tb=no` имеет идентичный список pass/fail с baseline-теста с `main` (см. pre-flight). Любое расхождение = blocker, до выяснения причины PR не мерджится. - [ ] Phase 1a (`_merge_headers` рефакторинг) выделен отдельным коммитом внутри PR — для bisect-friendly history. -### M2…M13 — Этапы по доменам (по PR на домен) - -Порядок (нарастающая сложность; пилот на самом простом): +### M2-PoC — Proof-of-concept шаблона (отдельный PR, до переработки доменов) + +**Цель этого шага — НЕ закрыть домен `tariffs`, а валидировать шаблон.** Это +осознанное исключение из правила «домен закрывается на 100%»: PoC может вернуть +feedback вида «контракт `AsyncPaginator` нужно расширить», «discovery не видит +spec», «mypy strict ругается на covariance возврата» — и это нормальный ожидаемый +выход. Все правки контракта вносятся в **этот же PR**, а если правки требуют +переработки M1-фундамента — PoC откатывается, фундамент дорабатывается отдельным +PR, после чего PoC переоткрывается. + +PoC берёт `tariffs` (1 sync-операция с binding) — минимальная поверхность без +пагинации, без autoteka-flow, без write-методов. Этого достаточно, чтобы ткнуть +все слои фундамента в один сценарий end-to-end. + +DoD M2-PoC: +- [ ] `avito/tariffs/async_domain.py` создан, `AsyncTariff` зеркалит `Tariff` + ровно по 1 публичному методу. +- [ ] `AsyncAvitoClient.tariff()` factory-метод возвращает `AsyncTariff`. +- [ ] `tests/domains/tariffs/test_tariffs_async.py` зеркалит sync-тест 1:1 + (golden path + 401 + 429 + transport error). Все тесты зелёные. +- [ ] `make check` зелёный, включая `swagger-lint --strict` (для `tariffs` теперь + требуется async-coverage 1:1). +- [ ] `tests/contracts/test_async_parity.py` зелёный. +- [ ] Документация `docs/site/reference/tariffs.md` дополнена async-секцией. +- [ ] **Lessons learned зафиксированы** в `docs/site/explanations/async-domain-template.md` + (новый файл): шаблон файла `async_domain.py`, чек-лист переноса домена, + найденные подводные камни. Этот документ становится нормативным для M3+. +- [ ] Если в ходе PoC понадобились изменения контракта (`AsyncPaginator`/`AsyncFakeTransport`/ + `swagger_linter`/`AsyncAuthProvider`), они **внесены в этот же PR** или вынесены + в отдельный M1.5-PR, но **до** старта M3. + +### M3…M12 + M-final — Закрытие доменов (по PR на домен) + +Порядок (нарастающая сложность; самый простой шёл в PoC): | # | Домен | Sync-методов с binding | Особенности | |---|---|---|---| -| M2 | `tariffs` | 1 | пилот — обкатка шаблона | | M3 | `ratings` | 4 | без пагинации | | M4 | `accounts` | 8 | первая `AsyncPaginatedList` (`get_operations_history`, `list_items_by_employee`); async `_resolve_account_user_id` | | M5 | `realty` | 7 | без пагинации | @@ -529,43 +561,75 @@ DoD: | M7 | `messenger` | 18 | без пагинации | | M8 | `jobs` | 25 | webhook-методы (REST) | | M9 | `promotion` | 24 | без пагинации | -| M10 | `autoteka` | 26 | использует autoteka token flow → проверить `AsyncAuthProvider.get_autoteka_access_token` | -| M11 | `ads` | 28 | вторая и третья `AsyncPaginatedList` (`Ad.list`, `AutoloadProfile`/`AutoloadReport.list`) | -| M12 | `orders` | 45 | самый большой | -| M13 | M-final | — | convenience-методы `AsyncAvitoClient` (`account_health`, `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`) — выполняют независимые подзапросы через `asyncio.gather(...)` (sync делает их последовательно); per-section error handling — как в sync `_safe_summary`. Финальный hardening; `docs/site/how-to/async.md`; CHANGELOG → 2.1.0 | +| M10 | `autoteka` | 26 | использует autoteka token flow → end-to-end проверка `AsyncAuthProvider.get_autoteka_access_token` + `_autoteka_refresh_lock` под нагрузкой (concurrent first-touch) | +| M11 | `ads` | 28 | вторая и третья `AsyncPaginatedList` (`Ad.list`, `AutoloadProfile`/`AutoloadReport.list`); прямой возврат `AsyncPaginator` (`avito/ads/domain.py:266`) | +| M12 | `orders` | 45 | самый большой; идемпотентность критична | +| M-final | — | — | convenience-методы `AsyncAvitoClient` (`account_health`, `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`) — выполняют независимые подзапросы через `asyncio.gather(...)`; per-section error handling — как в sync `_safe_summary`. Финальный hardening; `docs/site/how-to/async.md`; CHANGELOG → 2.1.0 | -Содержимое каждого M2…M12: +Содержимое каждого M3…M12: -1. `avito//async_domain.py` с `Async(AsyncDomainObject)` для каждого sync-``. - Импортирует те же `OperationSpec` из `avito//operations.py` **явно по именам** +1. `avito//async_domain.py` с `Async(AsyncDomainObject)` для **каждого** + sync-`` в домене. Импортирует те же `OperationSpec` из + `avito//operations.py` **явно по именам** (`from avito..operations import LIST_SPEC, GET_SPEC, ...`) — иначе `_operation_specs_for_sdk_method` не сможет резолвнуть spec через `__globals__` и swagger-lint выдаст `SWAGGER_OPERATION_SPEC_MISSING`. -2. Каждый публичный метод декорируется `@swagger_operation(..., variant="async")` теми же - аргументами `(method, path, spec, operation_id, factory, factory_args, method_args, - deprecated, legacy)`, что и sync. -3. Регистрация `Async` в `AsyncAvitoClient` (factory-метод по имени, идентичному sync). +2. **Каждый** публичный метод декорируется `@swagger_operation(..., variant="async")` + теми же аргументами `(method, path, spec, operation_id, factory, factory_args, + method_args, deprecated, legacy)`, что и sync. +3. Регистрация **всех** `Async` домена в `AsyncAvitoClient` (factory-методы по + именам, идентичным sync). 4. `tests/domains//test__async.py` — зеркало - `tests/domains//test_.py`, через `AsyncFakeTransport`. Тесты помечаем - `@pytest.mark.asyncio`. -5. Если в домене есть пагинация — соответствующие методы возвращают `AsyncPaginatedList[T]` - или `AsyncPaginator[T]` (зеркально sync — выбор делается из sync-контракта). M4 - `accounts` — первый домен, использующий `AsyncPaginatedList`; M11 `ads` — первый - домен, где может понадобиться возврат `AsyncPaginator` напрямую (см. - `avito/ads/domain.py:266`). + `tests/domains//test_.py`, через `AsyncFakeTransport`. Тесты + помечаем `@pytest.mark.asyncio`. **Каждый** sync-тест имеет async-двойник + с тем же сценарием. +5. Если в домене есть пагинация — соответствующие методы возвращают + `AsyncPaginatedList[T]` или `AsyncPaginator[T]` (зеркально sync). M4 `accounts` — + первый домен с `AsyncPaginatedList`; M11 `ads` — первый домен с прямым + `AsyncPaginator` (см. `avito/ads/domain.py:266`). 6. `docs/site/reference/.md` дополняется async-секцией (или второй колонкой). -7. `make check` после этапа: swagger-lint --strict теперь требует async-coverage 1:1 для - этого домена (class-gated rule увидит свежий `Async` класс). -8. `tests/contracts/test_async_parity.py` зелёный для всех уже портированных доменов. - -### Definition of done на каждом этапе - -- [ ] Все sync-методы домена имеют async-двойников (parity-тест зелёный). -- [ ] Все async-методы покрыты тестами с теми же сценариями, что sync (golden path, - ошибки 401/403/422/429, пагинация если есть, idempotency для write-методов, - `dry_run` если есть). -- [ ] `make check` зелёный. -- [ ] Документация и `make docs-strict` зелёные. +7. Если в домене есть write-методы с `dry_run` — async-двойник реализует тот же + контракт: при `dry_run=True` транспорт **не вызывается** (тест проверяет + `count(method=..., path=...) == 0`). +8. Если в домене есть idempotency-key поведение — async-тесты явно проверяют + проброс заголовка `Idempotency-Key`. + +### Definition of done каждого M3…M12 — закрыть домен на 100%, без работы на потом + +«100%» определяется проверяемо. Все пункты ниже — **обязательные**, не «nice to have»: + +- [ ] **Покрытие методов 1:1**: для каждого публичного sync-метода домена есть + async-двойник; `tests/contracts/test_async_parity.py` зелёный для домена. + Локальная проверка: `python -c "from avito..domain import *; from + avito..async_domain import *"` + parity-test без skip-маркеров. +- [ ] **Покрытие тестов 1:1**: каждый сценарий из `tests/domains//test_*.py` + имеет async-двойник; счётчики тестов сверены: `pytest --collect-only -q + tests/domains// | grep -c "test_.*async\|test_.*[^c]$"` показывает + идентичное количество sync- и async-тестов. Покрываются: golden path, 401, + 403, 422, 429, transport error/timeout, пагинация (если есть), idempotency + (для write), `dry_run` (если есть в sync). +- [ ] **Swagger-lint coverage 1:1 для домена**: `swagger-lint --strict` после этапа + требует async binding для **каждой** swagger-операции этого домена; class-gated + coverage гейт включён, и domain больше не «пуст по async». Никаких + исключений/skip'ов для отдельных методов. +- [ ] **Документация**: `docs/site/reference/.md` содержит async-секцию для + **всех** портированных классов; `make docs-strict` зелёный; ссылки и примеры + кода скомпилированы. +- [ ] **Никаких TODO/FIXME/`pytest.skip`/`xfail` в добавленных файлах**: + `git diff main..HEAD -- avito// tests/domains// | grep -E + "TODO|FIXME|@pytest.mark.skip|xfail"` пуст. Любая отсрочка работы = blocker. +- [ ] **`make check` локально и в CI зелёный**. +- [ ] **AsyncAvitoClient полностью настроен для домена**: factory-методы возвращают + готовые объекты, lifecycle (`aclose`/`__aexit__`) корректно закрывает все + ресурсы домена. +- [ ] **Регрессия sync = 0**: список pass/fail sync-тестов идентичен предыдущему + этапу (sanity-проверка через сравнение `pytest -q --tb=no` до и после). +- [ ] **Cumulative parity invariant**: после merge'а `tests/contracts/test_async_parity.py` + зелёный для **всех** уже портированных доменов (включая текущий). Этап не + может ослабить инвариант для предыдущих доменов. +- [ ] **Нет работы «потом»**: переоткрытие PR с фразой «допилю в следующем PR» + запрещено. Если scope не закрывается — PR разделяется или раздвигается, но + не оставляется частичный домен в main. ## Верификация (как проверить, что план сработал) @@ -609,12 +673,39 @@ asyncio.run(main()) избыточен. Использовать `respx` стоит только если в smoke нужен уникальный матчер, которого `add_json`/`add` не покрывает (на текущем этапе таких нет). -### Каждый M2…M12 +### M2-PoC (proof-of-concept) +```bash +poetry run pytest tests/domains/tariffs/ # sync + async для tariffs +poetry run pytest tests/contracts/test_async_parity.py # parity для tariffs +make swagger-lint # async-coverage 1:1 для tariffs +make check +# Артефакт: docs/site/explanations/async-domain-template.md создан +``` + +### Каждый M3…M12 (закрытие домена на 100%) ```bash +# Sync regression baseline (sanity) +poetry run pytest -q --tb=no tests/domains//test_.py > /tmp/sync_before.txt + +# После применения изменений: poetry run pytest tests/domains// # sync + async -poetry run pytest tests/contracts/test_async_parity.py # инвариант parity +poetry run pytest -q --tb=no tests/domains//test_.py > /tmp/sync_after.txt +diff /tmp/sync_before.txt /tmp/sync_after.txt # должен быть пустой + +poetry run pytest tests/contracts/test_async_parity.py # parity для всех закрытых доменов make swagger-lint # async-coverage 1:1 для этого домена + +# Грязные следы — пустой выхлоп +git diff main..HEAD -- avito// tests/domains// \ + | grep -E "TODO|FIXME|@pytest.mark.skip|xfail" || echo "OK: no leftover work" + +# Cumulative счётчики (sync-тестов = async-тестов в домене) +sync_count=$(poetry run pytest --collect-only -q tests/domains//test_.py | grep -c "::test_") +async_count=$(poetry run pytest --collect-only -q tests/domains//test__async.py | grep -c "::test_") +test "$sync_count" -eq "$async_count" && echo "OK: $sync_count == $async_count" + make check +make docs-strict ``` ### M-final @@ -646,5 +737,7 @@ poetry run pytest # полный наб | Миграция `_access_token` в `TokenCache` ломает `tests/core/test_authentication.py:122-127` | `AuthProvider` сохраняет `@property`/setter shim'ы для всех трёх частных полей; шим помечен legacy-комментом и удаляется в отдельном PR. | | `_operation_specs_for_sdk_method` не находит spec из `async_domain.py` | Pre-flight smoke-тест с async-методом + явным импортом spec; текущая реализация через `unwrapped_method.__globals__` (`swagger_linter.py:578-601`) обязана работать, потому что `from ...operations import SOME_SPEC` ставит spec в `__globals__` модуля. Если не работает — фикс в Phase 1b. | | Convenience-методы (`account_health`, …) теряют main user-value async (параллелизм) | M-final требует `asyncio.gather(...)` для независимых подзапросов + `return_exceptions=True` + конверсия per-секция как в sync `_safe_summary`. Запрещено реализовывать «sync, обмазанный await». | +| Скрытая работа «на потом» в доменных PR (TODO/FIXME/skip) | DoD M3…M12 явно требует пустой выхлоп `grep -E "TODO|FIXME|@pytest.mark.skip|xfail"` по diff'у; счётчики sync- и async-тестов сравниваются равенством; PR не мерджится при частичном покрытии домена. | +| PoC обнаруживает, что фундамент (M1) недостаточен | Это и есть назначение PoC: feedback от M2-PoC → правки фундамента в этом же PR или M1.5-PR; `tariffs`-домен после доработок закрыт на 100%, как и остальные. M3 не стартует, пока M2-PoC не зелёный. | | `AsyncTokenClient._request_token` закольцован через основной auth-провайдер | Внутри создаётся независимый `AsyncTransport` с `auth_provider=None` (зеркало sync `TokenClient._build_transport()`). | | Sync поведение незаметно изменилось в Phase 1 | DoD M1 включает baseline-diff: `pytest --tb=no -q` до и после M1 даёт идентичный список pass/fail. Любое расхождение блокирует merge. Phase 1a — отдельный коммит для bisect. | From df4956c0ec906580a891c3df199fe8c732da37a3 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Thu, 7 May 2026 21:09:31 +0300 Subject: [PATCH 04/26] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D1=8E=20=D0=BD=D0=B0=D0=B4=20todo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- todo.md | 142 +++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 126 insertions(+), 16 deletions(-) diff --git a/todo.md b/todo.md index 21fff21..01b9b4d 100644 --- a/todo.md +++ b/todo.md @@ -63,6 +63,16 @@ IO-agnostic вычисления (без httpx-вызова и sleep): `_decide_ `AsyncTransport` остаются тонкими обёртками с двумя различиями: формой sleep и формой client.request. +**Контракт retry-петли в обоих режимах.** Catch-блок в `Transport.request()` / +`AsyncTransport.request()` ловит **только** `Exception`-наследников (явно: `httpx.RequestError` +и его подклассы). `BaseException` (включая `asyncio.CancelledError`, +`KeyboardInterrupt`, `SystemExit`) **никогда не уходит в retry** — пробрасывается +наружу немодифицированным. Это критично для async: иначе SDK будет ловить отмену +корутины и пытаться её ретраить, нарушая cancellation-семантику. Sync-режим тоже +получает это уточнение (поведенчески идентично — `KeyboardInterrupt` уже не +ретраится в `httpx.RequestError`-блоке). Закрепляется тестом +`tests/core/test_async_transport.py::test_cancelled_error_is_not_retried`. + **Важное уточнение по `_merge_headers`.** Текущая реализация (`avito/core/transport.py:410-428`) внутри себя делает синхронный вызов `self._auth_provider.get_access_token()` — то есть couples token retrieval с merge. @@ -72,8 +82,9 @@ async-варианте) выполняют сами `Transport`/`AsyncTransport` Phase 1 (без поведенческих изменений sync), и он blocking для всего остального M1. Аналогично: `avito/auth/_cache.py` содержит in-memory state (поля `_access_token`, -`_refresh_token`, `_autoteka_access_token`) и чистые helpers (`_is_token_fresh`, -`_map_token_response` уже в provider.py — переедет туда). `AuthProvider` и `AsyncAuthProvider` +`_refresh_token`, `_autoteka_access_token`) и чистые helpers (`_is_token_fresh`). +Module-level функция `_map_token_response` (`avito/auth/provider.py:35`) переезжает +в `_cache.py` без изменения сигнатуры. `AuthProvider` и `AsyncAuthProvider` делегируют кешу, сами добавляют только sync/async lock + IO. ### Порядок зависимостей в M1 @@ -194,7 +205,19 @@ class AsyncTransport: 2. передаёт `bearer_token` в shared `_merge_headers(...)` — строго pure-функция; 3. петля retry-decisions делегирует в shared `_decide_*_retry`; 4. при 401 — `self._auth_provider.invalidate_token()` (sync-операция clear cache), - повторный `await self._auth_provider.get_access_token()`, один retry. + повторный `await self._auth_provider.get_access_token()`, один retry; +5. ловит **только** `Exception`-наследников (`httpx.RequestError` и т.п.). + `asyncio.CancelledError` и любой `BaseException` пробрасываются наружу без retry — + см. контракт shared retry-петли выше. + +**Rate-limiter в async.** Один `RateLimiter` принадлежит одному `AsyncTransport` +(а не каждой корутине-вызову). Все корутины, делящие транспорт, должны +сериализоваться через `asyncio.Lock` внутри лимитера — иначе N параллельных запросов +независимо посчитают «надо ждать X секунд» и улетят пачкой после ожидания, нарушив +лимит. Sync `RateLimiter` (логика «ждать сколько») переезжает в `_transport_shared.py` +без поведенческих изменений; `AsyncRateLimiter`-обёртка — тонкая: `asyncio.Lock` ++ `await asyncio.sleep(delay)`. Lock создаётся лениво (как `_refresh_lock` в +`AsyncAuthProvider`) — биндится к event loop'у при первом `await`. ### `avito/core/operations.py` (расширение) @@ -276,12 +299,12 @@ class AsyncPaginator[ItemT]: страничного перехода идентична sync `PaginatedList._consume_page` (включая `next_cursor`, `page+per_page`, `has_next_page`). -`AsyncPaginator` обязателен: sync-домены используют его в 5 местах -(`avito/ads/domain.py:266,1183`, `avito/accounts/domain.py:170,383`), включая один кейс, -где возвращается `Paginator` напрямую (без `as_list()`) — async-двойник такого метода -вернёт `AsyncPaginator`. У него тот же контракт, что и у sync `Paginator`, но -`iter_pages()` — `AsyncIterator`, `collect()` — корутина. Внутри `as_list()` создаёт -`AsyncPaginatedList`, передавая `first_page` как и sync-аналог. +`AsyncPaginator` обязателен: sync-домены используют его в 4 местах +(`avito/ads/domain.py:266,1183`, `avito/accounts/domain.py:170,383`), включая один кейс +(`avito/ads/domain.py:266`), где возвращается `Paginator` напрямую (без `as_list()`) — +async-двойник такого метода вернёт `AsyncPaginator`. У него тот же контракт, что и у +sync `Paginator`, но `iter_pages()` — `AsyncIterator`, `collect()` — корутина. Внутри +`as_list()` создаёт `AsyncPaginatedList`, передавая `first_page` как и sync-аналог. ### `avito/auth/_cache.py` @@ -321,6 +344,11 @@ Shim-ы помечены `# legacy private accessor — see PR M1` и удаля ### `avito/auth/async_provider.py` ```python +class AsyncTokenFetcher(Protocol): + """Async-зеркало sync `TokenFetcher` (avito/auth/provider.py:67-70).""" + async def __call__(self, settings: AuthSettings) -> TokenResponse: ... + + @dataclass(slots=True) class AsyncAuthProvider: settings: AuthSettings @@ -422,16 +450,68 @@ class AsyncAvitoClient: # ... ``` +**Ownership чужого `httpx.AsyncClient`.** Если `http_client` передан в `__init__` +извне — пользователь сам отвечает за его lifecycle: `aclose()` / `__aexit__` его +**не** закрывают (`AsyncTransport` хранит флаг `_owns_client = http_client is None`). +Это зеркало sync-политики (см. `avito/core/transport.py` — sync `Transport` уже +делает это для `httpx.Client`). Любое расхождение с sync = blocker. + +**Rollback при partial failure в `__aenter__`.** Если `__aenter__` бросает в +середине (например, `httpx.AsyncClient` уже создан, но `AsyncAuthProvider.__post_init__` +или ленивая инициализация локов даёт исключение), весь уже-созданный state должен +быть закрыт до проброса наружу. Реализация: + +```python +async def __aenter__(self) -> AsyncAvitoClient: + try: + # любая инициализация, которая может бросить + await self._transport.__aenter__() + return self + except BaseException: + await self.aclose() # idempotent: безопасен на полу-инициализированном state + raise +``` + +`aclose()` идемпотентен и устойчив к закрытию полу-инициализированного состояния +(каждый под-ресурс проверяет `is None` перед `await x.aclose()`). Закрепляется +тестом `tests/core/test_async_client_lifecycle.py::test_aenter_rollback_on_partial_failure`. + В M1 `AsyncAvitoClient` без factory-методов — только lifecycle и smoke-вызов через сырой `transport.request_json(...)` в тесте. **Convenience методы `account_health`, `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`** на `AsyncAvitoClient` — отдельный (последний) этап M-final, потому что они комбинируют 5+ доменов и не нужны до того, как все домены портированы. Реализация **не** должна буквально повторять sync последовательно: каждый -такой метод запускает независимые подзапросы через `asyncio.gather(...)` — это и есть -основной user-value async-режима для агрегационных операций. Ошибки per-секция -обрабатываются `return_exceptions=True` + конверсия `AvitoError → SummaryUnavailableSection`, -аналогично sync-вспомогательной `_safe_summary` (`avito/client.py:91-98`). +такой метод запускает независимые подзапросы параллельно — это и есть основной +user-value async-режима для агрегационных операций. + +**Cancellation-safe паттерн (обязательный).** Используется `asyncio.TaskGroup` +(Python 3.11+, у нас floor 3.12+) с per-section try/except, конвертирующим `AvitoError +→ SummaryUnavailableSection` (как sync `_safe_summary`, `avito/client.py:91-98`). +`asyncio.gather(..., return_exceptions=True)` запрещён, потому что он возвращает +`CancelledError` как обычный результат — это глушит cancellation семантику. Шаблон: + +```python +async def _safe_summary_async[T]( + section: str, factory: Callable[[], Awaitable[T]], +) -> tuple[T | None, list[SummaryUnavailableSection]]: + try: + return await factory(), [] + except asyncio.CancelledError: + raise # отмена пробрасывается, никогда не глушим + except AvitoError as error: + return None, [_summary_unavailable_section(section, error)] + +async def business_summary(self, ...) -> BusinessSummary: + async with asyncio.TaskGroup() as tg: + t_acc = tg.create_task(_safe_summary_async("account", lambda: ...)) + t_chat = tg.create_task(_safe_summary_async("chat", lambda: ...)) + ... + # После выхода из TaskGroup все таски завершены или отменены атомарно. +``` + +При отмене внешнего вызова `TaskGroup` отменит все child-таски и пробросит +`CancelledError` — без зависших корутин и без частичного state. ### `avito/testing/async_fake_transport.py` @@ -452,6 +532,15 @@ class AsyncFakeTransport: `JsonValue`, `json_response`, `route_sequence` — переиспользуем без копий из sync. `sleep` — `lambda _: asyncio.sleep(0)`. +**Concurrency policy.** `_handle` мутирует `self.requests.append(...)` и `route.pop(0)` +для `route_sequence`-сценариев. Для тестов с `asyncio.gather(...)` (в первую очередь +M-final convenience-методы) `_handle` берёт `self._handle_lock = asyncio.Lock()` и +сериализует match-and-record под ним. Без этого две параллельные корутины могут +одновременно дёрнуть `route.pop(0)` и получить непредсказуемый порядок ответов. +Lock создаётся лениво при первом `_handle`-вызове (cross-loop safe). Документируется +в docstring класса: «AsyncFakeTransport безопасен для concurrent access внутри +одного event loop'а; не переиспользовать между разными loop'ами». + ## Swagger binding — детали изменений 1. `SwaggerOperationBinding` (`avito/core/swagger.py`): @@ -495,10 +584,24 @@ class AsyncFakeTransport: - [ ] `grep -rn "\._access_token\|\._refresh_token\|\._autoteka_access_token" tests/` — зафиксировать все private probes; убедиться, что compat-shim в `AuthProvider` покроет каждый. Найденный сейчас кейс: `tests/core/test_authentication.py:122-127`. -- [ ] `grep -rn "\bPaginator\b" avito/` — зафиксировать все домены-потребители +- [ ] `grep -rn "\bPaginator\b" avito/` — зафиксировать все 4 usage-сайта (`avito/ads/domain.py:266,1183`, `avito/accounts/domain.py:170,383`); они определяют, нужен ли `AsyncPaginator.iter_pages()` и/или `as_list()` уже в M1 или доставляется в первом домене с пагинацией (M4 `accounts`). +- [ ] `grep -rn "len(.*Paginated\|\\b[a-z_]*list\\[[0-9-]" avito/ tests/` — найти все + потребители list-API на `PaginatedList[T]` (индексация, `len`, `bool`, slice). + `AsyncPaginatedList` намеренно НЕ повторяет list-API: каждый такой кейс должен + быть либо безопасен (только sync), либо явно заменён на `await materialize()` / + `loaded_count` в async-двойнике. Список фиксируется в commit-message PoC. +- [ ] `grep -rn "^async def test_" tests/` — убедиться, что в существующих тестах нет + async-функций без `@pytest.mark.asyncio`. После включения + `asyncio_mode = "strict"` любой такой тест начнёт игнорироваться (warning, + не падение). Если найдены — добавить маркер в pre-flight commit, отдельно от M1. +- [ ] Подтвердить минимальную поддерживаемую версию Python в `pyproject.toml`. SDK уже + использует PEP 695 (`type PageFetcher[ItemT] = ...` в `avito/core/pagination.py:10`), + значит требуется Python **3.12+**. Все async-контракты (`type AsyncPageFetcher`, + `async def execute[ResponseT]`) сохраняют этот же floor; повышать не нужно, но + явно зафиксировать в M1 PR description. - [ ] Прогон `pytest -q` на чистом `main` — сохранить файл baseline-теста pass/fail статусов (`pytest --tb=no -q > /tmp/baseline_main.txt`). Используется в DoD M1. - [ ] Проверить, что `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) @@ -564,7 +667,7 @@ DoD M2-PoC: | M10 | `autoteka` | 26 | использует autoteka token flow → end-to-end проверка `AsyncAuthProvider.get_autoteka_access_token` + `_autoteka_refresh_lock` под нагрузкой (concurrent first-touch) | | M11 | `ads` | 28 | вторая и третья `AsyncPaginatedList` (`Ad.list`, `AutoloadProfile`/`AutoloadReport.list`); прямой возврат `AsyncPaginator` (`avito/ads/domain.py:266`) | | M12 | `orders` | 45 | самый большой; идемпотентность критична | -| M-final | — | — | convenience-методы `AsyncAvitoClient` (`account_health`, `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`) — выполняют независимые подзапросы через `asyncio.gather(...)`; per-section error handling — как в sync `_safe_summary`. Финальный hardening; `docs/site/how-to/async.md`; CHANGELOG → 2.1.0 | +| M-final | — | — | convenience-методы `AsyncAvitoClient` (`account_health`, `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`) — выполняют независимые подзапросы через `asyncio.TaskGroup` (cancellation-safe; `asyncio.gather(return_exceptions=True)` запрещён); per-section error handling — как в sync `_safe_summary`. Финальный hardening; `docs/site/how-to/async.md`; CHANGELOG → 2.1.0 | Содержимое каждого M3…M12: @@ -736,7 +839,14 @@ poetry run pytest # полный наб | `asyncio.Lock` создан вне event loop'а → cross-loop UB | `AsyncAuthProvider` создаётся внутри `AsyncAvitoClient` (через `__aenter__` или `_from_transport`); в docstring явное предупреждение «не переиспользовать между event loop'ами». Python 3.10+ лениво биндит lock к loop'у при первом `await`. | | Миграция `_access_token` в `TokenCache` ломает `tests/core/test_authentication.py:122-127` | `AuthProvider` сохраняет `@property`/setter shim'ы для всех трёх частных полей; шим помечен legacy-комментом и удаляется в отдельном PR. | | `_operation_specs_for_sdk_method` не находит spec из `async_domain.py` | Pre-flight smoke-тест с async-методом + явным импортом spec; текущая реализация через `unwrapped_method.__globals__` (`swagger_linter.py:578-601`) обязана работать, потому что `from ...operations import SOME_SPEC` ставит spec в `__globals__` модуля. Если не работает — фикс в Phase 1b. | -| Convenience-методы (`account_health`, …) теряют main user-value async (параллелизм) | M-final требует `asyncio.gather(...)` для независимых подзапросов + `return_exceptions=True` + конверсия per-секция как в sync `_safe_summary`. Запрещено реализовывать «sync, обмазанный await». | +| Convenience-методы (`account_health`, …) теряют main user-value async (параллелизм) | M-final требует `asyncio.TaskGroup` для независимых подзапросов + per-секция try/except `AvitoError → SummaryUnavailableSection` (зеркало sync `_safe_summary`). Запрещено реализовывать «sync, обмазанный await». | +| `asyncio.gather(return_exceptions=True)` глушит `CancelledError` в convenience-методах | Запрещён; используется `asyncio.TaskGroup` (Python 3.11+, у нас floor 3.12+). При отмене внешнего вызова TaskGroup атомарно отменяет все child-таски без потери cancellation. | +| Retry-петля ловит `asyncio.CancelledError` и зацикливает отмену | Shared `_decide_*_retry` и обёртки `Transport`/`AsyncTransport` ловят **только** `Exception`, не `BaseException`. Закреплено тестом `test_cancelled_error_is_not_retried`. | +| `AsyncAvitoClient.__aenter__` оставляет полу-инициализированный state при ошибке | `__aenter__` обёрнут `try/except BaseException`: при любом исключении вызывает идемпотентный `aclose()` и пробрасывает наружу. Закреплено тестом `test_aenter_rollback_on_partial_failure`. | +| Ownership чужого `httpx.AsyncClient` не определён — потенциальный resource-leak или double-close | `AsyncTransport` хранит `_owns_client = http_client is None`; внешне переданный клиент `aclose()`/`__aexit__` не закрывают. Зеркало sync-политики; расхождение = blocker. | +| `AsyncFakeTransport` рассинхронизирован при `asyncio.gather` | `_handle_lock = asyncio.Lock()` сериализует match-and-record; create lazily. Закреплено тестом `test_async_fake_transport_concurrent_handle`. | +| Существующие `async def test_*` в репозитории молча скипаются после `asyncio_mode = "strict"` | Pre-flight `grep -rn "^async def test_" tests/` фиксирует все такие тесты до M1; маркер `@pytest.mark.asyncio` добавляется отдельным pre-flight commit'ом. | +| `len(PaginatedList)` / `paginated[0]` в коде ломаются при попытке мигрировать на `AsyncPaginatedList` | Pre-flight `grep` фиксирует все list-API usage. `AsyncPaginatedList` не повторяет list-API намеренно; каждый кейс заменяется на `await materialize()` / `loaded_count` в async-двойнике или остаётся sync-only. | | Скрытая работа «на потом» в доменных PR (TODO/FIXME/skip) | DoD M3…M12 явно требует пустой выхлоп `grep -E "TODO|FIXME|@pytest.mark.skip|xfail"` по diff'у; счётчики sync- и async-тестов сравниваются равенством; PR не мерджится при частичном покрытии домена. | | PoC обнаруживает, что фундамент (M1) недостаточен | Это и есть назначение PoC: feedback от M2-PoC → правки фундамента в этом же PR или M1.5-PR; `tariffs`-домен после доработок закрыт на 100%, как и остальные. M3 не стартует, пока M2-PoC не зелёный. | | `AsyncTokenClient._request_token` закольцован через основной auth-провайдер | Внутри создаётся независимый `AsyncTransport` с `auth_provider=None` (зеркало sync `TokenClient._build_transport()`). | From e7c721cd74aff7e2f28e227526e0ce6ce4a2cc5b Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Thu, 7 May 2026 21:39:45 +0300 Subject: [PATCH 05/26] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D1=8E=20=D0=BD=D0=B0=D0=B4=20todo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- todo.md | 219 +++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 193 insertions(+), 26 deletions(-) diff --git a/todo.md b/todo.md index 01b9b4d..92e1ba8 100644 --- a/todo.md +++ b/todo.md @@ -59,9 +59,12 @@ IO-agnostic вычисления (без httpx-вызова и sleep): `_decide_ `_safe_payload`, `_extract_message`, `_extract_error_code`, `_extract_error_details`, `_extract_request_id`, `_normalize_path`, `_normalize_params`, `_normalize_files`, `_merge_headers`, `_build_user_agent`, `_extract_filename`, `build_httpx_timeout`, -`_safe_endpoint`, `_log_http_exchange`, `_log_retry`, `_elapsed_ms`. `Transport` и -`AsyncTransport` остаются тонкими обёртками с двумя различиями: формой sleep и формой -client.request. +`_safe_endpoint`, `_log_http_exchange`, `_log_retry`, `_elapsed_ms`, +`RateLimitState` (pure token-bucket state с `compute_delay()`/`observe_response()`, +без `Lock` и без `sleep` — см. блок «Контракт shared-частей RateLimiter» ниже). +`Transport` и `AsyncTransport` остаются тонкими обёртками с тремя различиями: +формой sleep, формой client.request, и типом lock'а вокруг `RateLimitState` +(`threading.Lock` vs `asyncio.Lock`). **Контракт retry-петли в обоих режимах.** Catch-блок в `Transport.request()` / `AsyncTransport.request()` ловит **только** `Exception`-наследников (явно: `httpx.RequestError` @@ -187,7 +190,7 @@ class AsyncTransport: data=None, files=None, headers=None, content=None, idempotency_key=None) -> httpx.Response: ... async def request_json(...) -> object: ... - async def download_binary(...) -> BinaryResponse: ... + async def download_binary(...) -> BinaryResponse: ... # full-buffer, см. ниже async def aclose(self) -> None: ... async def __aenter__(self) -> AsyncTransport: ... async def __aexit__(self, *exc) -> None: ... @@ -210,14 +213,60 @@ class AsyncTransport: `asyncio.CancelledError` и любой `BaseException` пробрасываются наружу без retry — см. контракт shared retry-петли выше. -**Rate-limiter в async.** Один `RateLimiter` принадлежит одному `AsyncTransport` +**Rate-limiter в async.** Один rate-limiter принадлежит одному `AsyncTransport` (а не каждой корутине-вызову). Все корутины, делящие транспорт, должны сериализоваться через `asyncio.Lock` внутри лимитера — иначе N параллельных запросов независимо посчитают «надо ждать X секунд» и улетят пачкой после ожидания, нарушив -лимит. Sync `RateLimiter` (логика «ждать сколько») переезжает в `_transport_shared.py` -без поведенческих изменений; `AsyncRateLimiter`-обёртка — тонкая: `asyncio.Lock` -+ `await asyncio.sleep(delay)`. Lock создаётся лениво (как `_refresh_lock` в -`AsyncAuthProvider`) — биндится к event loop'у при первом `await`. +лимит. + +**Контракт shared-частей RateLimiter.** Текущий `avito/core/rate_limit.py` содержит +*и* состояние token-bucket'а (`_tokens`, `_blocked_until`, `_updated_at`), *и* +`while True: self._sleep(delay)` внутри `acquire()` — sleep запечён в метод. Sync +`RateLimiter` нельзя «обернуть» в async без переделки, потому что внутри стоит +`threading.Lock`, который удерживать через `await` запрещено. Поэтому декомпозиция +строгая, в три части: + +1. **`RateLimitState`** (pure dataclass в `avito/core/_transport_shared.py`): + `_tokens: float`, `_updated_at: float`, `_blocked_until: float`, политика + (`rate`, `capacity`, `enabled`). Методы: + - `compute_delay(now: float) -> float` — pure-функция, **не** sleep'ает, + возвращает 0 если можно сразу, иначе нужную задержку. Резервирует токен, + если возвращает 0 (мутирует state). + - `observe_response(now: float, headers: Mapping[str, str]) -> None` — pure + обновление `_blocked_until` по rate-limit headers (без IO). + +2. **`RateLimiter`** (sync, остаётся в `avito/core/rate_limit.py`): хранит + `RateLimitState` + `threading.Lock` + `_sleep` + `_clock`. `acquire()` — это + `while compute_delay() > 0: time.sleep(delay)` под `threading.Lock`. + Поведенчески идентичен текущему (закрепляется baseline-diff'ом sync-тестов). + +3. **`AsyncRateLimiter`** (новый, в `avito/core/async_transport.py` или отдельно + в `avito/core/_async_rate_limit.py` — выбор фиксируется в M1 PR): хранит + **отдельный `RateLimitState`** (не shared с sync — состояние не делится между + режимами; sync- и async-транспорты — независимые сущности с независимыми + bucket'ами) + `asyncio.Lock` + `_clock` + `_sleep: Callable[[float], + Awaitable[None]] = asyncio.sleep`. `async def acquire()` — это + `async with self._lock: while (delay := state.compute_delay(now())) > 0: + await self._sleep(delay)`. + +`asyncio.Lock` в `AsyncRateLimiter` создаётся лениво (как `_refresh_lock` в +`AsyncAuthProvider`) — биндится к event loop'у при первом `await`. Запрещено +переиспользовать один `AsyncRateLimiter` между event loop'ами. + +**Закрепляется тестами**: `tests/core/test_rate_limit_state.py` (pure compute); +`tests/core/test_async_transport.py::test_async_rate_limiter_serializes_concurrent_acquires` +(пять параллельных корутин не уходят пачкой после ожидания, а сериализуются под +`asyncio.Lock`). + +**Семантика `AsyncTransport.download_binary`.** В M1 — **full-buffer**, как sync: +внутри `await response.aread()` и возвращается `BinaryResponse` с полным `bytes`- +контентом. Streaming-вариант (`async for chunk in response.aiter_bytes()`) — +**out of scope для M1…M-final**: ни один публичный sync-метод не возвращает +chunked stream, parity-test это бы поломал, и пользователи Async API не получат +расхождения с sync. Если в будущем понадобится stream — это отдельный API +(`download_binary_stream` или итератор), вводимый отдельным минорным релизом +после 2.1.0 с симметричным sync-аналогом. Закрепляется тестом +`tests/core/test_async_transport.py::test_download_binary_full_buffer_matches_sync`. ### `avito/core/operations.py` (расширение) @@ -256,8 +305,15 @@ class AsyncDomainObject: async def _resolve_user_id(self, user_id: int | str | None = None) -> int: ... ``` -Async-двойник sync-`DomainObject._resolve_user_id`: тот же fallback-порядок (аргумент → -`AvitoSettings.user_id` → `await self.transport.request_json("GET", "/core/v1/accounts/self")`). +Async-двойник sync-`DomainObject._resolve_user_id`: тот же fallback-порядок и тот же +уровень абстракции, что и sync — резолв через `await self._execute(WHOAMI_SPEC)`, +**не** через сырой `transport.request_json`. Sync-версия идёт через executor-слой +(`OperationExecutor.execute(spec)`), и async обязан зеркалить ровно это, иначе +swagger-binding для `/core/v1/accounts/self` не будет покрыт async-cоверажем +(parity-test и `_validate_complete_bindings` per-variant обнаружат расхождение). +`WHOAMI_SPEC` живёт в `avito/accounts/operations.py` и реюзается обоими режимами; +импорт обязан быть явным (`from avito.accounts.operations import WHOAMI_SPEC`), +иначе `_operation_specs_for_sdk_method` через `__globals__` не резолвится. ### `avito/core/async_pagination.py` @@ -450,11 +506,57 @@ class AsyncAvitoClient: # ... ``` +**Lifecycle `from_env` и `__init__`.** `from_env` — **синхронная** фабрика +(зеркало sync `AvitoClient.from_env`): читает `.env`/окружение, конструирует +`AvitoSettings` и возвращает не-инициализированный `AsyncAvitoClient`. Сетевых +ресурсов (`httpx.AsyncClient`, `asyncio.Lock`) на этом этапе ещё нет — они +создаются лениво в `__aenter__` под текущий event loop. Это критично потому, +что: +- `httpx.AsyncClient`, созданный в одном loop'е и использованный в другом, даёт + неопределённое поведение; +- `asyncio.Lock` биндится к loop'у при первом `await` и не переносится между + loop'ами; +- `from_env` сам не `async` — пользователь не должен подключать SDK через + `await AsyncAvitoClient.from_env()`. + +**Контракт использования — обязательные паттерны:** + +```python +# (1) Рекомендованный: контекст-менеджер +async with AsyncAvitoClient.from_env() as client: + ... + +# (2) Допустимый: явный aclose +client = AsyncAvitoClient.from_env() +async with client: # инициализация в __aenter__ + ... +# или +client = AsyncAvitoClient.from_env() +await client.__aenter__() # эквивалент async with +try: + ... +finally: + await client.aclose() +``` + +**Запрещено:** +```python +client = AsyncAvitoClient.from_env() +await client.transport.request_json(...) # transport ещё None — RuntimeError +``` + +`transport`/`auth_provider` — `@property`, возвращают `RuntimeError("AsyncAvitoClient +не инициализирован: используйте 'async with' или дождитесь '__aenter__'")` до +первого `__aenter__`. Закрепляется тестом +`tests/core/test_async_client_lifecycle.py::test_access_before_aenter_raises`. + **Ownership чужого `httpx.AsyncClient`.** Если `http_client` передан в `__init__` извне — пользователь сам отвечает за его lifecycle: `aclose()` / `__aexit__` его **не** закрывают (`AsyncTransport` хранит флаг `_owns_client = http_client is None`). Это зеркало sync-политики (см. `avito/core/transport.py` — sync `Transport` уже -делает это для `httpx.Client`). Любое расхождение с sync = blocker. +делает это для `httpx.Client`). Любое расхождение с sync = blocker. Если +`http_client` передан, его loop должен совпадать с loop'ом, в котором будет +вызван `__aenter__`; cross-loop ownership — UB, проверяется только документацией. **Rollback при partial failure в `__aenter__`.** Если `__aenter__` бросает в середине (например, `httpx.AsyncClient` уже создан, но `AsyncAuthProvider.__post_init__` @@ -481,15 +583,33 @@ async def __aenter__(self) -> AsyncAvitoClient: `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`** на `AsyncAvitoClient` — отдельный (последний) этап M-final, потому что они комбинируют 5+ доменов и не нужны до того, как все домены -портированы. Реализация **не** должна буквально повторять sync последовательно: каждый -такой метод запускает независимые подзапросы параллельно — это и есть основной -user-value async-режима для агрегационных операций. +портированы. -**Cancellation-safe паттерн (обязательный).** Используется `asyncio.TaskGroup` -(Python 3.11+, у нас floor 3.12+) с per-section try/except, конвертирующим `AvitoError -→ SummaryUnavailableSection` (как sync `_safe_summary`, `avito/client.py:91-98`). -`asyncio.gather(..., return_exceptions=True)` запрещён, потому что он возвращает -`CancelledError` как обычный результат — это глушит cancellation семантику. Шаблон: +**Классификация методов M-final (важно для имплементации).** Не все 8 методов — +агрегаторы; путать паттерн нельзя. + +| Метод | Тип | Sync поведение | Async поведение | +|---|---|---|---| +| `account_health` | агрегатор (5 подзапросов) | последовательно вызывает `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary` (`avito/client.py:206-263`) | **`asyncio.TaskGroup`** на 5 параллельных подзапросов; per-section `_safe_summary_async` | +| `listing_health` | агрегатор (≥2 подзапроса) | вызывает `ad.list_items`, статистику, spendings (`avito/client.py:265-368`) | **`asyncio.TaskGroup`** на независимые ветки; зависимые ветки (нужен `item_ids` от первой) — последовательно, как sync | +| `business_summary` | **алиас** для `account_health` | `return self.account_health(...)` (`avito/client.py:184-204`) | `return await self.account_health(...)` — **никакого `TaskGroup`**, делегирование 1:1 | +| `chat_summary` | leaf (1 подзапрос) | один вызов `messenger`-домена | один `await` — параллелизма нет | +| `order_summary` | leaf | один подзапрос | один `await` | +| `review_summary` | leaf+safe | один подзапрос с `try/except` (`avito/client.py:396-410`) | один `await` + `_safe_summary_async` | +| `promotion_summary` | leaf | один подзапрос | один `await` | +| `capabilities` | probe-агрегатор | последовательно дёргает методы и ловит `AvitoError`, чтобы понять «доступно ли» (`avito/client.py:467+`) | **`asyncio.TaskGroup`** оправдан: каждая capability — независимый сетевой probe; параллельность экономит latency. Per-section `try/except AvitoError → CapabilityUnavailable` | + +Правило: «параллелим только если sync делает 2+ независимых сетевых вызова». Алиасы +(`business_summary`) и leaf'ы (`chat_summary`, `order_summary`, `promotion_summary`) — +**обычные `async def` с одним `await`**, без `TaskGroup`. Это записано в DoD M-final +ниже как явная проверка через code review checklist. + +**Cancellation-safe паттерн для агрегаторов (обязательный).** Используется +`asyncio.TaskGroup` (Python 3.11+, у нас floor 3.12+) с per-section try/except, +конвертирующим `AvitoError → SummaryUnavailableSection` (как sync `_safe_summary`, +`avito/client.py:91-98`). `asyncio.gather(..., return_exceptions=True)` запрещён, +потому что он возвращает `CancelledError` как обычный результат — это глушит +cancellation семантику. Шаблон: ```python async def _safe_summary_async[T]( @@ -537,9 +657,16 @@ class AsyncFakeTransport: M-final convenience-методы) `_handle` берёт `self._handle_lock = asyncio.Lock()` и сериализует match-and-record под ним. Без этого две параллельные корутины могут одновременно дёрнуть `route.pop(0)` и получить непредсказуемый порядок ответов. -Lock создаётся лениво при первом `_handle`-вызове (cross-loop safe). Документируется -в docstring класса: «AsyncFakeTransport безопасен для concurrent access внутри -одного event loop'а; не переиспользовать между разными loop'ами». + +**Инициализация lock'а в `__init__` (а не лениво).** Лениво создавать `asyncio.Lock` +из `_handle` нельзя: две корутины, одновременно прошедшие `if self._handle_lock is +None`, создадут разные lock-объекты — и сериализация сломается до первого `await`. +Поэтому `self._handle_lock = asyncio.Lock()` создаётся в `__init__` под текущий +event loop. Цена: `AsyncFakeTransport` нельзя переиспользовать между event loop'ами +(под `pytest-asyncio strict` это и так не происходит — каждый тест получает свой +loop). Документируется в docstring: «AsyncFakeTransport безопасен для concurrent +access внутри одного event loop'а; создавать новый instance в каждом тесте; не +переиспользовать между loop'ами». ## Swagger binding — детали изменений @@ -558,7 +685,28 @@ Lock создаётся лениво при первом `_handle`-вызове - `_validate_duplicate_bindings` — ключ `(operation_key, variant)` вместо `operation_key`. Допустимо иметь две независимые цепочки (sync + async) на одну swagger-операцию. - `_validate_complete_bindings(operations, bindings)` → `_validate_complete_bindings(operations, bindings, variant)`. Запускается дважды: - для `variant="sync"`: ожидаемое множество = все `operations` (как сейчас). - - для `variant="async"`: ожидаемое множество = только операции из доменов, у которых найден хотя бы один `Async*` discovery binding (class-gated). Помимо `_API_DOMAINS`, для `domain == "auth"` берём операции из `Авторизация.json` и `Автотека.json`, если найден `AsyncTokenClient` / `AsyncAlternateTokenClient`. + - для `variant="async"`: ожидаемое множество = **per-class**, не per-domain. + Для каждого sync-класса в домене (``) проверяем: существует ли + `Async` (по имени, `cls.__name__.startswith("Async") and + cls.__name__.removeprefix("Async") == sync_cls.__name__`, в том же пакете). + Если да — все swagger-операции, привязанные к sync-методам этого класса, + обязаны иметь async-двойник в `Async`. Если нет — класс считается + «ещё не портированным», и его операции не входят в expected для + `variant="async"` на этом этапе. + + Помимо `_API_DOMAINS`, для `domain == "auth"` берём операции из + `Авторизация.json` и `Автотека.json`, если найден `AsyncTokenClient` / + `AsyncAlternateTokenClient` соответственно (та же per-class логика). + + Это даёт два важных свойства: + 1. M1 фундамент мерджится: ни одного `Async` нет → expected = ∅, + линтер зелёный. + 2. Большой домен (например, M11 `ads` с 3 классами `Ad`/`AutoloadProfile`/ + `AutoloadReport`) теоретически можно разбить на под-PR'ы по классу; + DoD M3…M12 всё равно требует закрытия домена на 100%, но per-class + гранулярность даёт безопасную точку выхода, если PR раздувается. + (Дробление допустимо только при явном решении, а не «сделаю остальное + потом» — см. DoD M3…M12.) - `_validate_operation_spec_coverage` — без изменений (sync OperationSpec — единый источник истины для обоих режимов; реюз спеки между sync и async-методами не запрещён). `used_specs` — `set[id(spec)]`, поэтому одна и та же `OperationSpec` от sync и async binding'ов не дублируется и не теряется. - `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) — резолвит spec через `unwrapped_method.__globals__`. Async-методы должны импортировать spec явно (`from avito..operations import LIST_SPEC`), иначе резолв вернёт `()` и spec будет считаться unbound. Pre-flight тест проверяет, что это работает; если нет — расширяем функцию в Phase 1b. - `_validate_json_body_model_coverage` — без изменений (контрактные схемы общие). @@ -619,6 +767,10 @@ DoD: - [ ] Документация `swagger-binding-subsystem.md` отражает variant и class-gated coverage. - [ ] Публичная sync-поверхность не изменилась — formal: `pytest -q tests/core/ tests/auth/ tests/domains/ tests/contracts/ --tb=no` имеет идентичный список pass/fail с baseline-теста с `main` (см. pre-flight). Любое расхождение = blocker, до выяснения причины PR не мерджится. - [ ] Phase 1a (`_merge_headers` рефакторинг) выделен отдельным коммитом внутри PR — для bisect-friendly history. +- [ ] CHANGELOG `## [Unreleased]` дополнен: `- Фундамент Async API: AsyncTransport, + AsyncAuthProvider, AsyncOperationExecutor, AsyncPaginatedList, + AsyncAvitoClient (без factory-методов доменов); RateLimitState вынесен в + shared`. ### M2-PoC — Proof-of-concept шаблона (отдельный PR, до переработки доменов) @@ -650,6 +802,8 @@ DoD M2-PoC: - [ ] Если в ходе PoC понадобились изменения контракта (`AsyncPaginator`/`AsyncFakeTransport`/ `swagger_linter`/`AsyncAuthProvider`), они **внесены в этот же PR** или вынесены в отдельный M1.5-PR, но **до** старта M3. +- [ ] CHANGELOG `## [Unreleased]` дополнен: `- Async-поддержка домена tariffs: + AsyncTariff (PoC шаблона)`. ### M3…M12 + M-final — Закрытие доменов (по PR на домен) @@ -667,7 +821,7 @@ DoD M2-PoC: | M10 | `autoteka` | 26 | использует autoteka token flow → end-to-end проверка `AsyncAuthProvider.get_autoteka_access_token` + `_autoteka_refresh_lock` под нагрузкой (concurrent first-touch) | | M11 | `ads` | 28 | вторая и третья `AsyncPaginatedList` (`Ad.list`, `AutoloadProfile`/`AutoloadReport.list`); прямой возврат `AsyncPaginator` (`avito/ads/domain.py:266`) | | M12 | `orders` | 45 | самый большой; идемпотентность критична | -| M-final | — | — | convenience-методы `AsyncAvitoClient` (`account_health`, `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`) — выполняют независимые подзапросы через `asyncio.TaskGroup` (cancellation-safe; `asyncio.gather(return_exceptions=True)` запрещён); per-section error handling — как в sync `_safe_summary`. Финальный hardening; `docs/site/how-to/async.md`; CHANGELOG → 2.1.0 | +| M-final | — | — | convenience-методы `AsyncAvitoClient`: только агрегаторы (`account_health`, `listing_health`, `capabilities`) реализуются через `asyncio.TaskGroup` с per-section error handling (`_safe_summary_async`); leaf'ы (`chat_summary`, `order_summary`, `promotion_summary`, `review_summary`) — обычный `async def` с одним `await`; алиас `business_summary` делегирует в `account_health`. `asyncio.gather(return_exceptions=True)` запрещён. Финальный hardening; `docs/site/how-to/async.md`; CHANGELOG `## [Unreleased]` → `## [2.1.0]` (свод накопленных пунктов из M1…M12 + запись про convenience-методы). | Содержимое каждого M3…M12: @@ -733,6 +887,12 @@ DoD M2-PoC: - [ ] **Нет работы «потом»**: переоткрытие PR с фразой «допилю в следующем PR» запрещено. Если scope не закрывается — PR разделяется или раздвигается, но не оставляется частичный домен в main. +- [ ] **CHANGELOG обновлён**: в `docs/CHANGELOG.md` (раздел `## [Unreleased]`) + добавлена строка вида `- Async-поддержка домена : Async, Async + (#)`. M-final сводит накопленные `Unreleased`-строки в релиз 2.1.0, + добавляя только запись про convenience-методы и `AsyncAvitoClient`-агрегаторы. + Без этого history-readers не увидят, в каком PR домен стал async, и release + notes 2.1.0 не получится собрать механически. ## Верификация (как проверить, что план сработал) @@ -829,6 +989,13 @@ poetry run pytest # полный наб | Риск | Mitigation | |---|---| | Расхождение retry/auth-логики sync vs async | Вся не-IO логика — в `_transport_shared.py` и `_cache.py`, обе обёртки делегируют. | +| `RateLimiter` неприменим к async (sleep + `threading.Lock` запечены в `acquire()`) | Декомпозиция в три части: pure `RateLimitState.compute_delay()` в shared (без sleep, без lock), sync `RateLimiter` поверх (`threading.Lock` + `time.sleep`), отдельный `AsyncRateLimiter` (`asyncio.Lock` + `await asyncio.sleep`). State **не** делится между режимами — sync и async транспорты независимы. | +| `_resolve_user_id` в async идёт через сырой `request_json`, а не через executor | Async-двойник обязан использовать `await self._execute(WHOAMI_SPEC)`, как sync. Иначе swagger-binding для `/core/v1/accounts/self` не покрыт async-coverage'ом, parity-test и `_validate_complete_bindings` падают. | +| `download_binary` в async может неявно стать streaming, расходясь с sync | M1 фиксирует full-buffer-семантику (`await response.aread()`), как sync. Streaming — отдельный API после 2.1.0 с симметричным sync-аналогом. Закреплено тестом `test_download_binary_full_buffer_matches_sync`. | +| Convenience-метод М-final реализован как «sync с обмазанным await» (потеря параллелизма) ИЛИ leaf-метод обёрнут в ненужный `TaskGroup` | DoD M-final проверяет классификацию по таблице: TaskGroup — только для агрегаторов с 2+ независимыми сетевыми вызовами (`account_health`, `listing_health`, `capabilities`); алиасы и leaf'ы — обычный `async def` с одним `await`. Code review checklist. | +| Class-gated swagger-coverage применён per-domain → большой домен (`ads`) нельзя разбить, либо мини-домен с двумя классами требует доделки до merge'а | Class-gated применяется **per-class**: `Async` существует ↔ все операции класса `` обязаны иметь async-binding. Отсутствие `Async` в том же домене не блокирует мердж класса `Async`. DoD M3…M12 всё равно требует домен закрыть на 100%. | +| `from_env` инициализирует loop-зависимые ресурсы вне loop'а → cross-loop UB | `from_env` синхронен, ресурсы (`httpx.AsyncClient`, `asyncio.Lock`) создаются в `__aenter__`. Доступ к `transport`/`auth_provider` до `__aenter__` бросает `RuntimeError` с понятным сообщением. Закреплено тестом `test_access_before_aenter_raises`. | +| Release notes 2.1.0 невозможно собрать механически, потому что в PR M3…M12 нет CHANGELOG-записей | DoD M3…M12 требует `## [Unreleased]` строку в `docs/CHANGELOG.md` на каждый PR. M-final сводит накопленное в `## [2.1.0]`. | | `_merge_headers` срытно делает sync IO (`get_access_token()`) | Phase 1a первым шагом рефакторит контракт: helper принимает уже резолвнутый `bearer_token: str | None`. Без этого shared слой не IO-agnostic, и vary-логика расползётся. | | `AsyncPaginatedList` не наследует `list` → ломаются ожидания сервисов | Документируем в docstring; parity-test допускает `PaginatedList[T]` ↔ `AsyncPaginatedList[T]` и `Paginator[T]` ↔ `AsyncPaginator[T]`. List-API не реплицируется намеренно. | | `AsyncPaginator` не покрывает кейс прямого возврата `Paginator` без `as_list()` | Контракт `AsyncPaginator` симметричен sync (`iter_pages`/`collect`/`as_list`); все 5 текущих usage-сайтов покрыты. | @@ -844,7 +1011,7 @@ poetry run pytest # полный наб | Retry-петля ловит `asyncio.CancelledError` и зацикливает отмену | Shared `_decide_*_retry` и обёртки `Transport`/`AsyncTransport` ловят **только** `Exception`, не `BaseException`. Закреплено тестом `test_cancelled_error_is_not_retried`. | | `AsyncAvitoClient.__aenter__` оставляет полу-инициализированный state при ошибке | `__aenter__` обёрнут `try/except BaseException`: при любом исключении вызывает идемпотентный `aclose()` и пробрасывает наружу. Закреплено тестом `test_aenter_rollback_on_partial_failure`. | | Ownership чужого `httpx.AsyncClient` не определён — потенциальный resource-leak или double-close | `AsyncTransport` хранит `_owns_client = http_client is None`; внешне переданный клиент `aclose()`/`__aexit__` не закрывают. Зеркало sync-политики; расхождение = blocker. | -| `AsyncFakeTransport` рассинхронизирован при `asyncio.gather` | `_handle_lock = asyncio.Lock()` сериализует match-and-record; create lazily. Закреплено тестом `test_async_fake_transport_concurrent_handle`. | +| `AsyncFakeTransport` рассинхронизирован при `asyncio.gather` | `_handle_lock = asyncio.Lock()` сериализует match-and-record; **создаётся в `__init__`**, не лениво (лениво — гонка на самой инициализации lock'а). Закреплено тестом `test_async_fake_transport_concurrent_handle`. | | Существующие `async def test_*` в репозитории молча скипаются после `asyncio_mode = "strict"` | Pre-flight `grep -rn "^async def test_" tests/` фиксирует все такие тесты до M1; маркер `@pytest.mark.asyncio` добавляется отдельным pre-flight commit'ом. | | `len(PaginatedList)` / `paginated[0]` в коде ломаются при попытке мигрировать на `AsyncPaginatedList` | Pre-flight `grep` фиксирует все list-API usage. `AsyncPaginatedList` не повторяет list-API намеренно; каждый кейс заменяется на `await materialize()` / `loaded_count` в async-двойнике или остаётся sync-only. | | Скрытая работа «на потом» в доменных PR (TODO/FIXME/skip) | DoD M3…M12 явно требует пустой выхлоп `grep -E "TODO|FIXME|@pytest.mark.skip|xfail"` по diff'у; счётчики sync- и async-тестов сравниваются равенством; PR не мерджится при частичном покрытии домена. | From 5f18cffbcdf0f5dd1e752bab475b76887fac52ca Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 12:35:31 +0300 Subject: [PATCH 06/26] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D1=8E=20=D0=BD=D0=B0=D0=B4=20todo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai/python-guidelines.md | 404 +++++++++++++++++++++++++++++++++ todo.md | 478 +++++++++++++++++++++++++++++---------- 2 files changed, 758 insertions(+), 124 deletions(-) create mode 100644 .ai/python-guidelines.md diff --git a/.ai/python-guidelines.md b/.ai/python-guidelines.md new file mode 100644 index 0000000..81df633 --- /dev/null +++ b/.ai/python-guidelines.md @@ -0,0 +1,404 @@ +# Python Guidelines + +Rules and conventions for Python code in this repository. + +## Critical Rules + +These are the conventions that matter most for code quality and maintainability. +Exceptions exist, but they should be rare and justified in a code comment. + +### Keep imports at the top of the file + +**Always flag** any `import` statement that appears inside a function body, method, +or class. Imports inside functions hide dependencies, make the module harder to +understand at a glance, and can mask missing packages until a specific code path +is hit at runtime. + +**Always flag** `try/except ImportError` around imports (except for the documented +exceptions below). This pattern creates two execution modes -- one with the library +and one without -- which doubles the testing surface and produces confusing +behavior when the dependency is unexpectedly absent. + +```python +# BAD -- import inside function; dependency is invisible until this path runs +# ALWAYS FLAG THIS PATTERN +def process_data(): + import json + return json.loads(data) + +# BAD -- try/except hides a missing dependency behind a flag +try: + import requests + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +# GOOD -- direct import at module top; fails immediately if missing +import json +import requests +``` + +Failing immediately on a missing dependency is better than hiding the problem +until a user hits an obscure code path in production. If a package is required, +add it to `requirements.txt` or `pyproject.toml` so it's installed upfront. + +**Exception: optional dependencies and pytest collection.** This repo has multiple +optional backends (vLLM, SGLang, TRT-LLM, cupy, etc.) that are not installed in +every environment. Using `try/except ImportError` is the correct pattern when: + +- An optional backend dependency (e.g. `tritonclient.grpc`, `vllm_omni`, + `torch_memory_saver`) may not be installed, and the code provides a + fallback or sets the import to `None`. +- A test file needs to skip collection when optional packages are absent + (e.g. `except ImportError: pytest.skip(..., allow_module_level=True)`). +- A stdlib module has version-dependent availability + (e.g. `tomllib` on Python 3.11+ vs `tomli` fallback). + +```python +# OK -- optional backend, graceful fallback to None +try: + from vllm_omni.diffusion.data import DiffusionParallelConfig +except ImportError: + DiffusionParallelConfig = None + +# OK -- skip test collection when optional deps are missing +try: + from dynamo.profiler.rapid import WorkloadSpec +except ImportError as e: + pytest.skip(f"Skip (missing dependency): {e}", allow_module_level=True) + +# OK -- stdlib version compatibility +try: + import tomllib +except ImportError: + import tomli as tomllib +``` + +### Prefer failing fast over hiding errors + +From PEP 20 (The Zen of Python): + +> *"Errors should never pass silently. Unless explicitly silenced."* +> *"Explicit is better than implicit."* + +Failing immediately when something goes wrong is better than silently continuing +with bad state, because: + +- The person who caused the error sees it right away, while the context is fresh. +- The stack trace points directly at the root cause, not at a downstream symptom + three function calls later. +- Hidden errors compound -- a swallowed exception in one place produces confusing + behavior somewhere else, and the debugging cost grows exponentially with distance + from the original failure. + +```python +# BAD -- all of these hide errors +except Exception: + pass + +except Exception as e: + logging.error(e) # logs but silently continues! + return [] # returns a fake default + +# BAD -- bare except catches KeyboardInterrupt and SystemExit too, +# making the process impossible to kill with Ctrl-C or sys.exit() +try: + do_work() +except: + log_error() + +# GOOD -- just let it crash +result = something() + +# GOOD -- catch SPECIFIC exceptions you can actually handle +try: + result = json.loads(text) +except json.JSONDecodeError: + result = {} + +# GOOD -- if you must catch broad, catch Exception (not bare except) +# and re-raise after logging +try: + result = something() +except Exception as e: + logger.error(f"Failed: {e}") + raise +``` + +**Three rules:** +1. Remove the try/except if possible -- let it crash. +2. Catch **specific** exceptions only (`FileNotFoundError`, `ValueError`, `json.JSONDecodeError`, etc.). +3. If you must catch broadly, use `except Exception:` (never bare `except:`) and **always** re-raise after logging. + +### NO defensive `getattr()` on known types + +**Always flag** `getattr(obj, "attr", default)` when the object's type is known +and the attribute is part of its definition (class attribute, `__init__` parameter, +dataclass field, etc.). Using `getattr()` with a default hides bugs by silently +returning a fallback when the attribute should always exist. Direct attribute +access fails loudly if the type contract changes, which is what you want. + +```python +# BAD -- cfg is a ServiceConfig with host/port; getattr hides AttributeError +# ALWAYS FLAG THIS PATTERN +cfg = ServiceConfig(host="0.0.0.0", port=8080) +host = getattr(cfg, "host", "localhost") +port = getattr(cfg, "port", 9999) + +# GOOD -- direct access, fails loudly if something is wrong +host = cfg.host +port = cfg.port +``` + +--- + +## Anti-Patterns That Must Be Flagged in Review + +Every item below is a **mandatory review check**. If any of these patterns appear +in a pull request, flag it and request changes. These are not style preferences -- +they are sources of real bugs, resource leaks, and CI flakiness. + +### Mutable default arguments + +**Always flag** any function whose default argument is a mutable object (`[]`, `{}`, +`set()`). Default values are evaluated once at function definition time and shared +across all calls, so mutations accumulate silently between invocations. + +```python +# BAD -- the list is shared across all calls; flag this +def add_item(item, items=[]): + items.append(item) + return items + +add_item("a") # ["a"] +add_item("b") # ["a", "b"] -- not ["b"]! + +# GOOD -- use None sentinel, create a new list each call +def add_item(item, items=None): + if items is None: + items = [] + items.append(item) + return items +``` + +### Leaked file handles -- always use context managers + +**Always flag** any `open()` call that is not wrapped in a `with` statement. +Files, network connections, subprocesses, and locks must be opened with `with` +so they are released even if an exception occurs. Bare `open()` followed by +manual `.close()` leaks the handle when an exception fires between the two calls. + +```python +# BAD -- file handle leaks if json.load raises; flag this +f = open("data.json") +data = json.load(f) +f.close() + +# GOOD +with open("data.json") as f: + data = json.load(f) +``` + +This is especially important in this project where tests manage subprocesses, +etcd/NATS connections, and temp directories. Use `ManagedProcess`, +`tempfile.TemporaryDirectory`, and similar context managers rather than +manual setup/teardown. + +### Shadowing built-in names + +**Always flag** any variable named `list`, `dict`, `id`, `type`, `input`, `open`, +`format`, `set`, `map`, `filter`, `range`, `str`, `int`, `float`, `bool`, `bytes`, +`tuple`, `hash`, `len`, `min`, `max`, `sum`, `any`, `all`, `zip`, `enumerate`, +`sorted`, `reversed`, or `next`. Assigning to these names overwrites the built-in +and causes confusing `TypeError`s later in the same scope. + +```python +# BAD -- shadows built-in list(); flag this +list = get_items() +filtered = list(some_gen) # TypeError: 'list' object is not callable + +# GOOD +items = get_items() +filtered = list(some_gen) +``` + +### Use `is` for None / True / False comparisons + +**Always flag** `== None`, `== True`, `== False`, `!= None`, `!= True`, and +`!= False`. These invoke `__eq__`, which can be overridden and produce +surprising results. Use `is` / `is not` for singleton comparisons. + +```python +# BAD -- flag these +if result == None: +if flag == True: +if done == False: + +# GOOD +if result is None: +if flag is True: # or just: if flag: +if not done: +``` + +### Do not modify a collection while iterating + +**Always flag** any loop that adds, removes, or deletes from the collection it is +iterating over. This causes skipped elements, `RuntimeError` (for dicts), or +infinite loops. Build a new collection or iterate over a copy. + +```python +# BAD -- RuntimeError on dict, skips elements on list; flag this +for item in items: + if item.is_stale(): + items.remove(item) + +# GOOD +items = [item for item in items if not item.is_stale()] +``` + +### Prefer `join()` over string concatenation in loops + +**Always flag** `+=` on a string variable inside a loop. Repeated `+=` on strings +creates a new string object each time, which is O(n^2) for large loops. + +```python +# BAD -- O(n^2) string building; flag this +result = "" +for line in lines: + result += line + "\n" + +# GOOD -- O(n) with join +result = "\n".join(lines) +``` + +### Late-binding closures in loops + +**Always flag** lambdas or inner functions created inside a loop that reference the +loop variable without binding it as a default argument. Closures capture the +variable reference, not its value at the time of creation, so all closures end up +with the final loop value. + +```python +# BAD -- all lambdas return 4 (the final value of i); flag this +fns = [lambda: i for i in range(5)] +[f() for f in fns] # [4, 4, 4, 4, 4] + +# GOOD -- default argument captures current value +fns = [lambda i=i: i for i in range(5)] +[f() for f in fns] # [0, 1, 2, 3, 4] +``` + +### Do not use `assert` for runtime validation + +**Always flag** `assert` statements used to validate function arguments, request +payloads, configuration, or any data that comes from outside the current function. +Assertions are stripped when Python runs with `-O` (optimize), silently removing +the validation. Use explicit `if/raise` for checks that must always execute. + +```python +# BAD -- silently skipped under python -O; flag this +assert user_id is not None, "user_id required" + +# GOOD +if user_id is None: + raise ValueError("user_id required") +``` + +--- + +## Code Style + +- Follow PEP 8. +- `snake_case` for variables and functions. +- `PascalCase` for classes. +- Add type hints where they improve readability. +- Use docstrings for public functions and classes. +- Use `dataclass` instead of plain dicts when a structure has >4 fields (better + type inference and IDE support). + +## File Organization + +- `__init__.py` for package initialization. +- Clear module separation. +- Tests in `tests/` directory. + +## Formatting and Linting + +### Preferred workflow + +Auto-fix formatting, then lint: + +```bash +ruff format +ruff check --fix +``` + +Or use pre-commit (runs isort, black, flake8, ruff, and more): + +```bash +pre-commit run --files +pre-commit run --all-files # for broad changes +``` + +### Pre-commit hooks + +The repo's `.pre-commit-config.yaml` runs these Python hooks: + +- **isort** -- import sorting (`profile = "black"`, configured in `pyproject.toml`) +- **black** -- code formatting +- **flake8** -- style checks (`max-line-length=88`) +- **ruff** -- fast linting with auto-fix +- **codespell** -- spelling checks +- **trailing-whitespace**, **end-of-file-fixer**, **check-yaml**, **check-json**, **check-toml** + +### Before committing + +Always run: + +```bash +pre-commit run --files +``` + +For broader changes or if in doubt: + +```bash +pre-commit run --all-files +``` + +### Indentation verification + +Indentation errors are common and hard to spot visually. After editing Python +files, verify mechanically: + +```bash +ruff format # auto-fix (preferred) +python3 -m compileall -q # fast parse-only check +``` + +When fixing indentation errors, always read 20-30 lines of surrounding context +and fix the whole block -- adjacent lines often share the same mistake. + +## Error Handling + +See the **Critical Rules** section above for the full policy. Summary: + +- Let exceptions propagate by default. +- Catch only specific exceptions you can actually handle. +- If you catch `Exception`, you must re-raise after logging. +- No `except Exception: pass`. Ever. + +### Regex caution + +Be careful with escaping in raw strings (`\s` vs `\\s`). When changing a critical +regex, add a one-line test to prove it matches. + +## Import Order + +Imports are sorted by isort with `profile = "black"` (configured in `pyproject.toml`). +The order is: + +1. Standard library +2. Third-party (known: `vllm`, `tensorrt_llm`, `sglang`, `aiconfigurator`) +3. First-party (`dynamo`, `deploy`) + +Run `isort` or `pre-commit run isort` to auto-fix ordering diff --git a/todo.md b/todo.md index 92e1ba8..52018d1 100644 --- a/todo.md +++ b/todo.md @@ -3,8 +3,9 @@ ## Контекст SDK сейчас полностью синхронный: `AvitoClient` → `Transport` (`httpx.Client` + `time.sleep`) → -`AuthProvider` (`TokenClient` поверх sync-transport) → `DomainObject` подклассы (12 пакетов, -~204 swagger-операций) → `PaginatedList[T]` (наследник `list`). Цель — добавить вторую, +`AuthProvider` (`TokenClient` поверх sync-transport) → `DomainObject` подклассы +(11 API-пакетов + auth-bindings, 204 swagger-операции) → `PaginatedList[T]` +(наследник `list`). Цель — добавить вторую, асинхронную, поверхность по образцу `httpx.Client`/`httpx.AsyncClient`, без слома sync-API, с переиспользованием `OperationSpec`, моделей, request/query DTO, swagger-инвариантов и ошибок. @@ -16,7 +17,8 @@ SDK сейчас полностью синхронный: `AvitoClient` → `Tra | Стиль | Параллельные классы вручную: рядом с каждым sync-слоем кладём `Async*` класс. Codegen не используем. | | Размещение | `avito//async_domain.py` рядом с `domain.py`. | | Swagger-binding | `@swagger_operation(..., variant="sync"\|"async")`. Уникальный ключ линтера — `(operation_key, variant)`. | -| Sequencing | M1 — фундамент с тестами; M2-PoC — proof-of-concept шаблона на `tariffs` (валидация фундамента, может вернуть feedback); M3…M12 — закрытие каждого домена отдельным PR на 100%; M-final — convenience-методы и релиз. До появления первого `AsyncX` класса strict-coverage по `variant="async"` пуст и не падает. | +| Нормативные документы | M1 обновляет `STYLEGUIDE.md`, потому что сейчас он описывает SDK как sync-only и разрешает только `domain.py`. Без этого M1 конфликтует с главным style gate. | +| Sequencing | M1 — фундамент с тестами и async auth-bindings; M2-PoC — proof-of-concept шаблона на `tariffs` (валидация фундамента, может вернуть feedback); M3…M12 — закрытие каждого домена отдельным PR на 100%; M-final — convenience-методы и релиз. До появления первого доменного `Async` класса strict-coverage по `variant="async"` для API-доменов пуст и не падает; auth gated отдельно по `AsyncTokenClient` / `AsyncAlternateTokenClient`. | | Pagination | `AsyncPaginatedList[ItemT]` — отдельный класс (не наследник `list`), без list-API parity (только `__aiter__` / `materialize` / `loaded_count` / `is_materialized` / `known_total` / `source_total`). | ## Архитектура: что общее, что дублируем @@ -124,21 +126,32 @@ Module-level функция `_map_token_response` (`avito/auth/provider.py:35`) | Файл | Что меняем | |---|---| | `avito/core/transport.py` | Извлекаем IO-agnostic helpers в `_transport_shared.py` и переиспользуем. Поведение sync — без изменений. | -| `avito/core/operations.py` | + `AsyncOperationTransport` (Protocol, async зеркало `OperationTransport`), + `AsyncOperationExecutor` (async зеркало `OperationExecutor.execute`). Helpers `render_path`, `_serialize_query`, `_serialize_request`, `_merge_content_type`, `_extract_filename` уже module-level — переиспользуем без копий. | +| `avito/core/operations.py` | + `AsyncOperationTransport` (Protocol, async зеркало `OperationTransport`), + `AsyncOperationExecutor` (async зеркало `OperationExecutor.execute`) с теми же ветками `json` / `empty` / `binary`, что и sync. Helpers `render_path`, `_serialize_query`, `_serialize_request`, `_merge_content_type`, `_extract_filename` уже module-level — переиспользуем без копий. | | `avito/core/swagger.py` | + поле `variant: Literal["sync","async"] = "sync"` в `SwaggerOperationBinding`. + параметр `variant` в `swagger_operation(...)`. Ошибка `ConfigurationError` при двойном декоре одной функции — без изменений. | -| `avito/core/swagger_discovery.py` | `_iter_domain_modules` дополнительно ищет `.async_domain` (рядом с `.domain`). `DiscoveredSwaggerBinding` получает `variant`. `canonical_map` — ключ `(operation_key, variant)`. | +| `avito/core/swagger_discovery.py` | `_iter_domain_modules` дополнительно ищет `.async_domain` (рядом с `.domain`). `DiscoveredSwaggerBinding` получает `variant`. `canonical_map` остаётся sync-only compatibility API для существующих sync contract tests; новый `canonical_map_by_variant` / `binding_for(operation_key, variant)` использует ключ `(operation_key, variant)`. | | `avito/core/swagger_linter.py` | `_validate_duplicate_bindings` группирует по `(operation_key, variant)`. `_validate_complete_bindings` запускается per-variant; для `variant="async"` ожидаемое множество ограничено доменами, у которых уже найден `Async*` класс (class-gated coverage). `_validate_no_unbound_operation_specs` остаётся по `OperationSpec` (sync OperationSpec реюзается обоими режимами — счётчик использований единый). | +| `avito/core/swagger_report.py` | JSON report становится variant-aware: summary хранит `sync` и `async` coverage отдельно, `operations[].bindings` содержит mapping по variant. Старые поля `bound`/`unbound` остаются sync-only compatibility до отдельного report API bump. | | `avito/auth/provider.py` | Извлекаем shared cache state в `_cache.py`. Сам `AuthProvider` остаётся sync. Сохраняем `_access_token`/`_refresh_token`/`_autoteka_access_token` как `@property` shim'ы поверх `TokenCache` (с сеттерами), потому что `tests/core/test_authentication.py:122-127` мутирует поле напрямую через `replace()`. | +| `avito/core/deprecation.py` | `deprecated_method(...)` становится async-aware: если исходный метод coroutine function, wrapper тоже `async def` и делает `return await method(...)`, сохраняя `__sdk_deprecation__`. Это нужно для deprecated async-двойников в `cpa` и `ads`. | | `avito/core/transport.py` (отдельно) | Phase 1a: `_merge_headers` рефакторится первым — принимает уже резолвнутый bearer-token, резолв вызывается отдельной строкой выше. Все остальные shared helpers — Phase 1b. | -| `avito/__init__.py` | + экспорт `AsyncAvitoClient`. | -| `avito/core/__init__.py` | + экспорт `AsyncTransport`, `AsyncOperationExecutor`, `AsyncOperationTransport`, `AsyncPaginatedList`. | +| `avito/__init__.py` | + экспорт `AsyncAvitoClient`, `AsyncPaginatedList`. `AsyncPaginator` не выносим на root level, потому что sync-root экспортирует `PaginatedList`, но не `Paginator`; `AsyncPaginator` остаётся доступен из `avito.core`. | +| `avito/core/__init__.py` | + экспорт `AsyncTransport`, `AsyncOperationExecutor`, `AsyncOperationTransport`, `AsyncPaginatedList`, `AsyncPaginator`. | +| `avito/auth/__init__.py` | + экспорт `AsyncAuthProvider`, `AsyncTokenClient`, `AsyncAlternateTokenClient`, если эти классы объявлены публичными для consumer-side тестов и type-hint'ов. | +| `avito/testing/__init__.py` | + экспорт `AsyncFakeTransport`, `AsyncSwaggerFakeTransport` и общих helpers, чтобы async test utilities были таким же публичным контрактом, как sync `FakeTransport`. | +| `avito//__init__.py` | На каждом M2/M3…M12 добавляется export соответствующих `Async` классов; без этого `_gen_reference.py`, mkdocstrings и IDE-discovery не увидят async-поверхность. | +| `docs/site/assets/_gen_reference.py` | + расширение `public_domain_packages()` / `public_domain_classes()` / `public_domain_methods()` для подхвата `async_domain.py` и `Async`-классов рядом с sync-аналогами. Builder не должен зависеть только от `avito..__all__`: он обязан импортировать `avito..domain` и `avito..async_domain` напрямую, затем сохранять порядок sync-класс → async-класс. Без этого `make docs-strict` после M2-PoC не докажет полноту reference. | | `avito/core/domain.py` | + `AsyncDomainObject` с async `_execute` и async `_resolve_user_id`. Sync `DomainObject` — без изменений. | | `pyproject.toml` | + `pytest-asyncio = "^0.24"` в dev-deps. + `[tool.pytest.ini_options] asyncio_mode = "strict"`. | -| `Makefile` | Без новых целей; `make check` после M1 должен оставаться зелёным. | -| `scripts/lint_architecture.py` | `LEGACY_FILENAMES` не трогаем (там `client.py`, `mappers.py`, `enums.py` — `async_domain.py` не пересекается). | +| `Makefile` | + цель `async-parity-lint`, включённая в `quality`; `make check` после M1 должен оставаться зелёным. | +| `scripts/lint_architecture.py` | `LEGACY_FILENAMES` не трогаем, но public-method checks применяются к `domain.py` и `async_domain.py`; AST-парсер должен учитывать `ast.AsyncFunctionDef` наравне с `ast.FunctionDef`. | +| `scripts/lint_docstrings.py` | Проверяет `avito/*/domain.py` и `avito/*/async_domain.py`, чтобы async public methods не получили generic/reference-плохие docstring-и. | +| `scripts/lint_async_parity.py` | Новый static linter, не pytest: проверяет `Async ↔ X`, сигнатуры, return annotations (`PaginatedList[T] ↔ AsyncPaginatedList[T]`), `async def`, binding equality и отсутствие лишних/пропущенных public methods. | | `scripts/lint_swagger_bindings.py` | Без изменений в CLI (логика вынесена в `swagger_linter.py`). | +| `tests/contracts/test_swagger_contracts.py` | Фильтруется на `variant="sync"` и продолжает проверять sync `SwaggerFakeTransport` без изменения behavioral coverage. | +| `STYLEGUIDE.md` | M1 нормативно разрешает двухрежимный SDK: `async_domain.py`, `AsyncDomainObject`, `AsyncTransport`/`httpx.AsyncClient`, async lifecycle и variant-aware Swagger bindings. Sync-only рекомендация заменяется на описание двух поверхностей. | | `docs/site/explanations/swagger-binding-subsystem.md` | Раздел про `variant` и class-gated coverage. | | `docs/site/explanations/domain-architecture-v2.md` | Параграф про `async_domain.py` как разрешённый файл, парный к `domain.py`. | +| `README.md`, `mkdocs.yml`, `docs/site/index.md`, `docs/site/reference/client.md`, `docs/site/reference/pagination.md`, `docs/site/reference/testing.md`, `docs/site/how-to/index.md` | В M-final обновляются с «синхронный SDK» на двухрежимный SDK и получают ссылки на async lifecycle/testing/pagination. | ### Новые файлы (M1) @@ -154,6 +167,9 @@ avito/auth/async_token_client.py # AsyncTokenClient, AsyncAlternateToken # (со @swagger_operation(..., variant="async")) avito/async_client.py # AsyncAvitoClient (lifecycle + factory-методы пустые в M1) avito/testing/async_fake_transport.py # AsyncFakeTransport (httpx.MockTransport+AsyncClient) +avito/testing/async_swagger_fake_transport.py + # AsyncSwaggerFakeTransport: async contract runner + # для discovered bindings с variant="async" tests/async_fake_transport.py # тонкий re-export с DeprecationWarning (как у sync; # шаблон скопирован 1:1 с tests/fake_transport.py) tests/core/test_async_transport.py @@ -161,7 +177,9 @@ tests/core/test_async_pagination.py tests/core/test_async_executor.py tests/core/test_async_client_lifecycle.py tests/auth/test_async_provider.py -tests/contracts/test_async_parity.py # инвариант "Async ↔ X" для всех портированных доменов +tests/contracts/test_async_swagger_contracts.py + # Swagger-spec compliance для async bindings +scripts/lint_async_parity.py # static linter, не pytest ``` ### Новые файлы (M2-PoC + M3…M12, на каждый домен) @@ -236,9 +254,10 @@ class AsyncTransport: обновление `_blocked_until` по rate-limit headers (без IO). 2. **`RateLimiter`** (sync, остаётся в `avito/core/rate_limit.py`): хранит - `RateLimitState` + `threading.Lock` + `_sleep` + `_clock`. `acquire()` — это - `while compute_delay() > 0: time.sleep(delay)` под `threading.Lock`. - Поведенчески идентичен текущему (закрепляется baseline-diff'ом sync-тестов). + `RateLimitState` + `threading.Lock` + `_sleep` + `_clock`. Чтобы не менять + sync-поведение, wrapper сохраняет текущий порядок: lock держится только на + вычислении/мутации state, sleep выполняется вне `threading.Lock`. Любое изменение + sync-concurrency semantics — отдельный сознательный PR, не часть M1. 3. **`AsyncRateLimiter`** (новый, в `avito/core/async_transport.py` или отдельно в `avito/core/_async_rate_limit.py` — выбор фиксируется в M1 PR): хранит @@ -249,9 +268,12 @@ class AsyncTransport: `async with self._lock: while (delay := state.compute_delay(now())) > 0: await self._sleep(delay)`. -`asyncio.Lock` в `AsyncRateLimiter` создаётся лениво (как `_refresh_lock` в -`AsyncAuthProvider`) — биндится к event loop'у при первом `await`. Запрещено -переиспользовать один `AsyncRateLimiter` между event loop'ами. +Async wrapper намеренно держит `asyncio.Lock` во время ожидания, чтобы несколько +корутин с одним transport-ом не просыпались одной пачкой после одинакового delay. +`asyncio.Lock` создаётся при создании `AsyncRateLimiter` внутри async lifecycle +(`AsyncAvitoClient.__aenter__`, `AsyncFakeTransport.as_client()` внутри тестового loop'а +или явное создание пользователем внутри loop'а) и биндится к event loop'у при первом +`await`. Запрещено переиспользовать один `AsyncRateLimiter` между event loop'ами. **Закрепляется тестами**: `tests/core/test_rate_limit_state.py` (pure compute); `tests/core/test_async_transport.py::test_async_rate_limiter_serializes_concurrent_acquires` @@ -262,7 +284,8 @@ class AsyncTransport: внутри `await response.aread()` и возвращается `BinaryResponse` с полным `bytes`- контентом. Streaming-вариант (`async for chunk in response.aiter_bytes()`) — **out of scope для M1…M-final**: ни один публичный sync-метод не возвращает -chunked stream, parity-test это бы поломал, и пользователи Async API не получат +chunked stream, `scripts/lint_async_parity.py` и async contract suite это бы поломали, +и пользователи Async API не получат расхождения с sync. Если в будущем понадобится stream — это отдельный API (`download_binary_stream` или итератор), вводимый отдельным минорным релизом после 2.1.0 с симметричным sync-аналогом. Закрепляется тестом @@ -286,6 +309,21 @@ class AsyncOperationExecutor: `render_path`, `_serialize_query`, `_serialize_request`, `_merge_content_type`, `_extract_filename` — общие, переиспользуются обоими executor'ами без копирования. +`AsyncOperationExecutor.execute()` повторяет все три ветки sync-executor'а: + +- `response_kind == "json"`: `payload = await transport.request_json(...)`, затем + `response_model.from_payload(payload)`; +- `response_kind == "empty"`: `response = await transport.request(...)`, затем + `EmptyResponse(status_code=response.status_code, headers=dict(response.headers))`; +- `response_kind == "binary"`: executor вызывает `await transport.request(...)` + с method/path из `OperationSpec`, затем строит `BinaryResponse` тем же helper-кодом, + что sync `_request_binary()` использует для `OrderLabel.download()`. `download_binary()` + остаётся низкоуровневым convenience-методом `AsyncTransport`, но **не** входит в + `AsyncOperationTransport` Protocol, иначе binary-ветка начнёт отличаться от sync + executor и потеряет method/path из `OperationSpec`. + +Binary-ветка закрепляется M1 unit-тестом на executor и M12 domain-тестом +`OrderLabel.download()` через `AsyncSwaggerFakeTransport`/`AsyncFakeTransport`. Замечание по типизации Protocol: для async-методов в `Protocol` используем `async def`, а не `Awaitable[T]` в return-аннотации синхронной сигнатуры. Это даёт mypy strict корректный @@ -305,15 +343,15 @@ class AsyncDomainObject: async def _resolve_user_id(self, user_id: int | str | None = None) -> int: ... ``` -Async-двойник sync-`DomainObject._resolve_user_id`: тот же fallback-порядок и тот же -уровень абстракции, что и sync — резолв через `await self._execute(WHOAMI_SPEC)`, -**не** через сырой `transport.request_json`. Sync-версия идёт через executor-слой -(`OperationExecutor.execute(spec)`), и async обязан зеркалить ровно это, иначе -swagger-binding для `/core/v1/accounts/self` не будет покрыт async-cоверажем -(parity-test и `_validate_complete_bindings` per-variant обнаружат расхождение). -`WHOAMI_SPEC` живёт в `avito/accounts/operations.py` и реюзается обоими режимами; -импорт обязан быть явным (`from avito.accounts.operations import WHOAMI_SPEC`), -иначе `_operation_specs_for_sdk_method` через `__globals__` не резолвится. +Async-двойник sync-`DomainObject._resolve_user_id`: тот же fallback-порядок, что и +текущий sync-код в `avito/core/domain.py`: сначала аргумент, затем `settings.user_id`, +затем internal raw request на `/core/v1/accounts/self` через transport. Это +осознанный exception для базового helper-а: `core` не импортирует +`avito.accounts.operations.GET_SELF`, чтобы не создавать зависимость core → domain. +Swagger-binding для `/core/v1/accounts/self` покрывается публичным +`Account.get_self()` / `AsyncAccount.get_self()`, а `_resolve_user_id` остаётся +internal helper без отдельного binding-а. Если в будущем sync `_resolve_user_id` +переводится на executor, async меняется в том же PR. ### `avito/core/async_pagination.py` @@ -355,12 +393,24 @@ class AsyncPaginator[ItemT]: страничного перехода идентична sync `PaginatedList._consume_page` (включая `next_cursor`, `page+per_page`, `has_next_page`). -`AsyncPaginator` обязателен: sync-домены используют его в 4 местах -(`avito/ads/domain.py:266,1183`, `avito/accounts/domain.py:170,383`), включая один кейс -(`avito/ads/domain.py:266`), где возвращается `Paginator` напрямую (без `as_list()`) — -async-двойник такого метода вернёт `AsyncPaginator`. У него тот же контракт, что и у -sync `Paginator`, но `iter_pages()` — `AsyncIterator`, `collect()` — корутина. Внутри -`as_list()` создаёт `AsyncPaginatedList`, передавая `first_page` как и sync-аналог. +**Concurrency contract.** `AsyncPaginatedList` не поддерживает concurrent iteration +одного instance из нескольких корутин. Но это не должно превращаться в silent data +corruption: класс хранит флаг активной итерации (`_active_iterator`) и fail-fast +бросает `RuntimeError("AsyncPaginatedList уже итерируется; используйте materialize() или создайте отдельный список.")`, +если второй `__aiter__` стартует до завершения первого. Если нужен fan-out — +вызывайте `await materialize()` один раз и итерируйтесь по полученному `list[T]`, +либо создавайте отдельный `AsyncPaginatedList` per consumer. Документируется +в docstring класса и в `docs/site/explanations/pagination-semantics.md` +(дополнение в M-final). Закрепляется поведением +`tests/core/test_async_pagination.py::test_concurrent_aiter_raises_runtime_error`. + +`AsyncPaginator` обязателен как implementation helper: sync-домены используют +`Paginator(...).as_list(...)` в 4 местах (`avito/ads/domain.py:266,1183`, +`avito/accounts/domain.py:170,383`). Текущая публичная поверхность не возвращает +`Paginator` напрямую, поэтому async public methods возвращают `AsyncPaginatedList[T]`, +а не `AsyncPaginator[T]`. Сам `AsyncPaginator` остаётся доступен из `avito.core` для +симметрии core API: `iter_pages()` — `AsyncIterator`, `collect()` — корутина, +`as_list()` создаёт `AsyncPaginatedList`, передавая `first_page` как sync-аналог. ### `avito/auth/_cache.py` @@ -421,6 +471,8 @@ class AsyncAuthProvider: def invalidate_token(self) -> None: ... # sync clear cache, без await async def aclose(self) -> None: ... async def get_autoteka_access_token(self) -> str: ... # double-checked + _autoteka_refresh_lock + def token_flow(self) -> AsyncTokenClient: ... + def alternate_token_flow(self) -> AsyncAlternateTokenClient: ... ``` **Lock lifecycle.** В Python 3.10+ `asyncio.Lock()`, созданный вне event loop, @@ -495,6 +547,7 @@ class AsyncAvitoClient: @property def transport(self) -> AsyncTransport: ... + def auth(self) -> AsyncAuthProvider: ... async def aclose(self) -> None: ... async def __aenter__(self) -> AsyncAvitoClient: ... async def __aexit__(self, *exc) -> None: ... @@ -508,9 +561,11 @@ class AsyncAvitoClient: **Lifecycle `from_env` и `__init__`.** `from_env` — **синхронная** фабрика (зеркало sync `AvitoClient.from_env`): читает `.env`/окружение, конструирует -`AvitoSettings` и возвращает не-инициализированный `AsyncAvitoClient`. Сетевых -ресурсов (`httpx.AsyncClient`, `asyncio.Lock`) на этом этапе ещё нет — они -создаются лениво в `__aenter__` под текущий event loop. Это критично потому, +`AvitoSettings` и возвращает не-инициализированный `AsyncAvitoClient`. SDK-managed +сетевых ресурсов (`httpx.AsyncClient`, `asyncio.Lock`) на этом этапе ещё нет — +они создаются лениво в `__aenter__` под текущий event loop. Исключение: если +пользователь явно передал внешний `http_client`, он уже существует, но transport +и auth-provider всё равно связываются с ним только в `__aenter__`. Это критично потому, что: - `httpx.AsyncClient`, созданный в одном loop'е и использованный в другом, даёт неопределённое поведение; @@ -550,13 +605,16 @@ await client.transport.request_json(...) # transport ещё None — RuntimeEr первого `__aenter__`. Закрепляется тестом `tests/core/test_async_client_lifecycle.py::test_access_before_aenter_raises`. -**Ownership чужого `httpx.AsyncClient`.** Если `http_client` передан в `__init__` -извне — пользователь сам отвечает за его lifecycle: `aclose()` / `__aexit__` его -**не** закрывают (`AsyncTransport` хранит флаг `_owns_client = http_client is None`). -Это зеркало sync-политики (см. `avito/core/transport.py` — sync `Transport` уже -делает это для `httpx.Client`). Любое расхождение с sync = blocker. Если -`http_client` передан, его loop должен совпадать с loop'ом, в котором будет -вызван `__aenter__`; cross-loop ownership — UB, проверяется только документацией. +**Ownership внешнего `httpx.AsyncClient`.** В M1 нельзя незаметно менять текущую +sync-семантику. Сейчас sync `Transport.close()` закрывает `httpx.Client` даже если +он был передан извне. Поэтому `AsyncTransport.aclose()` в 2.1.0 зеркалит это +поведение: закрывает внутренний `httpx.AsyncClient` независимо от того, создан он +SDK или передан пользователем. Это фиксируется тестом, чтобы план не опирался на +неверное предположение про `_owns_client`. Если нужна политика "external client is +owned by caller", она вводится отдельным PR одновременно для sync и async с явным +CHANGELOG/deprecation-дизайном. Если `http_client` передан, его loop должен совпадать +с loop'ом, в котором будет вызван `__aenter__`; cross-loop ownership — UB, +проверяется только документацией. **Rollback при partial failure в `__aenter__`.** Если `__aenter__` бросает в середине (например, `httpx.AsyncClient` уже создан, но `AsyncAuthProvider.__post_init__` @@ -582,27 +640,27 @@ async def __aenter__(self) -> AsyncAvitoClient: `transport.request_json(...)` в тесте. **Convenience методы `account_health`, `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`** на `AsyncAvitoClient` — отдельный (последний) -этап M-final, потому что они комбинируют 5+ доменов и не нужны до того, как все домены -портированы. +этап M-final, потому что часть из них комбинирует несколько доменов и не нужна до +того, как все домены портированы. **Классификация методов M-final (важно для имплементации).** Не все 8 методов — агрегаторы; путать паттерн нельзя. | Метод | Тип | Sync поведение | Async поведение | |---|---|---|---| -| `account_health` | агрегатор (5 подзапросов) | последовательно вызывает `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary` (`avito/client.py:206-263`) | **`asyncio.TaskGroup`** на 5 параллельных подзапросов; per-section `_safe_summary_async` | -| `listing_health` | агрегатор (≥2 подзапроса) | вызывает `ad.list_items`, статистику, spendings (`avito/client.py:265-368`) | **`asyncio.TaskGroup`** на независимые ветки; зависимые ветки (нужен `item_ids` от первой) — последовательно, как sync | +| `account_health` | агрегатор с зависимостями | сначала `_resolve_user_id`; затем независимые ветки `balance`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`; `promotion_summary` зависит от `item_ids` из `listing_health` (`avito/client.py:206-263`) | **`asyncio.TaskGroup`** только для независимых веток после `user_id`; `promotion_summary` запускается после `listing_health`. Ошибки `balance`/`listing_health` пробрасываются как sync; chat/order/review/promotion остаются safe-секциями через `_safe_summary_async`. | +| `listing_health` | агрегатор с first-list dependency | сначала `ad.list(...)`, потом при наличии `item_ids` вызывает item stats, calls stats и spendings (`avito/client.py:265-368`) | список объявлений загружается первым; после получения `item_ids` **`asyncio.TaskGroup`** на независимые stats/calls/spendings. Spendings остаётся optional safe-секцией; stats/calls ошибки пробрасываются как sync. | | `business_summary` | **алиас** для `account_health` | `return self.account_health(...)` (`avito/client.py:184-204`) | `return await self.account_health(...)` — **никакого `TaskGroup`**, делегирование 1:1 | -| `chat_summary` | leaf (1 подзапрос) | один вызов `messenger`-домена | один `await` — параллелизма нет | -| `order_summary` | leaf | один подзапрос | один `await` | -| `review_summary` | leaf+safe | один подзапрос с `try/except` (`avito/client.py:396-410`) | один `await` + `_safe_summary_async` | -| `promotion_summary` | leaf | один подзапрос | один `await` | -| `capabilities` | probe-агрегатор | последовательно дёргает методы и ловит `AvitoError`, чтобы понять «доступно ли» (`avito/client.py:467+`) | **`asyncio.TaskGroup`** оправдан: каждая capability — независимый сетевой probe; параллельность экономит latency. Per-section `try/except AvitoError → CapabilityUnavailable` | +| `chat_summary` | leaf/sequential | `_resolve_user_id`, затем один вызов `messenger`-домена | последовательный `async def`; `TaskGroup` не нужен | +| `order_summary` | leaf | один вызов `orders`-домена | один `await`; `TaskGroup` запрещён | +| `review_summary` | small aggregator | `review().list()` optional-safe, `rating_profile().get()` required (`avito/client.py:396-429`) | допускается **`asyncio.TaskGroup`**: reviews через `_safe_summary_async`, rating как required task. `rating` error пробрасывается; reviews error превращается в unavailable section. | +| `promotion_summary` | conditional aggregator | `list_orders`; если `item_ids` переданы — дополнительно `list_services` (`avito/client.py:431-465`) | без `item_ids` один `await`; с `item_ids` допускается **`asyncio.TaskGroup`** на `list_orders` и `list_services`. | +| `capabilities` | статическая справка | не делает сетевых probe-запросов, только строит `CapabilityDiscoveryResult` из текущей конфигурации (`avito/client.py:467-531`) | остаётся sync-shaped CPU-only методом без `TaskGroup` и без сетевых вызовов. Если позже capabilities станет probe-методом, это отдельное API/behavior изменение с тестами. | -Правило: «параллелим только если sync делает 2+ независимых сетевых вызова». Алиасы -(`business_summary`) и leaf'ы (`chat_summary`, `order_summary`, `promotion_summary`) — -**обычные `async def` с одним `await`**, без `TaskGroup`. Это записано в DoD M-final -ниже как явная проверка через code review checklist. +Правило: параллелим только фактически независимые сетевые ветки и сохраняем sync +error semantics. Алиасы (`business_summary`), CPU-only методы (`capabilities`) и +leaf'ы (`order_summary`) не получают `TaskGroup`. Это записано в DoD M-final ниже +как явная проверка через code review checklist. **Cancellation-safe паттерн для агрегаторов (обязательный).** Используется `asyncio.TaskGroup` (Python 3.11+, у нас floor 3.12+) с per-section try/except, @@ -622,12 +680,14 @@ async def _safe_summary_async[T]( except AvitoError as error: return None, [_summary_unavailable_section(section, error)] -async def business_summary(self, ...) -> BusinessSummary: +async def account_health(self, ...) -> AccountHealthSummary: async with asyncio.TaskGroup() as tg: - t_acc = tg.create_task(_safe_summary_async("account", lambda: ...)) + t_balance = tg.create_task(self.account(resolved_user_id).get_balance()) + t_listings = tg.create_task(self.listing_health(...)) t_chat = tg.create_task(_safe_summary_async("chat", lambda: ...)) ... # После выхода из TaskGroup все таски завершены или отменены атомарно. + # Зависимая promotion ветка запускается после получения item_ids из listings. ``` При отмене внешнего вызова `TaskGroup` отменит все child-таски и пробросит @@ -661,28 +721,41 @@ M-final convenience-методы) `_handle` берёт `self._handle_lock = asyn **Инициализация lock'а в `__init__` (а не лениво).** Лениво создавать `asyncio.Lock` из `_handle` нельзя: две корутины, одновременно прошедшие `if self._handle_lock is None`, создадут разные lock-объекты — и сериализация сломается до первого `await`. -Поэтому `self._handle_lock = asyncio.Lock()` создаётся в `__init__` под текущий -event loop. Цена: `AsyncFakeTransport` нельзя переиспользовать между event loop'ами -(под `pytest-asyncio strict` это и так не происходит — каждый тест получает свой -loop). Документируется в docstring: «AsyncFakeTransport безопасен для concurrent +Поэтому `self._handle_lock = asyncio.Lock()` создаётся в `__init__`; экземпляр +`AsyncFakeTransport` создаётся внутри async-теста/loop'а, а lock биндится к loop'у +при первом `await`. Цена: `AsyncFakeTransport` нельзя переиспользовать между event +loop'ами (под `pytest-asyncio strict` это и так не происходит — каждый тест получает +свой loop). Документируется в docstring: «AsyncFakeTransport безопасен для concurrent access внутри одного event loop'а; создавать новый instance в каждом тесте; не переиспользовать между loop'ами». ## Swagger binding — детали изменений 1. `SwaggerOperationBinding` (`avito/core/swagger.py`): - - `variant: Literal["sync","async"] = "sync"` (frozen field, нормализация в `__post_init__` не нужна). + - `variant: Literal["sync","async"] = "sync"` (frozen field). - Декоратор `swagger_operation(..., variant: Literal["sync","async"] = "sync")`. + - `__post_init__` валидирует runtime-значение: любое значение кроме `"sync"` / + `"async"` даёт `ConfigurationError`, потому что `Literal` не защищает вызов + из runtime-кода. - Двойной декор одной функции остаётся `ConfigurationError`. 2. `DiscoveredSwaggerBinding` (`avito/core/swagger_discovery.py`): - `variant: Literal["sync","async"]` копируется из `SwaggerOperationBinding`. - `_iter_domain_modules` ищет в каждом пакете оба модуля: `.domain` и `.async_domain`. Если `async_domain` нет — игнорируем (это нормальная стадия миграции). - - `canonical_map` — ключ `f"{operation_key}\t{variant}"` (или вложенный mapping). + - `canonical_map` остаётся sync-only compatibility property, чтобы текущие + `tests/contracts/test_swagger_contracts.py` и report builder не получили + silent semantic break. Реализация явно фильтрует `variant == "sync"`, а не + "последний binding wins". + - новый API: `canonical_map_by_variant: Mapping[Literal["sync","async"], + Mapping[str, DiscoveredSwaggerBinding]]` и/или `binding_for(operation_key, + variant)`. Внутренний уникальный ключ — `(operation_key, variant)`. 3. `swagger_linter.py`: - `_validate_single_binding_per_sdk_method` — без изменений: ключ `binding.sdk_method` уникален даже в async (т.к. `module.class.method` отличается). - `_validate_duplicate_bindings` — ключ `(operation_key, variant)` вместо `operation_key`. Допустимо иметь две независимые цепочки (sync + async) на одну swagger-операцию. + - `_validate_factory` становится variant-aware: sync binding проверяет factory на + `AvitoClient`, async binding — на `AsyncAvitoClient`. Иначе можно получить + зелёный swagger-lint при отсутствии async factory-метода. - `_validate_complete_bindings(operations, bindings)` → `_validate_complete_bindings(operations, bindings, variant)`. Запускается дважды: - для `variant="sync"`: ожидаемое множество = все `operations` (как сейчас). - для `variant="async"`: ожидаемое множество = **per-class**, не per-domain. @@ -699,8 +772,9 @@ access внутри одного event loop'а; создавать новый in `AsyncAlternateTokenClient` соответственно (та же per-class логика). Это даёт два важных свойства: - 1. M1 фундамент мерджится: ни одного `Async` нет → expected = ∅, - линтер зелёный. + 1. M1 фундамент мерджится: для API-доменов ни одного `Async` нет → + domain expected = ∅; для auth expected включает только + `AsyncTokenClient` / `AsyncAlternateTokenClient` bindings. Линтер зелёный. 2. Большой домен (например, M11 `ads` с 3 классами `Ad`/`AutoloadProfile`/ `AutoloadReport`) теоретически можно разбить на под-PR'ы по классу; DoD M3…M12 всё равно требует закрытия домена на 100%, но per-class @@ -709,9 +783,34 @@ access внутри одного event loop'а; создавать новый in потом» — см. DoD M3…M12.) - `_validate_operation_spec_coverage` — без изменений (sync OperationSpec — единый источник истины для обоих режимов; реюз спеки между sync и async-методами не запрещён). `used_specs` — `set[id(spec)]`, поэтому одна и та же `OperationSpec` от sync и async binding'ов не дублируется и не теряется. - `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) — резолвит spec через `unwrapped_method.__globals__`. Async-методы должны импортировать spec явно (`from avito..operations import LIST_SPEC`), иначе резолв вернёт `()` и spec будет считаться unbound. Pre-flight тест проверяет, что это работает; если нет — расширяем функцию в Phase 1b. - - `_validate_json_body_model_coverage` — без изменений (контрактные схемы общие). - -4. `tests/contracts/test_async_parity.py` — новый тест, проверяет для каждого Async-класса: + - `_validate_json_body_model_coverage` — запускается по sync bindings; async + bindings проверяются через `AsyncSwaggerFakeTransport` contract suite, чтобы + не дублировать schema-lint ошибки на общих `OperationSpec`. + +4. `swagger_report.py` и docs report: + - `operations[].binding` остаётся sync-only compatibility field. + - добавляется `operations[].bindings_by_variant = {"sync": ..., "async": ...}`. + - `summary.bound/unbound/duplicate/ambiguous` остаются sync-only до отдельного + report API bump. + - добавляется `summary.variants.sync` и `summary.variants.async` с теми же + счётчиками. Для M1 async domain summary может быть `bound=0, expected=0`, + а async auth summary уже должен покрывать свои bindings; после M-final общий + async expected/bound = 204. + - `docs/site/assets/_gen_reference.py` и `reference/operations.md` показывают обе + SDK-ссылки, когда async binding уже существует, но не ломают текущую sync-карту. + +5. Contract tests: + - `tests/contracts/test_swagger_contracts.py` фильтрует bindings по + `variant="sync"` и сохраняет текущий exhaustive sync behavior. + - новый `tests/contracts/test_async_swagger_contracts.py` — Swagger-spec + compliance test, а не architecture/introspection test: для каждого discovered + binding с `variant="async"` `AsyncSwaggerFakeTransport` строит + `AsyncAvitoClient`, вызывает async SDK method через `await`, валидирует + фактический request против Swagger и проверяет success/error payload mapping. + В M1 он покрывает async auth-bindings; в M2+ автоматически расширяется на + портированные домены. + +6. `scripts/lint_async_parity.py` — static linter, проверяет для каждого Async-класса: - имя `Async` ↔ существует sync `` в том же пакете; - множество публичных async-методов (`async def` без префикса `_`) совпадает с sync-методами; - перебор методов фильтруется по `func.__qualname__.startswith(cls.__name__ + ".")`, @@ -720,8 +819,16 @@ access внутри одного event loop'а; создавать новый in - для каждой пары `(sync_method, async_method)`: - `inspect.signature(sync).parameters` (без `self`) == `inspect.signature(async).parameters`; - аннотация возврата либо совпадает, либо `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`, - либо `Paginator[T]` ↔ `AsyncPaginator[T]`; + либо `BinaryResponse`/wrapper-модель совпадает напрямую; `Paginator[T] ↔ + AsyncPaginator[T]` допускается только если в будущем появится публичный + sync-метод, который реально возвращает `Paginator[T]`; - оба декорированы `@swagger_operation` на ту же `(spec, method, path, operation_id)`, отличаясь только `variant`. + - для каждой async class-level `__sdk_factory__` проверяет, что такой factory + существует на `AsyncAvitoClient`, имеет сигнатуру, совместимую с sync factory + на `AvitoClient`, и возвращает соответствующий `Async`. + Этот linter вызывается из `make quality`; pytest не содержит parity/introspection + тестов, потому что STYLEGUIDE разрешает в pytest только functional tests и + Swagger-spec compliance tests. ## Этапы @@ -733,9 +840,10 @@ access внутри одного event loop'а; создавать новый in зафиксировать все private probes; убедиться, что compat-shim в `AuthProvider` покроет каждый. Найденный сейчас кейс: `tests/core/test_authentication.py:122-127`. - [ ] `grep -rn "\bPaginator\b" avito/` — зафиксировать все 4 usage-сайта - (`avito/ads/domain.py:266,1183`, `avito/accounts/domain.py:170,383`); они - определяют, нужен ли `AsyncPaginator.iter_pages()` и/или `as_list()` уже в M1 - или доставляется в первом домене с пагинацией (M4 `accounts`). + (`avito/ads/domain.py:266,1183`, `avito/accounts/domain.py:170,383`). + Все текущие usage-сайты завершаются `.as_list(...)`; прямого публичного + возврата `Paginator` нет. `AsyncPaginator.as_list()` нужен уже к M4 + (`accounts`), но root-level export `AsyncPaginator` не нужен. - [ ] `grep -rn "len(.*Paginated\|\\b[a-z_]*list\\[[0-9-]" avito/ tests/` — найти все потребители list-API на `PaginatedList[T]` (индексация, `len`, `bool`, slice). `AsyncPaginatedList` намеренно НЕ повторяет list-API: каждый такой кейс должен @@ -750,37 +858,78 @@ access внутри одного event loop'а; создавать новый in значит требуется Python **3.12+**. Все async-контракты (`type AsyncPageFetcher`, `async def execute[ResponseT]`) сохраняют этот же floor; повышать не нужно, но явно зафиксировать в M1 PR description. -- [ ] Прогон `pytest -q` на чистом `main` — сохранить файл baseline-теста pass/fail - статусов (`pytest --tb=no -q > /tmp/baseline_main.txt`). Используется в DoD M1. +- [ ] Прогон baseline на чистом `main` — сохранить **nodeid существующих тестов** и + их pass/fail статусы: + `poetry run pytest --collect-only -q tests/core tests/auth tests/domains tests/contracts | grep '::' > /tmp/baseline_nodeids.txt` + и затем `poetry run pytest -q --tb=no $(cat /tmp/baseline_nodeids.txt) > + /tmp/baseline_main.txt`. Используется в DoD M1; новые async tests после M1 + не входят в baseline-сравнение. - [ ] Проверить, что `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) работает с `async_domain.py`: тест-стаб с `async def m(self): return self._execute(SOME_SPEC)` и `from ...operations import SOME_SPEC` — функция должна найти `SOME_SPEC` через `unwrapped_method.__globals__`. Если не работает — расширить функцию (Phase 1b), иначе оставить без изменений. +- [ ] Прочитать `docs/site/assets/_gen_reference.py` целиком и зафиксировать + существующие точки фильтрации: `PACKAGE_ROOT.glob("*/domain.py")`, + `EXCLUDED_PACKAGES`, `public_domain_classes()` (фильтр по `DomainObject`-наследованию + и `value.__module__.startswith(f"avito.{package}.")`), `public_domain_methods()` + (фильтр по `value.__qualname__.startswith(f"{domain_class.__name__}.")`). + Расширение builder'а в M1 обязано переиспользовать ровно эту логику для + `async_domain.py` + `AsyncDomainObject`-наследников и не полагаться только + на `avito..__all__`. Без этого reference будет несимметричным. +- [ ] Прочитать `scripts/lint_architecture.py` и `scripts/lint_docstrings.py`: + текущие проверки смотрят только `domain.py` и `ast.FunctionDef`. M1 обязан + расширить их на `async_domain.py` и `ast.AsyncFunctionDef`. +- [ ] Прочитать `avito/core/deprecation.py`: текущий `deprecated_method` возвращает + sync-wrapper. M1 обязан добавить async-aware wrapper до портирования + deprecated методов `cpa`/`ads`. ### M1 — Фундамент (1 PR) DoD: -- [ ] `make check` зелёный: test, typecheck (mypy strict), lint (ruff), swagger-lint --strict, architecture-lint, docstring-lint, build. +- [ ] `make check` зелёный: test, typecheck (mypy strict), lint (ruff), + swagger-lint --strict, architecture-lint, async-parity-lint, + docstring-lint, build. +- [ ] `make docs-strict` зелёный: M1 правит `STYLEGUIDE.md`, + `swagger-binding-subsystem.md` и `domain-architecture-v2.md` + расширяет + `_gen_reference.py` (см. таблицу «Существующие, изменяются в M1»). Без правки + `STYLEGUIDE.md` план формально противоречит нормативному sync-only тексту. + Без зелёного docs-strict нельзя гарантировать, что reference-builder в M2-PoC + увидит первый `Async`. Если на M1 ещё ни одного `Async` нет — builder + проверяется на нейтральность (sync reference генерится идентично baseline'у). - [ ] Покрытие тестами фундамента не ниже sync-аналогов (sample проверка по `coverage report`). - [ ] Smoke-тест: `AsyncAvitoClient` через `AsyncFakeTransport` (без respx) делает один авторизованный запрос; токен рефрешится после 401; retry на 429 срабатывает; `Idempotency-Key` пробрасывается; `aclose()` корректно закрывает `httpx.AsyncClient` и `AsyncAuthProvider`. +- [ ] Ownership test: `AsyncTransport.aclose()` закрывает переданный + `httpx.AsyncClient`, потому что это выбранная mirror-политика текущего sync + `Transport.close()`. Тест отдельно покрывает idempotent double-close. +- [ ] Async auth public surface зеркалит sync: `AsyncAvitoClient.auth()` возвращает + `AsyncAuthProvider`, а `token_flow()` / `alternate_token_flow()` возвращают + async token clients с `variant="async"` bindings. - [ ] Документация `swagger-binding-subsystem.md` отражает variant и class-gated coverage. -- [ ] Публичная sync-поверхность не изменилась — formal: `pytest -q tests/core/ tests/auth/ tests/domains/ tests/contracts/ --tb=no` имеет идентичный список pass/fail с baseline-теста с `main` (см. pre-flight). Любое расхождение = blocker, до выяснения причины PR не мерджится. +- [ ] `AsyncSwaggerFakeTransport` добавлен и экспортирован из `avito.testing`; async + contract suite зелёный для discovered async bindings (`auth` в M1, домены + появляются позже). +- [ ] Публичная sync-поверхность не изменилась — formal: pass/fail статусы + **только baseline nodeids из `/tmp/baseline_nodeids.txt`** идентичны + baseline-тесту с `main` (см. pre-flight). Новые async tests не участвуют + в сравнении. Любое расхождение по старым nodeid = blocker. - [ ] Phase 1a (`_merge_headers` рефакторинг) выделен отдельным коммитом внутри PR — для bisect-friendly history. -- [ ] CHANGELOG `## [Unreleased]` дополнен: `- Фундамент Async API: AsyncTransport, +- [ ] CHANGELOG `## [Unreleased]` в корневом `CHANGELOG.md` дополнен: + `- Фундамент Async API: AsyncTransport, AsyncAuthProvider, AsyncOperationExecutor, AsyncPaginatedList, - AsyncAvitoClient (без factory-методов доменов); RateLimitState вынесен в - shared`. + AsyncAvitoClient (без factory-методов доменов); RateLimitState вынесен в shared`. ### M2-PoC — Proof-of-concept шаблона (отдельный PR, до переработки доменов) -**Цель этого шага — НЕ закрыть домен `tariffs`, а валидировать шаблон.** Это -осознанное исключение из правила «домен закрывается на 100%»: PoC может вернуть +**Цель этого шага — валидировать шаблон на минимальном домене и при этом закрыть +`tariffs` полностью.** Это не "частичный доменный PR": к merge `tariffs` должен +иметь async-поверхность, тесты, swagger coverage и reference 1:1. PoC может вернуть feedback вида «контракт `AsyncPaginator` нужно расширить», «discovery не видит -spec», «mypy strict ругается на covariance возврата» — и это нормальный ожидаемый +spec», «mypy strict ругается на covariance возврата» — и это нормальный ожидаемый выход. Все правки контракта вносятся в **этот же PR**, а если правки требуют переработки M1-фундамента — PoC откатывается, фундамент дорабатывается отдельным -PR, после чего PoC переоткрывается. +PR, после чего PoC переоткрывается. M3 не начинается, пока M2-PoC не зелёный и +`tariffs` не закрыт на 100%. PoC берёт `tariffs` (1 sync-операция с binding) — минимальная поверхность без пагинации, без autoteka-flow, без write-методов. Этого достаточно, чтобы ткнуть @@ -789,21 +938,26 @@ PoC берёт `tariffs` (1 sync-операция с binding) — минимал DoD M2-PoC: - [ ] `avito/tariffs/async_domain.py` создан, `AsyncTariff` зеркалит `Tariff` ровно по 1 публичному методу. +- [ ] `avito/tariffs/__init__.py` экспортирует `AsyncTariff` рядом с `Tariff`. - [ ] `AsyncAvitoClient.tariff()` factory-метод возвращает `AsyncTariff`. - [ ] `tests/domains/tariffs/test_tariffs_async.py` зеркалит sync-тест 1:1 (golden path + 401 + 429 + transport error). Все тесты зелёные. - [ ] `make check` зелёный, включая `swagger-lint --strict` (для `tariffs` теперь требуется async-coverage 1:1). -- [ ] `tests/contracts/test_async_parity.py` зелёный. -- [ ] Документация `docs/site/reference/tariffs.md` дополнена async-секцией. +- [ ] `scripts/lint_async_parity.py` зелёный. +- [ ] `tests/contracts/test_async_swagger_contracts.py` зелёный для async auth + + `tariffs`. +- [ ] Документация generated reference для `docs/site/reference/domains/tariffs.md` + содержит async-секцию. +- [ ] **`_gen_reference.py` валидируется на реальном домене**: после расширения builder'а в M1 на M2-PoC он впервые видит `AsyncTariff` и должен сгенерировать reference-страницу с обоими классами (`Tariff` + `AsyncTariff`). `make docs-strict` зелёный, в `docs/site/reference/domains/tariffs.md` в выхлопе билда присутствуют обе секции. Если builder требует доработки — она входит в этот же PR (это и есть смысл PoC). Конкретно в `_gen_reference.py`: `public_domain_packages()` дополнительно возвращает пакет, если есть `*/async_domain.py`; `public_domain_classes()` импортирует `avito..domain` и `avito..async_domain` напрямую, а не только `avito..__all__`; `Async` фильтруется через `cls.__name__.startswith("Async")` + `issubclass(AsyncDomainObject)`; `EXCLUDED_PACKAGES` остаётся прежним; для `auth` (исключён) async-классы reference не получают. - [ ] **Lessons learned зафиксированы** в `docs/site/explanations/async-domain-template.md` (новый файл): шаблон файла `async_domain.py`, чек-лист переноса домена, найденные подводные камни. Этот документ становится нормативным для M3+. - [ ] Если в ходе PoC понадобились изменения контракта (`AsyncPaginator`/`AsyncFakeTransport`/ `swagger_linter`/`AsyncAuthProvider`), они **внесены в этот же PR** или вынесены в отдельный M1.5-PR, но **до** старта M3. -- [ ] CHANGELOG `## [Unreleased]` дополнен: `- Async-поддержка домена tariffs: - AsyncTariff (PoC шаблона)`. +- [ ] Корневой `CHANGELOG.md` (`## [Unreleased]`) дополнен: + `- Async-поддержка домена tariffs: AsyncTariff (PoC шаблона)`. ### M3…M12 + M-final — Закрытие доменов (по PR на домен) @@ -819,9 +973,9 @@ DoD M2-PoC: | M8 | `jobs` | 25 | webhook-методы (REST) | | M9 | `promotion` | 24 | без пагинации | | M10 | `autoteka` | 26 | использует autoteka token flow → end-to-end проверка `AsyncAuthProvider.get_autoteka_access_token` + `_autoteka_refresh_lock` под нагрузкой (concurrent first-touch) | -| M11 | `ads` | 28 | вторая и третья `AsyncPaginatedList` (`Ad.list`, `AutoloadProfile`/`AutoloadReport.list`); прямой возврат `AsyncPaginator` (`avito/ads/domain.py:266`) | +| M11 | `ads` | 28 | вторая и третья `AsyncPaginatedList` (`Ad.list`, `AutoloadReport.list`); сложный offset/limit first-page reuse в `Ad.list` (`avito/ads/domain.py:266`) | | M12 | `orders` | 45 | самый большой; идемпотентность критична | -| M-final | — | — | convenience-методы `AsyncAvitoClient`: только агрегаторы (`account_health`, `listing_health`, `capabilities`) реализуются через `asyncio.TaskGroup` с per-section error handling (`_safe_summary_async`); leaf'ы (`chat_summary`, `order_summary`, `promotion_summary`, `review_summary`) — обычный `async def` с одним `await`; алиас `business_summary` делегирует в `account_health`. `asyncio.gather(return_exceptions=True)` запрещён. Финальный hardening; `docs/site/how-to/async.md`; CHANGELOG `## [Unreleased]` → `## [2.1.0]` (свод накопленных пунктов из M1…M12 + запись про convenience-методы). | +| M-final | — | — | convenience-методы `AsyncAvitoClient`: `account_health`, `listing_health`, `review_summary` и `promotion_summary` используют `asyncio.TaskGroup` только там, где есть фактически независимые сетевые ветки; `business_summary` делегирует в `account_health`; `chat_summary`/`order_summary` остаются sequential leaf; `capabilities` остаётся CPU-only без сетевых probe-запросов. `asyncio.gather(return_exceptions=True)` запрещён. Финальный hardening; `docs/site/how-to/async.md`; CHANGELOG `## [Unreleased]` → `## [2.1.0]` (свод накопленных пунктов из M1…M12 + запись про convenience-методы). | Содержимое каждого M3…M12: @@ -834,21 +988,25 @@ DoD M2-PoC: 2. **Каждый** публичный метод декорируется `@swagger_operation(..., variant="async")` теми же аргументами `(method, path, spec, operation_id, factory, factory_args, method_args, deprecated, legacy)`, что и sync. -3. Регистрация **всех** `Async` домена в `AsyncAvitoClient` (factory-методы по +3. `avito//__init__.py` экспортирует **все** `Async` класса домена рядом + с sync-классами, чтобы mkdocstrings, IDE и generated reference видели публичную + async-поверхность. +4. Регистрация **всех** `Async` домена в `AsyncAvitoClient` (factory-методы по именам, идентичным sync). -4. `tests/domains//test__async.py` — зеркало +5. `tests/domains//test__async.py` — зеркало `tests/domains//test_.py`, через `AsyncFakeTransport`. Тесты помечаем `@pytest.mark.asyncio`. **Каждый** sync-тест имеет async-двойник с тем же сценарием. -5. Если в домене есть пагинация — соответствующие методы возвращают - `AsyncPaginatedList[T]` или `AsyncPaginator[T]` (зеркально sync). M4 `accounts` — - первый домен с `AsyncPaginatedList`; M11 `ads` — первый домен с прямым - `AsyncPaginator` (см. `avito/ads/domain.py:266`). -6. `docs/site/reference/.md` дополняется async-секцией (или второй колонкой). -7. Если в домене есть write-методы с `dry_run` — async-двойник реализует тот же +6. Если в домене есть пагинация — соответствующие методы возвращают + `AsyncPaginatedList[T]` (зеркально sync `PaginatedList[T]`). M4 `accounts` — + первый домен с `AsyncPaginatedList`; M11 `ads` проверяет сложный first-page + reuse в `Ad.list`. +7. Generated reference `docs/site/reference/domains/.md` дополняется + async-секцией (или второй колонкой). +8. Если в домене есть write-методы с `dry_run` — async-двойник реализует тот же контракт: при `dry_run=True` транспорт **не вызывается** (тест проверяет `count(method=..., path=...) == 0`). -8. Если в домене есть idempotency-key поведение — async-тесты явно проверяют +9. Если в домене есть idempotency-key поведение — async-тесты явно проверяют проброс заголовка `Idempotency-Key`. ### Definition of done каждого M3…M12 — закрыть домен на 100%, без работы на потом @@ -856,44 +1014,77 @@ DoD M2-PoC: «100%» определяется проверяемо. Все пункты ниже — **обязательные**, не «nice to have»: - [ ] **Покрытие методов 1:1**: для каждого публичного sync-метода домена есть - async-двойник; `tests/contracts/test_async_parity.py` зелёный для домена. + async-двойник; `scripts/lint_async_parity.py` зелёный для домена. Локальная проверка: `python -c "from avito..domain import *; from - avito..async_domain import *"` + parity-test без skip-маркеров. + avito..async_domain import *"` + `scripts/lint_async_parity.py` + без allowlist/skip для текущего домена. - [ ] **Покрытие тестов 1:1**: каждый сценарий из `tests/domains//test_*.py` - имеет async-двойник; счётчики тестов сверены: `pytest --collect-only -q - tests/domains// | grep -c "test_.*async\|test_.*[^c]$"` показывает - идентичное количество sync- и async-тестов. Покрываются: golden path, 401, + имеет async-двойник; счётчики тестов сверены отдельными командами: + `pytest --collect-only -q tests/domains//test_.py | grep -c "::test_"` + и `pytest --collect-only -q tests/domains//test__async.py | grep -c "::test_"` + показывают одинаковое число. Покрываются: golden path, 401, 403, 422, 429, transport error/timeout, пагинация (если есть), idempotency (для write), `dry_run` (если есть в sync). - [ ] **Swagger-lint coverage 1:1 для домена**: `swagger-lint --strict` после этапа требует async binding для **каждой** swagger-операции этого домена; class-gated coverage гейт включён, и domain больше не «пуст по async». Никаких исключений/skip'ов для отдельных методов. -- [ ] **Документация**: `docs/site/reference/.md` содержит async-секцию для +- [ ] **Async Swagger contract coverage**: `tests/contracts/test_async_swagger_contracts.py` + вызывает **каждый** async binding домена через `AsyncSwaggerFakeTransport` и + валидирует request/response/error contract. Это обязательный Swagger-spec + compliance test, поэтому он разрешён STYLEGUIDE. +- [ ] **Документация**: generated `docs/site/reference/domains/.md` содержит async-секцию для **всех** портированных классов; `make docs-strict` зелёный; ссылки и примеры кода скомпилированы. - [ ] **Никаких TODO/FIXME/`pytest.skip`/`xfail` в добавленных файлах**: `git diff main..HEAD -- avito// tests/domains// | grep -E "TODO|FIXME|@pytest.mark.skip|xfail"` пуст. Любая отсрочка работы = blocker. +- [ ] **Сообщения ошибок только на русском** (STYLEGUIDE.md, секция «Errors»): + все новые `raise ("...")` в `async_domain.py` пишутся по-русски, + без английских вкраплений. Code review checklist; `make lint` напрямую этого + не ловит, но смешанные языки — формальный blocker. Если sync-аналог уже + использует английский (legacy) — оставляем как есть в sync, а в async + пишем по-русски и заводим отдельный issue на миграцию sync. - [ ] **`make check` локально и в CI зелёный**. - [ ] **AsyncAvitoClient полностью настроен для домена**: factory-методы возвращают готовые объекты, lifecycle (`aclose`/`__aexit__`) корректно закрывает все ресурсы домена. - [ ] **Регрессия sync = 0**: список pass/fail sync-тестов идентичен предыдущему этапу (sanity-проверка через сравнение `pytest -q --tb=no` до и после). -- [ ] **Cumulative parity invariant**: после merge'а `tests/contracts/test_async_parity.py` - зелёный для **всех** уже портированных доменов (включая текущий). Этап не - может ослабить инвариант для предыдущих доменов. +- [ ] **Cumulative parity invariant**: после merge'а `scripts/lint_async_parity.py` + и `tests/contracts/test_async_swagger_contracts.py` зелёные для **всех** уже + портированных доменов (включая текущий). Этап не может ослабить инвариант + для предыдущих доменов. - [ ] **Нет работы «потом»**: переоткрытие PR с фразой «допилю в следующем PR» запрещено. Если scope не закрывается — PR разделяется или раздвигается, но не оставляется частичный домен в main. -- [ ] **CHANGELOG обновлён**: в `docs/CHANGELOG.md` (раздел `## [Unreleased]`) +- [ ] **CHANGELOG обновлён**: в корневом `CHANGELOG.md` (раздел `## [Unreleased]`) добавлена строка вида `- Async-поддержка домена : Async, Async (#)`. M-final сводит накопленные `Unreleased`-строки в релиз 2.1.0, добавляя только запись про convenience-методы и `AsyncAvitoClient`-агрегаторы. Без этого history-readers не увидят, в каком PR домен стал async, и release notes 2.1.0 не получится собрать механически. +### Definition of done M-final — релиз 2.1.0 + +«Финальный hardening» определяется проверяемо: + +- [ ] **Convenience-методы реализованы по таблице классификации** (агрегатор / алиас / leaf / CPU-only). Code review проверяет: `asyncio.TaskGroup` стоит только в ветках с фактически независимыми сетевыми вызовами (`account_health`, `listing_health`, `review_summary`, `promotion_summary` при наличии `item_ids`); в `business_summary` — `return await self.account_health(...)` без `TaskGroup`; `chat_summary` и `order_summary` sequential; `capabilities` не делает сетевых probe-запросов и не использует `TaskGroup`. Любое нарушение = blocker. +- [ ] **`_safe_summary_async` живёт в одном модуле с sync `_safe_summary`** — `avito/client.py` (вынесение в общий `avito/summary/_helpers.py` допускается, но требует одновременного переноса sync `_safe_summary`; частичное вынесение запрещено, чтобы не разделять симметричные хелперы по разным файлам). Импорт в `avito/async_client.py` явный. +- [ ] **Версия пакета поднята до 2.1.0**: `poetry version 2.1.0`, изменение в `pyproject.toml` зафиксировано в M-final PR. CHANGELOG `## [Unreleased]` → `## [2.1.0] - YYYY-MM-DD`, накопленные строки M1…M12 + запись про convenience-методы и `AsyncAvitoClient`-агрегаторы сведены в один раздел. `git tag v2.1.0` ставится после merge M-final. +- [ ] **`AsyncSwaggerFakeTransport` contract suite полный**: `tests/contracts/test_async_swagger_contracts.py` + вызывает все async bindings (204 Swagger operations, включая auth-bindings) + и проверяет success/error/request-body schema, как sync contract suite. +- [ ] **`docs/site/how-to/async.md` написан**: контракт lifecycle (`async with` обязателен), пример с `AsyncFakeTransport`, миграционный гайд «как переписать sync-вызов на async», ограничения (`AsyncPaginatedList` не list-API, full-buffer download, нет streaming). Ссылки из `docs/site/index.md` и `docs/site/how-to/index.md`. +- [ ] **README/site wording обновлены**: `README.md`, `mkdocs.yml`, `docs/site/index.md`, + `docs/site/reference/client.md`, `docs/site/reference/pagination.md`, + `docs/site/reference/testing.md` больше не называют SDK только синхронным. +- [ ] **`make check` + `make docs-strict` зелёные**; `scripts/lint_async_parity.py` + и `tests/contracts/test_async_swagger_contracts.py` зелёные для всех 11 API-доменов + + auth-bindings. +- [ ] **Cumulative coverage**: после M-final swagger-lint --strict требует обоюдное 1:1 (sync + async) для всех 204 операций. Любой пропуск = blocker; никаких «допилим в 2.1.1». +- [ ] **CHANGELOG release-ready**: запись 2.1.0 содержит: фундамент Async API, по строке на каждый портированный домен (агрегируется из `## [Unreleased]`-записей M1…M12), convenience-методы `AsyncAvitoClient`. Release notes 2.1.0 собираются механически — это и есть проверка дисциплины M3…M12. + ## Верификация (как проверить, что план сработал) ### M1 @@ -902,11 +1093,12 @@ poetry install make test # sync + новые async unit-тесты make typecheck # mypy strict — все Awaitable[T], AsyncPaginatedList[T] корректны make lint # ruff -make swagger-lint # 1) sync coverage 1:1 как сейчас; 2) async coverage пуст и не падает +make swagger-lint # sync 1:1; async auth 1:1, domain expected пуст +make async-parity-lint # static Async ↔ X checks, не pytest make check # финальный гейт poetry run pytest tests/core/test_async_transport.py tests/core/test_async_pagination.py \ tests/core/test_async_executor.py tests/core/test_async_client_lifecycle.py \ - tests/auth/test_async_provider.py tests/contracts/test_async_parity.py + tests/auth/test_async_provider.py tests/contracts/test_async_swagger_contracts.py ``` Ручной smoke (M1, в тесте — не на проде; через `AsyncFakeTransport`, без `respx`): @@ -939,7 +1131,8 @@ asyncio.run(main()) ### M2-PoC (proof-of-concept) ```bash poetry run pytest tests/domains/tariffs/ # sync + async для tariffs -poetry run pytest tests/contracts/test_async_parity.py # parity для tariffs +make async-parity-lint # parity для tariffs как static lint +poetry run pytest tests/contracts/test_async_swagger_contracts.py make swagger-lint # async-coverage 1:1 для tariffs make check # Артефакт: docs/site/explanations/async-domain-template.md создан @@ -955,7 +1148,8 @@ poetry run pytest tests/domains// # sync + async poetry run pytest -q --tb=no tests/domains//test_.py > /tmp/sync_after.txt diff /tmp/sync_before.txt /tmp/sync_after.txt # должен быть пустой -poetry run pytest tests/contracts/test_async_parity.py # parity для всех закрытых доменов +make async-parity-lint # parity для всех закрытых доменов +poetry run pytest tests/contracts/test_async_swagger_contracts.py make swagger-lint # async-coverage 1:1 для этого домена # Грязные следы — пустой выхлоп @@ -976,12 +1170,42 @@ make docs-strict make check make docs-strict poetry run pytest # полный набор + +# Версия и release notes +poetry version 2.1.0 # бамп до 2.1.0 +grep -E "^## \[2\.1\.0\]" CHANGELOG.md # секция 2.1.0 существует +grep -E "^## \[Unreleased\]" CHANGELOG.md # Unreleased пуст или содержит только заголовок + +# Reference после билда содержит обе поверхности на каждом домене +poetry run mkdocs build --strict 2>&1 | tee /tmp/mkdocs.log +for cls in AsyncTariff AsyncReview AsyncReviewAnswer AsyncRatingProfile AsyncAccount \ + AsyncAccountHierarchy AsyncRealtyListing AsyncRealtyBooking AsyncRealtyPricing \ + AsyncRealtyAnalyticsReport AsyncCpaLead AsyncCpaChat AsyncCpaCall AsyncCpaArchive \ + AsyncCallTrackingCall AsyncChat AsyncChatMessage AsyncChatWebhook AsyncChatMedia \ + AsyncSpecialOfferCampaign AsyncVacancy AsyncApplication AsyncResume AsyncJobWebhook \ + AsyncJobDictionary AsyncPromotionOrder AsyncBbipPromotion AsyncTrxPromotion \ + AsyncCpaAuction AsyncTargetActionPricing AsyncAutostrategyCampaign AsyncAutotekaVehicle \ + AsyncAutotekaReport AsyncAutotekaMonitoring AsyncAutotekaScoring AsyncAutotekaValuation \ + AsyncAd AsyncAdStats AsyncAdPromotion AsyncAutoloadProfile AsyncAutoloadReport \ + AsyncAutoloadArchive AsyncOrder AsyncOrderLabel AsyncDeliveryOrder AsyncSandboxDelivery \ + AsyncDeliveryTask AsyncStock; do + grep -R -q "$cls" site/reference/domains || echo "MISSING async section: $cls" +done + +# После merge +git tag v2.1.0 +git push --tags ``` После M-final: -- swagger-lint --strict требует обоюдное 1:1 покрытие (sync + async) для всех 12 доменов и +- swagger-lint --strict требует обоюдное 1:1 покрытие (sync + async) для всех 11 API-доменов и auth-bindings; -- `tests/contracts/test_async_parity.py` зелёный для всех доменов; +- `scripts/lint_async_parity.py` и `tests/contracts/test_async_swagger_contracts.py` + зелёные для всех доменов; +- `pyproject.toml` версия = 2.1.0; корневой `CHANGELOG.md` содержит `## [2.1.0]` с агрегированной + историей M1…M12 + convenience-методы; +- `docs/site/reference/domains//` для каждого домена показывает обе классовые + поверхности (sync + async); - релиз 2.1.0 с CHANGELOG: «двухрежимный SDK, AsyncAvitoClient». ## Риски и mitigations @@ -990,31 +1214,37 @@ poetry run pytest # полный наб |---|---| | Расхождение retry/auth-логики sync vs async | Вся не-IO логика — в `_transport_shared.py` и `_cache.py`, обе обёртки делегируют. | | `RateLimiter` неприменим к async (sleep + `threading.Lock` запечены в `acquire()`) | Декомпозиция в три части: pure `RateLimitState.compute_delay()` в shared (без sleep, без lock), sync `RateLimiter` поверх (`threading.Lock` + `time.sleep`), отдельный `AsyncRateLimiter` (`asyncio.Lock` + `await asyncio.sleep`). State **не** делится между режимами — sync и async транспорты независимы. | -| `_resolve_user_id` в async идёт через сырой `request_json`, а не через executor | Async-двойник обязан использовать `await self._execute(WHOAMI_SPEC)`, как sync. Иначе swagger-binding для `/core/v1/accounts/self` не покрыт async-coverage'ом, parity-test и `_validate_complete_bindings` падают. | +| `_resolve_user_id` в async расходится с sync fallback-порядком | Async-двойник повторяет текущий sync helper: argument → `settings.user_id` → raw `/core/v1/accounts/self` через transport. Публичный Swagger binding `/core/v1/accounts/self` покрывается `AsyncAccount.get_self()`, не internal helper-ом. | | `download_binary` в async может неявно стать streaming, расходясь с sync | M1 фиксирует full-buffer-семантику (`await response.aread()`), как sync. Streaming — отдельный API после 2.1.0 с симметричным sync-аналогом. Закреплено тестом `test_download_binary_full_buffer_matches_sync`. | -| Convenience-метод М-final реализован как «sync с обмазанным await» (потеря параллелизма) ИЛИ leaf-метод обёрнут в ненужный `TaskGroup` | DoD M-final проверяет классификацию по таблице: TaskGroup — только для агрегаторов с 2+ независимыми сетевыми вызовами (`account_health`, `listing_health`, `capabilities`); алиасы и leaf'ы — обычный `async def` с одним `await`. Code review checklist. | +| Convenience-метод М-final реализован как «sync с обмазанным await» (потеря параллелизма) ИЛИ leaf/CPU-only метод обёрнут в ненужный `TaskGroup` | DoD M-final проверяет классификацию по фактическому sync-коду: `TaskGroup` только для независимых сетевых веток (`account_health`, `listing_health`, `review_summary`, `promotion_summary` при `item_ids`); `business_summary` — алиас; `chat_summary`/`order_summary` — sequential; `capabilities` — CPU-only без network probes. | | Class-gated swagger-coverage применён per-domain → большой домен (`ads`) нельзя разбить, либо мини-домен с двумя классами требует доделки до merge'а | Class-gated применяется **per-class**: `Async` существует ↔ все операции класса `` обязаны иметь async-binding. Отсутствие `Async` в том же домене не блокирует мердж класса `Async`. DoD M3…M12 всё равно требует домен закрыть на 100%. | -| `from_env` инициализирует loop-зависимые ресурсы вне loop'а → cross-loop UB | `from_env` синхронен, ресурсы (`httpx.AsyncClient`, `asyncio.Lock`) создаются в `__aenter__`. Доступ к `transport`/`auth_provider` до `__aenter__` бросает `RuntimeError` с понятным сообщением. Закреплено тестом `test_access_before_aenter_raises`. | -| Release notes 2.1.0 невозможно собрать механически, потому что в PR M3…M12 нет CHANGELOG-записей | DoD M3…M12 требует `## [Unreleased]` строку в `docs/CHANGELOG.md` на каждый PR. M-final сводит накопленное в `## [2.1.0]`. | +| `from_env` инициализирует loop-зависимые ресурсы вне loop'а → cross-loop UB | `from_env` синхронен, SDK-managed ресурсы (`httpx.AsyncClient`, `asyncio.Lock`) создаются в `__aenter__`. Если внешний `http_client` передан пользователем, transport связывается с ним только в `__aenter__`. Доступ к `transport`/`auth_provider` до `__aenter__` бросает `RuntimeError` с понятным сообщением. Закреплено тестом `test_access_before_aenter_raises`. | +| Release notes 2.1.0 невозможно собрать механически, потому что в PR M3…M12 нет CHANGELOG-записей | DoD M3…M12 требует `## [Unreleased]` строку в корневом `CHANGELOG.md` на каждый PR. M-final сводит накопленное в `## [2.1.0]`. | | `_merge_headers` срытно делает sync IO (`get_access_token()`) | Phase 1a первым шагом рефакторит контракт: helper принимает уже резолвнутый `bearer_token: str | None`. Без этого shared слой не IO-agnostic, и vary-логика расползётся. | -| `AsyncPaginatedList` не наследует `list` → ломаются ожидания сервисов | Документируем в docstring; parity-test допускает `PaginatedList[T]` ↔ `AsyncPaginatedList[T]` и `Paginator[T]` ↔ `AsyncPaginator[T]`. List-API не реплицируется намеренно. | -| `AsyncPaginator` не покрывает кейс прямого возврата `Paginator` без `as_list()` | Контракт `AsyncPaginator` симметричен sync (`iter_pages`/`collect`/`as_list`); все 5 текущих usage-сайтов покрыты. | +| `AsyncPaginatedList` не наследует `list` → ломаются ожидания сервисов | Документируем в docstring; `scripts/lint_async_parity.py` допускает `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`. List-API не реплицируется намеренно. | +| `AsyncPaginator` не покрывает helper usage `Paginator(...).as_list(...)` | Контракт `AsyncPaginator` симметричен sync (`iter_pages`/`collect`/`as_list`); все 4 текущих usage-сайта покрыты через методы, возвращающие `AsyncPaginatedList[T]`. | | Auth-bindings не попадают в async-coverage | `_NON_DOMAIN_BINDING_MODULES` дополнен строго `"avito.auth.async_token_client"`; class-gated coverage гейтится по присутствию `AsyncTokenClient`/`AsyncAlternateTokenClient`. | | Двойной декор одной функции | Текущая защита `__swagger_binding__` остаётся; sync и async — разные функции. | | Гонка на основном refresh-токене в async | `asyncio.Lock` (`_refresh_lock`) в `AsyncAuthProvider` + double-checked pattern (как sync, но через `await`). | -| Гонка на autoteka-токене в async | Отдельный `_autoteka_refresh_lock` + double-checked в `get_autoteka_access_token()`. Sync аналога не имел, потому что в sync GIL предотвращает деление instruction stream между потоками; в async это явная race-condition. | +| Гонка на autoteka-токене в async | Отдельный `_autoteka_refresh_lock` + double-checked в `get_autoteka_access_token()`. Sync-провайдер остаётся без нового thread-safety контракта в M1, чтобы не менять sync semantics; async получает явную защиту, потому что concurrent first-touch через один event loop — штатный сценарий. | | `asyncio.Lock` создан вне event loop'а → cross-loop UB | `AsyncAuthProvider` создаётся внутри `AsyncAvitoClient` (через `__aenter__` или `_from_transport`); в docstring явное предупреждение «не переиспользовать между event loop'ами». Python 3.10+ лениво биндит lock к loop'у при первом `await`. | | Миграция `_access_token` в `TokenCache` ломает `tests/core/test_authentication.py:122-127` | `AuthProvider` сохраняет `@property`/setter shim'ы для всех трёх частных полей; шим помечен legacy-комментом и удаляется в отдельном PR. | | `_operation_specs_for_sdk_method` не находит spec из `async_domain.py` | Pre-flight smoke-тест с async-методом + явным импортом spec; текущая реализация через `unwrapped_method.__globals__` (`swagger_linter.py:578-601`) обязана работать, потому что `from ...operations import SOME_SPEC` ставит spec в `__globals__` модуля. Если не работает — фикс в Phase 1b. | -| Convenience-методы (`account_health`, …) теряют main user-value async (параллелизм) | M-final требует `asyncio.TaskGroup` для независимых подзапросов + per-секция try/except `AvitoError → SummaryUnavailableSection` (зеркало sync `_safe_summary`). Запрещено реализовывать «sync, обмазанный await». | +| Convenience-методы (`account_health`, …) теряют main user-value async (параллелизм) или меняют error semantics | M-final требует `asyncio.TaskGroup` только для независимых подзапросов и сохраняет sync error semantics: required ветки пробрасывают `AvitoError`, optional ветки идут через `_safe_summary_async`. Запрещено реализовывать «sync, обмазанный await» и запрещено превращать required ошибку в unavailable section. | | `asyncio.gather(return_exceptions=True)` глушит `CancelledError` в convenience-методах | Запрещён; используется `asyncio.TaskGroup` (Python 3.11+, у нас floor 3.12+). При отмене внешнего вызова TaskGroup атомарно отменяет все child-таски без потери cancellation. | | Retry-петля ловит `asyncio.CancelledError` и зацикливает отмену | Shared `_decide_*_retry` и обёртки `Transport`/`AsyncTransport` ловят **только** `Exception`, не `BaseException`. Закреплено тестом `test_cancelled_error_is_not_retried`. | | `AsyncAvitoClient.__aenter__` оставляет полу-инициализированный state при ошибке | `__aenter__` обёрнут `try/except BaseException`: при любом исключении вызывает идемпотентный `aclose()` и пробрасывает наружу. Закреплено тестом `test_aenter_rollback_on_partial_failure`. | -| Ownership чужого `httpx.AsyncClient` не определён — потенциальный resource-leak или double-close | `AsyncTransport` хранит `_owns_client = http_client is None`; внешне переданный клиент `aclose()`/`__aexit__` не закрывают. Зеркало sync-политики; расхождение = blocker. | +| Ownership внешнего `httpx.AsyncClient` не определён — потенциальный resource-leak или double-close | M1 явно выбирает mirror текущего sync-поведения: `AsyncTransport.aclose()` закрывает переданный `httpx.AsyncClient`. Это закреплено тестом. Альтернативная политика `_owns_client` возможна только отдельным PR одновременно для sync и async. | | `AsyncFakeTransport` рассинхронизирован при `asyncio.gather` | `_handle_lock = asyncio.Lock()` сериализует match-and-record; **создаётся в `__init__`**, не лениво (лениво — гонка на самой инициализации lock'а). Закреплено тестом `test_async_fake_transport_concurrent_handle`. | | Существующие `async def test_*` в репозитории молча скипаются после `asyncio_mode = "strict"` | Pre-flight `grep -rn "^async def test_" tests/` фиксирует все такие тесты до M1; маркер `@pytest.mark.asyncio` добавляется отдельным pre-flight commit'ом. | | `len(PaginatedList)` / `paginated[0]` в коде ломаются при попытке мигрировать на `AsyncPaginatedList` | Pre-flight `grep` фиксирует все list-API usage. `AsyncPaginatedList` не повторяет list-API намеренно; каждый кейс заменяется на `await materialize()` / `loaded_count` в async-двойнике или остаётся sync-only. | | Скрытая работа «на потом» в доменных PR (TODO/FIXME/skip) | DoD M3…M12 явно требует пустой выхлоп `grep -E "TODO|FIXME|@pytest.mark.skip|xfail"` по diff'у; счётчики sync- и async-тестов сравниваются равенством; PR не мерджится при частичном покрытии домена. | | PoC обнаруживает, что фундамент (M1) недостаточен | Это и есть назначение PoC: feedback от M2-PoC → правки фундамента в этом же PR или M1.5-PR; `tariffs`-домен после доработок закрыт на 100%, как и остальные. M3 не стартует, пока M2-PoC не зелёный. | | `AsyncTokenClient._request_token` закольцован через основной auth-провайдер | Внутри создаётся независимый `AsyncTransport` с `auth_provider=None` (зеркало sync `TokenClient._build_transport()`). | -| Sync поведение незаметно изменилось в Phase 1 | DoD M1 включает baseline-diff: `pytest --tb=no -q` до и после M1 даёт идентичный список pass/fail. Любое расхождение блокирует merge. Phase 1a — отдельный коммит для bisect. | +| Sync поведение незаметно изменилось в Phase 1 | DoD M1 включает baseline-diff только по nodeid существующих тестов с main; новые async tests не участвуют в сравнении. Любое расхождение по старым nodeid блокирует merge. Phase 1a — отдельный коммит для bisect. | +| `_gen_reference.py` строит reference только из sync `*/domain.py` → `Async` молча отсутствуют в reference, `make docs-strict` остаётся зелёным, но публикация неполна | M1 обязан расширить builder (`public_domain_packages` подхватывает `async_domain.py`, `public_domain_classes` фильтрует `Async` через `AsyncDomainObject`-наследование, `public_domain_methods` — через `value.__qualname__.startswith(f"{cls.__name__}.")`). Pre-flight фиксирует текущие точки фильтрации. M2-PoC валидирует на `tariffs`. | +| Версия пакета не поднята в M-final → релиз 2.1.0 опубликован под старой версией | DoD M-final требует `poetry version 2.1.0` + `## [2.1.0] - YYYY-MM-DD` в CHANGELOG в одном PR. `git tag v2.1.0` после merge. | +| `_safe_summary_async` вынесен в отдельный модуль, sync `_safe_summary` остался в `client.py` → симметричные хелперы в разных файлах | DoD M-final требует: либо оба в `avito/client.py`, либо оба в `avito/summary/_helpers.py`. Частичное вынесение запрещено. | +| Concurrent iteration одного `AsyncPaginatedList` мутит общий `_cursor` → пользователь получает silent data corruption | Fail-fast контракт: второй `__aiter__` на активном instance бросает `RuntimeError`; fan-out делается через `await materialize()` или отдельный `AsyncPaginatedList` per consumer. | +| Английский в новых сообщениях ошибок `async_domain.py` (STYLEGUIDE.md violation) | DoD M3…M12 включает явный пункт «сообщения ошибок только на русском»; code review проверяет каждый `raise ("...")`. | +| `AsyncSwaggerFakeTransport` не синхронизирован со sync `SwaggerFakeTransport` | Добавляется в M1 как thin async mirror поверх общих schema/argument helpers. `tests/contracts/test_async_swagger_contracts.py` проходит по discovered `variant="async"` bindings на каждом этапе и в M-final покрывает все 204 operations. | From 0549509f968fa47771c47b78c1c97f57d38d9724 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 12:50:50 +0300 Subject: [PATCH 07/26] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D1=8E=20=D0=BD=D0=B0=D0=B4=20todo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- todo.md | 166 +++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 123 insertions(+), 43 deletions(-) diff --git a/todo.md b/todo.md index 52018d1..9cef34d 100644 --- a/todo.md +++ b/todo.md @@ -69,14 +69,16 @@ IO-agnostic вычисления (без httpx-вызова и sleep): `_decide_ (`threading.Lock` vs `asyncio.Lock`). **Контракт retry-петли в обоих режимах.** Catch-блок в `Transport.request()` / -`AsyncTransport.request()` ловит **только** `Exception`-наследников (явно: `httpx.RequestError` -и его подклассы). `BaseException` (включая `asyncio.CancelledError`, +`AsyncTransport.request()` ловит только явно retryable transport exceptions. +Для M1 это зеркало текущего sync-поведения: `httpx.TimeoutException` и +`httpx.NetworkError`. Расширять catch до всего `httpx.RequestError` нельзя +незаметно: это изменение sync-семантики и возможно только отдельным deliberate +behavior PR с тестами. `BaseException` (включая `asyncio.CancelledError`, `KeyboardInterrupt`, `SystemExit`) **никогда не уходит в retry** — пробрасывается наружу немодифицированным. Это критично для async: иначе SDK будет ловить отмену -корутины и пытаться её ретраить, нарушая cancellation-семантику. Sync-режим тоже -получает это уточнение (поведенчески идентично — `KeyboardInterrupt` уже не -ретраится в `httpx.RequestError`-блоке). Закрепляется тестом -`tests/core/test_async_transport.py::test_cancelled_error_is_not_retried`. +корутины и пытаться её ретраить, нарушая cancellation-семантику. Закрепляется тестом +`tests/core/test_async_transport.py::test_cancelled_error_is_not_retried` и sync +baseline-diff в M1. **Важное уточнение по `_merge_headers`.** Текущая реализация (`avito/core/transport.py:410-428`) внутри себя делает синхронный вызов @@ -139,7 +141,7 @@ Module-level функция `_map_token_response` (`avito/auth/provider.py:35`) | `avito/auth/__init__.py` | + экспорт `AsyncAuthProvider`, `AsyncTokenClient`, `AsyncAlternateTokenClient`, если эти классы объявлены публичными для consumer-side тестов и type-hint'ов. | | `avito/testing/__init__.py` | + экспорт `AsyncFakeTransport`, `AsyncSwaggerFakeTransport` и общих helpers, чтобы async test utilities были таким же публичным контрактом, как sync `FakeTransport`. | | `avito//__init__.py` | На каждом M2/M3…M12 добавляется export соответствующих `Async` классов; без этого `_gen_reference.py`, mkdocstrings и IDE-discovery не увидят async-поверхность. | -| `docs/site/assets/_gen_reference.py` | + расширение `public_domain_packages()` / `public_domain_classes()` / `public_domain_methods()` для подхвата `async_domain.py` и `Async`-классов рядом с sync-аналогами. Builder не должен зависеть только от `avito..__all__`: он обязан импортировать `avito..domain` и `avito..async_domain` напрямую, затем сохранять порядок sync-класс → async-класс. Без этого `make docs-strict` после M2-PoC не докажет полноту reference. | +| `docs/site/assets/_gen_reference.py` | + расширение `public_domain_packages()` / `public_domain_classes()` / `public_domain_methods()` для подхвата `async_domain.py` и `Async`-классов рядом с sync-аналогами. Builder не должен зависеть только от `avito..__all__`: он обязан импортировать `avito..domain` и `avito..async_domain` напрямую, затем сохранять порядок sync-класс → async-класс. Важно: текущий `write_domain_pages()` пишет только `::: avito.` и не использует helper-функции классов/методов; M1 обязан перевести генерацию domain pages на явные class-директивы (`::: avito..ClassName`) в порядке sync-класс → async-класс. `ensure_debug_info_exists()` расширяется на `AsyncAvitoClient.debug_info()`. Без этого `make docs-strict` после M2-PoC не докажет полноту reference. | | `avito/core/domain.py` | + `AsyncDomainObject` с async `_execute` и async `_resolve_user_id`. Sync `DomainObject` — без изменений. | | `pyproject.toml` | + `pytest-asyncio = "^0.24"` в dev-deps. + `[tool.pytest.ini_options] asyncio_mode = "strict"`. | | `Makefile` | + цель `async-parity-lint`, включённая в `quality`; `make check` после M1 должен оставаться зелёным. | @@ -165,7 +167,8 @@ avito/auth/async_provider.py # AsyncAuthProvider (отдельные # основного и autoteka токенов) avito/auth/async_token_client.py # AsyncTokenClient, AsyncAlternateTokenClient # (со @swagger_operation(..., variant="async")) -avito/async_client.py # AsyncAvitoClient (lifecycle + factory-методы пустые в M1) +avito/async_client.py # AsyncAvitoClient (lifecycle + auth/debug_info/closed-state; + # factory-методы доменов пустые в M1) avito/testing/async_fake_transport.py # AsyncFakeTransport (httpx.MockTransport+AsyncClient) avito/testing/async_swagger_fake_transport.py # AsyncSwaggerFakeTransport: async contract runner @@ -227,9 +230,9 @@ class AsyncTransport: 3. петля retry-decisions делегирует в shared `_decide_*_retry`; 4. при 401 — `self._auth_provider.invalidate_token()` (sync-операция clear cache), повторный `await self._auth_provider.get_access_token()`, один retry; -5. ловит **только** `Exception`-наследников (`httpx.RequestError` и т.п.). - `asyncio.CancelledError` и любой `BaseException` пробрасываются наружу без retry — - см. контракт shared retry-петли выше. +5. ловит только `httpx.TimeoutException` и `httpx.NetworkError`, как sync + `Transport` на момент M1. `asyncio.CancelledError` и любой `BaseException` + пробрасываются наружу без retry — см. контракт shared retry-петли выше. **Rate-limiter в async.** Один rate-limiter принадлежит одному `AsyncTransport` (а не каждой корутине-вызову). Все корутины, делящие транспорт, должны @@ -548,6 +551,7 @@ class AsyncAvitoClient: def transport(self) -> AsyncTransport: ... def auth(self) -> AsyncAuthProvider: ... + def debug_info(self) -> TransportDebugInfo: ... async def aclose(self) -> None: ... async def __aenter__(self) -> AsyncAvitoClient: ... async def __aexit__(self, *exc) -> None: ... @@ -605,6 +609,23 @@ await client.transport.request_json(...) # transport ещё None — RuntimeEr первого `__aenter__`. Закрепляется тестом `tests/core/test_async_client_lifecycle.py::test_access_before_aenter_raises`. +**Публичный client-contract parity.** `AsyncAvitoClient` зеркалит публичный контракт +`AvitoClient`, который не зависит от конкретного домена: + +- `debug_info()` доступен после `__aenter__`, возвращает тот же `TransportDebugInfo`, + что sync `AvitoClient.debug_info()`, и работает через `_require_transport()`; +- `auth()` проверяет `_ensure_open()` и возвращает `AsyncAuthProvider`; +- `aclose()` идемпотентен, выставляет `_closed=True` и закрывает `AsyncTransport` + + `AsyncAuthProvider`; +- после `aclose()` публичные методы (`auth()`, `debug_info()`, factory-методы, + convenience-методы после M-final) бросают `ClientClosedError("Клиент закрыт; создайте новый AsyncAvitoClient.")`; +- доступ к `transport`/`auth_provider` до `__aenter__` остаётся ошибкой + инициализации, а после `aclose()` — ошибкой закрытого клиента. Если оба состояния + возможны, приоритет у `_closed`. + +Это не optional sugar: `debug_info()` входит в публичный diagnostic contract sync SDK +и должен появиться в M1, до первого домена. + **Ownership внешнего `httpx.AsyncClient`.** В M1 нельзя незаметно менять текущую sync-семантику. Сейчас sync `Transport.close()` закрывает `httpx.Client` даже если он был передан извне. Поэтому `AsyncTransport.aclose()` в 2.1.0 зеркалит это @@ -636,8 +657,9 @@ async def __aenter__(self) -> AsyncAvitoClient: (каждый под-ресурс проверяет `is None` перед `await x.aclose()`). Закрепляется тестом `tests/core/test_async_client_lifecycle.py::test_aenter_rollback_on_partial_failure`. -В M1 `AsyncAvitoClient` без factory-методов — только lifecycle и smoke-вызов через сырой -`transport.request_json(...)` в тесте. **Convenience методы `account_health`, +В M1 `AsyncAvitoClient` без domain factory-методов — только lifecycle, `auth()`, +`debug_info()`, closed-state и smoke-вызов через сырой `transport.request_json(...)` +в тесте. **Convenience методы `account_health`, `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`** на `AsyncAvitoClient` — отдельный (последний) этап M-final, потому что часть из них комбинирует несколько доменов и не нужна до @@ -700,8 +722,12 @@ class AsyncFakeTransport: def __init__(self, *, base_url: str = "https://api.avito.ru") -> None: ... def add(self, method, path, *responses) -> AsyncFakeTransport: ... def add_json(self, method, path, payload, *, status_code=200, headers=None) -> AsyncFakeTransport: ... - def build(self, *, retry_policy=None, user_id=None) -> AsyncTransport: ... - def as_client(self, *, user_id=None, retry_policy=None) -> AsyncAvitoClient: ... + def build(self, *, retry_policy=None, user_id=None, + authenticated: bool = False, + auth_settings: AuthSettings | None = None) -> AsyncTransport: ... + def as_client(self, *, user_id=None, retry_policy=None, + authenticated: bool = False, + auth_settings: AuthSettings | None = None) -> AsyncAvitoClient: ... def count(self, *, method=None, path=None) -> int: ... def last(self, *, method=None, path=None) -> RecordedRequest: ... requests: list[RecordedRequest] @@ -712,6 +738,23 @@ class AsyncFakeTransport: `JsonValue`, `json_response`, `route_sequence` — переиспользуем без копий из sync. `sleep` — `lambda _: asyncio.sleep(0)`. +**Auth mode для fake transport.** По умолчанию `authenticated=False`, чтобы простые +domain-тесты, как sync `FakeTransport.as_client()`, не требовали `/token` route. +Для M1 auth/retry smoke и contract-тестов, где надо проверить реальный +`Authorization`, 401 invalidate и token refresh, используется `authenticated=True`: + +- `as_client(authenticated=True)` создаёт `AsyncAuthProvider` с `AsyncTokenClient` / + `AsyncAlternateTokenClient`, построенными на том же `httpx.MockTransport(self._handle)`; +- основной `AsyncTransport` получает этот `auth_provider`, поэтому первый + авторизованный запрос вызывает `/token`, а 401 сбрасывает кэш и делает второй + `/token`; +- тест обязан явно зарегистрировать token routes через `add_json("POST", "/token", ...)`; +- `build(authenticated=True)` возвращает низкоуровневый `AsyncTransport` с таким же + auth provider-ом, чтобы core-тесты не обходили auth pipeline. + +Без этого M1 smoke может выглядеть «авторизованным», но фактически пройти через +transport с `auth_provider=None` и не проверить refresh-семантику. + **Concurrency policy.** `_handle` мутирует `self.requests.append(...)` и `route.pop(0)` для `route_sequence`-сценариев. Для тестов с `asyncio.gather(...)` (в первую очередь M-final convenience-методы) `_handle` берёт `self._handle_lock = asyncio.Lock()` и @@ -812,6 +855,10 @@ access внутри одного event loop'а; создавать новый in 6. `scripts/lint_async_parity.py` — static linter, проверяет для каждого Async-класса: - имя `Async` ↔ существует sync `` в том же пакете; + - class-level metadata зеркальна sync-классу: `__swagger_domain__`, + `__sdk_factory__`, `__sdk_factory_args__` должны совпадать по значениям + (за исключением сознательно документированных legacy-wrapper'ов, если такие + появятся отдельным PR); - множество публичных async-методов (`async def` без префикса `_`) совпадает с sync-методами; - перебор методов фильтруется по `func.__qualname__.startswith(cls.__name__ + ".")`, чтобы не учитывать унаследованные от `AsyncDomainObject` (`_execute`, `_resolve_user_id`) @@ -826,6 +873,9 @@ access внутри одного event loop'а; создавать новый in - для каждой async class-level `__sdk_factory__` проверяет, что такой factory существует на `AsyncAvitoClient`, имеет сигнатуру, совместимую с sync factory на `AvitoClient`, и возвращает соответствующий `Async`. + Если metadata отсутствует, это blocker даже при наличии decorator-ов: + swagger discovery, reference builder и IDE-discovery должны видеть async-класс + тем же способом, что sync-класс. Этот linter вызывается из `make quality`; pytest не содержит parity/introspection тестов, потому что STYLEGUIDE разрешает в pytest только functional tests и Swagger-spec compliance tests. @@ -873,10 +923,13 @@ access внутри одного event loop'а; создавать новый in существующие точки фильтрации: `PACKAGE_ROOT.glob("*/domain.py")`, `EXCLUDED_PACKAGES`, `public_domain_classes()` (фильтр по `DomainObject`-наследованию и `value.__module__.startswith(f"avito.{package}.")`), `public_domain_methods()` - (фильтр по `value.__qualname__.startswith(f"{domain_class.__name__}.")`). - Расширение builder'а в M1 обязано переиспользовать ровно эту логику для - `async_domain.py` + `AsyncDomainObject`-наследников и не полагаться только - на `avito..__all__`. Без этого reference будет несимметричным. + (фильтр по `value.__qualname__.startswith(f"{domain_class.__name__}.")`), + и `write_domain_pages()` (сейчас он пишет один `::: avito.` и не + использует class helper-ы). Расширение builder'а в M1 обязано переиспользовать + эту логику для `async_domain.py` + `AsyncDomainObject`-наследников, а + `write_domain_pages()` должен перейти на явные class-директивы sync → async + и не полагаться только на `avito..__all__`. Без этого reference будет + несимметричным. - [ ] Прочитать `scripts/lint_architecture.py` и `scripts/lint_docstrings.py`: текущие проверки смотрят только `domain.py` и `ast.FunctionDef`. M1 обязан расширить их на `async_domain.py` и `ast.AsyncFunctionDef`. @@ -898,13 +951,22 @@ DoD: увидит первый `Async`. Если на M1 ещё ни одного `Async` нет — builder проверяется на нейтральность (sync reference генерится идентично baseline'у). - [ ] Покрытие тестами фундамента не ниже sync-аналогов (sample проверка по `coverage report`). -- [ ] Smoke-тест: `AsyncAvitoClient` через `AsyncFakeTransport` (без respx) делает один авторизованный запрос; токен рефрешится после 401; retry на 429 срабатывает; `Idempotency-Key` пробрасывается; `aclose()` корректно закрывает `httpx.AsyncClient` и `AsyncAuthProvider`. +- [ ] Smoke-тест: `AsyncAvitoClient` через `AsyncFakeTransport.as_client(authenticated=True)` + (без respx) делает один авторизованный запрос; `/token` реально вызывается + через `AsyncTokenClient`; после 401 кэш сбрасывается и `/token` вызывается + повторно; retry на 429 срабатывает; `Authorization` и `Idempotency-Key` + пробрасываются; `aclose()` корректно закрывает `httpx.AsyncClient` и + `AsyncAuthProvider`. - [ ] Ownership test: `AsyncTransport.aclose()` закрывает переданный `httpx.AsyncClient`, потому что это выбранная mirror-политика текущего sync `Transport.close()`. Тест отдельно покрывает idempotent double-close. - [ ] Async auth public surface зеркалит sync: `AsyncAvitoClient.auth()` возвращает `AsyncAuthProvider`, а `token_flow()` / `alternate_token_flow()` возвращают async token clients с `variant="async"` bindings. +- [ ] Async client diagnostic/closed contract зеркалит sync: `debug_info()` возвращает + `TransportDebugInfo` после `__aenter__`; `auth()` и `debug_info()` падают до + инициализации понятным `RuntimeError`; после `aclose()` они и будущие factory- + методы падают `ClientClosedError`; повторный `aclose()` no-op. - [ ] Документация `swagger-binding-subsystem.md` отражает variant и class-gated coverage. - [ ] `AsyncSwaggerFakeTransport` добавлен и экспортирован из `avito.testing`; async contract suite зелёный для discovered async bindings (`auth` в M1, домены @@ -938,10 +1000,14 @@ PoC берёт `tariffs` (1 sync-операция с binding) — минимал DoD M2-PoC: - [ ] `avito/tariffs/async_domain.py` создан, `AsyncTariff` зеркалит `Tariff` ровно по 1 публичному методу. +- [ ] `AsyncTariff` содержит class-level metadata, зеркальную `Tariff`: + `__swagger_domain__ = "tariffs"`, `__sdk_factory__ = "tariff"`, + `__sdk_factory_args__ = {"tariff_id": "path.tariff_id"}`. - [ ] `avito/tariffs/__init__.py` экспортирует `AsyncTariff` рядом с `Tariff`. - [ ] `AsyncAvitoClient.tariff()` factory-метод возвращает `AsyncTariff`. -- [ ] `tests/domains/tariffs/test_tariffs_async.py` зеркалит sync-тест 1:1 - (golden path + 401 + 429 + transport error). Все тесты зелёные. +- [ ] `tests/domains/tariffs/test_tariffs_async.py` содержит async-двойник sync + golden-path сценария и дополнительные async-риск сценарии: 401, 429, + transport error. Все тесты зелёные. - [ ] `make check` зелёный, включая `swagger-lint --strict` (для `tariffs` теперь требуется async-coverage 1:1). - [ ] `scripts/lint_async_parity.py` зелёный. @@ -949,7 +1015,7 @@ DoD M2-PoC: `tariffs`. - [ ] Документация generated reference для `docs/site/reference/domains/tariffs.md` содержит async-секцию. -- [ ] **`_gen_reference.py` валидируется на реальном домене**: после расширения builder'а в M1 на M2-PoC он впервые видит `AsyncTariff` и должен сгенерировать reference-страницу с обоими классами (`Tariff` + `AsyncTariff`). `make docs-strict` зелёный, в `docs/site/reference/domains/tariffs.md` в выхлопе билда присутствуют обе секции. Если builder требует доработки — она входит в этот же PR (это и есть смысл PoC). Конкретно в `_gen_reference.py`: `public_domain_packages()` дополнительно возвращает пакет, если есть `*/async_domain.py`; `public_domain_classes()` импортирует `avito..domain` и `avito..async_domain` напрямую, а не только `avito..__all__`; `Async` фильтруется через `cls.__name__.startswith("Async")` + `issubclass(AsyncDomainObject)`; `EXCLUDED_PACKAGES` остаётся прежним; для `auth` (исключён) async-классы reference не получают. +- [ ] **`_gen_reference.py` валидируется на реальном домене**: после расширения builder'а в M1 на M2-PoC он впервые видит `AsyncTariff` и должен сгенерировать reference-страницу с обоими классами (`Tariff` + `AsyncTariff`). `make docs-strict` зелёный, в generated `site/reference/domains/tariffs/` или `site/reference/domains/tariffs.html` присутствуют обе секции. Если builder требует доработки — она входит в этот же PR (это и есть смысл PoC). Конкретно в `_gen_reference.py`: `public_domain_packages()` дополнительно возвращает пакет, если есть `*/async_domain.py`; `public_domain_classes()` импортирует `avito..domain` и `avito..async_domain` напрямую, а не только `avito..__all__`; `Async` фильтруется через `cls.__name__.startswith("Async")` + `issubclass(AsyncDomainObject)`; `write_domain_pages()` пишет явные mkdocstrings-директивы для каждого класса в порядке `Tariff` → `AsyncTariff`, а не один общий `::: avito.tariffs`; `EXCLUDED_PACKAGES` остаётся прежним; для `auth` (исключён) async-классы reference не получают. - [ ] **Lessons learned зафиксированы** в `docs/site/explanations/async-domain-template.md` (новый файл): шаблон файла `async_domain.py`, чек-лист переноса домена, найденные подводные камни. Этот документ становится нормативным для M3+. @@ -985,28 +1051,33 @@ DoD M2-PoC: (`from avito..operations import LIST_SPEC, GET_SPEC, ...`) — иначе `_operation_specs_for_sdk_method` не сможет резолвнуть spec через `__globals__` и swagger-lint выдаст `SWAGGER_OPERATION_SPEC_MISSING`. -2. **Каждый** публичный метод декорируется `@swagger_operation(..., variant="async")` +2. **Каждый** `Async` содержит class-level metadata, зеркальную sync-классу: + `__swagger_domain__`, `__sdk_factory__`, `__sdk_factory_args__`. Metadata не + считается «дублированием» Swagger-контракта: это SDK discovery/factory metadata, + без которого async-класс может не попасть в discovery/reference или получить + зелёный decorator при отсутствующем factory. +3. **Каждый** публичный метод декорируется `@swagger_operation(..., variant="async")` теми же аргументами `(method, path, spec, operation_id, factory, factory_args, method_args, deprecated, legacy)`, что и sync. -3. `avito//__init__.py` экспортирует **все** `Async` класса домена рядом +4. `avito//__init__.py` экспортирует **все** `Async` класса домена рядом с sync-классами, чтобы mkdocstrings, IDE и generated reference видели публичную async-поверхность. -4. Регистрация **всех** `Async` домена в `AsyncAvitoClient` (factory-методы по +5. Регистрация **всех** `Async` домена в `AsyncAvitoClient` (factory-методы по именам, идентичным sync). -5. `tests/domains//test__async.py` — зеркало +6. `tests/domains//test__async.py` — зеркало `tests/domains//test_.py`, через `AsyncFakeTransport`. Тесты помечаем `@pytest.mark.asyncio`. **Каждый** sync-тест имеет async-двойник с тем же сценарием. -6. Если в домене есть пагинация — соответствующие методы возвращают +7. Если в домене есть пагинация — соответствующие методы возвращают `AsyncPaginatedList[T]` (зеркально sync `PaginatedList[T]`). M4 `accounts` — первый домен с `AsyncPaginatedList`; M11 `ads` проверяет сложный first-page reuse в `Ad.list`. -7. Generated reference `docs/site/reference/domains/.md` дополняется +8. Generated reference `docs/site/reference/domains/.md` дополняется async-секцией (или второй колонкой). -8. Если в домене есть write-методы с `dry_run` — async-двойник реализует тот же +9. Если в домене есть write-методы с `dry_run` — async-двойник реализует тот же контракт: при `dry_run=True` транспорт **не вызывается** (тест проверяет `count(method=..., path=...) == 0`). -9. Если в домене есть idempotency-key поведение — async-тесты явно проверяют +10. Если в домене есть idempotency-key поведение — async-тесты явно проверяют проброс заголовка `Idempotency-Key`. ### Definition of done каждого M3…M12 — закрыть домен на 100%, без работы на потом @@ -1018,11 +1089,14 @@ DoD M2-PoC: Локальная проверка: `python -c "from avito..domain import *; from avito..async_domain import *"` + `scripts/lint_async_parity.py` без allowlist/skip для текущего домена. -- [ ] **Покрытие тестов 1:1**: каждый сценарий из `tests/domains//test_*.py` - имеет async-двойник; счётчики тестов сверены отдельными командами: - `pytest --collect-only -q tests/domains//test_.py | grep -c "::test_"` - и `pytest --collect-only -q tests/domains//test__async.py | grep -c "::test_"` - показывают одинаковое число. Покрываются: golden path, 401, +- [ ] **Покрытие тестов сценарий-в-сценарий**: каждый сценарий из + `tests/domains//test_.py` имеет async-двойник с тем же + бизнес-смыслом. Дополнительные async-тесты разрешены и обязательны там, + где закрывают async-специфичные риски (401 refresh через async auth, + cancellation, concurrent pagination/fake transport, async rate limiter). + Счётчики тестов не обязаны быть равны; async-count должен быть **не меньше** + sync-count, а PR description содержит короткую mapping-таблицу + `sync test -> async test`. Покрываются: golden path, 401, 403, 422, 429, transport error/timeout, пагинация (если есть), idempotency (для write), `dry_run` (если есть в sync). - [ ] **Swagger-lint coverage 1:1 для домена**: `swagger-lint --strict` после этапа @@ -1110,15 +1184,18 @@ from avito.core.types import RequestContext async def main(): async with ( AsyncFakeTransport() - .add_json("POST", "/token", {"access_token": "t", "expires_in": 3600}) + .add_json("POST", "/token", {"access_token": "old", "expires_in": 3600}) + .add_json("POST", "/token", {"access_token": "new", "expires_in": 3600}) + .add_json("GET", "/core/v1/accounts/self", {"error": "expired"}, status_code=401) .add_json("GET", "/core/v1/accounts/self", {"id": 1}) - .as_client() + .as_client(authenticated=True) ) as client: payload = await client.transport.request_json( "GET", "/core/v1/accounts/self", context=RequestContext("smoke"), ) assert payload == {"id": 1} + assert client.transport.debug_info().requires_auth is True asyncio.run(main()) ``` @@ -1156,10 +1233,10 @@ make swagger-lint # async-coverage 1:1 git diff main..HEAD -- avito// tests/domains// \ | grep -E "TODO|FIXME|@pytest.mark.skip|xfail" || echo "OK: no leftover work" -# Cumulative счётчики (sync-тестов = async-тестов в домене) +# Cumulative счётчики (async-тестов не меньше sync; mapping сценариев в PR description) sync_count=$(poetry run pytest --collect-only -q tests/domains//test_.py | grep -c "::test_") async_count=$(poetry run pytest --collect-only -q tests/domains//test__async.py | grep -c "::test_") -test "$sync_count" -eq "$async_count" && echo "OK: $sync_count == $async_count" +test "$async_count" -ge "$sync_count" && echo "OK: async $async_count >= sync $sync_count" make check make docs-strict @@ -1219,11 +1296,13 @@ git push --tags | Convenience-метод М-final реализован как «sync с обмазанным await» (потеря параллелизма) ИЛИ leaf/CPU-only метод обёрнут в ненужный `TaskGroup` | DoD M-final проверяет классификацию по фактическому sync-коду: `TaskGroup` только для независимых сетевых веток (`account_health`, `listing_health`, `review_summary`, `promotion_summary` при `item_ids`); `business_summary` — алиас; `chat_summary`/`order_summary` — sequential; `capabilities` — CPU-only без network probes. | | Class-gated swagger-coverage применён per-domain → большой домен (`ads`) нельзя разбить, либо мини-домен с двумя классами требует доделки до merge'а | Class-gated применяется **per-class**: `Async` существует ↔ все операции класса `` обязаны иметь async-binding. Отсутствие `Async` в том же домене не блокирует мердж класса `Async`. DoD M3…M12 всё равно требует домен закрыть на 100%. | | `from_env` инициализирует loop-зависимые ресурсы вне loop'а → cross-loop UB | `from_env` синхронен, SDK-managed ресурсы (`httpx.AsyncClient`, `asyncio.Lock`) создаются в `__aenter__`. Если внешний `http_client` передан пользователем, transport связывается с ним только в `__aenter__`. Доступ к `transport`/`auth_provider` до `__aenter__` бросает `RuntimeError` с понятным сообщением. Закреплено тестом `test_access_before_aenter_raises`. | +| `AsyncAvitoClient` реализует только domain factories и забывает публичный diagnostic/closed contract sync-клиента | M1 включает `auth()`, `debug_info()`, `_ensure_open()`, `_require_transport()`, `ClientClosedError` после `aclose()` и проверку `AsyncAvitoClient.debug_info()` в `_gen_reference.py.ensure_debug_info_exists()`. | | Release notes 2.1.0 невозможно собрать механически, потому что в PR M3…M12 нет CHANGELOG-записей | DoD M3…M12 требует `## [Unreleased]` строку в корневом `CHANGELOG.md` на каждый PR. M-final сводит накопленное в `## [2.1.0]`. | | `_merge_headers` срытно делает sync IO (`get_access_token()`) | Phase 1a первым шагом рефакторит контракт: helper принимает уже резолвнутый `bearer_token: str | None`. Без этого shared слой не IO-agnostic, и vary-логика расползётся. | | `AsyncPaginatedList` не наследует `list` → ломаются ожидания сервисов | Документируем в docstring; `scripts/lint_async_parity.py` допускает `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`. List-API не реплицируется намеренно. | | `AsyncPaginator` не покрывает helper usage `Paginator(...).as_list(...)` | Контракт `AsyncPaginator` симметричен sync (`iter_pages`/`collect`/`as_list`); все 4 текущих usage-сайта покрыты через методы, возвращающие `AsyncPaginatedList[T]`. | | Auth-bindings не попадают в async-coverage | `_NON_DOMAIN_BINDING_MODULES` дополнен строго `"avito.auth.async_token_client"`; class-gated coverage гейтится по присутствию `AsyncTokenClient`/`AsyncAlternateTokenClient`. | +| `Async` имеет decorators, но не имеет class-level `__sdk_factory__` / `__swagger_domain__` → discovery/reference/factory checks неполные | DoD M2…M12 требует зеркальную class metadata для каждого `Async`, а `scripts/lint_async_parity.py` сравнивает metadata sync/async и падает при отсутствии. | | Двойной декор одной функции | Текущая защита `__swagger_binding__` остаётся; sync и async — разные функции. | | Гонка на основном refresh-токене в async | `asyncio.Lock` (`_refresh_lock`) в `AsyncAuthProvider` + double-checked pattern (как sync, но через `await`). | | Гонка на autoteka-токене в async | Отдельный `_autoteka_refresh_lock` + double-checked в `get_autoteka_access_token()`. Sync-провайдер остаётся без нового thread-safety контракта в M1, чтобы не менять sync semantics; async получает явную защиту, потому что concurrent first-touch через один event loop — штатный сценарий. | @@ -1232,17 +1311,18 @@ git push --tags | `_operation_specs_for_sdk_method` не находит spec из `async_domain.py` | Pre-flight smoke-тест с async-методом + явным импортом spec; текущая реализация через `unwrapped_method.__globals__` (`swagger_linter.py:578-601`) обязана работать, потому что `from ...operations import SOME_SPEC` ставит spec в `__globals__` модуля. Если не работает — фикс в Phase 1b. | | Convenience-методы (`account_health`, …) теряют main user-value async (параллелизм) или меняют error semantics | M-final требует `asyncio.TaskGroup` только для независимых подзапросов и сохраняет sync error semantics: required ветки пробрасывают `AvitoError`, optional ветки идут через `_safe_summary_async`. Запрещено реализовывать «sync, обмазанный await» и запрещено превращать required ошибку в unavailable section. | | `asyncio.gather(return_exceptions=True)` глушит `CancelledError` в convenience-методах | Запрещён; используется `asyncio.TaskGroup` (Python 3.11+, у нас floor 3.12+). При отмене внешнего вызова TaskGroup атомарно отменяет все child-таски без потери cancellation. | -| Retry-петля ловит `asyncio.CancelledError` и зацикливает отмену | Shared `_decide_*_retry` и обёртки `Transport`/`AsyncTransport` ловят **только** `Exception`, не `BaseException`. Закреплено тестом `test_cancelled_error_is_not_retried`. | +| Retry-петля ловит `asyncio.CancelledError` и зацикливает отмену | Shared `_decide_*_retry` и обёртки `Transport`/`AsyncTransport` ловят только retryable `httpx.TimeoutException` / `httpx.NetworkError`, не `BaseException` и не весь `httpx.RequestError`. Закреплено тестом `test_cancelled_error_is_not_retried`. | | `AsyncAvitoClient.__aenter__` оставляет полу-инициализированный state при ошибке | `__aenter__` обёрнут `try/except BaseException`: при любом исключении вызывает идемпотентный `aclose()` и пробрасывает наружу. Закреплено тестом `test_aenter_rollback_on_partial_failure`. | | Ownership внешнего `httpx.AsyncClient` не определён — потенциальный resource-leak или double-close | M1 явно выбирает mirror текущего sync-поведения: `AsyncTransport.aclose()` закрывает переданный `httpx.AsyncClient`. Это закреплено тестом. Альтернативная политика `_owns_client` возможна только отдельным PR одновременно для sync и async. | | `AsyncFakeTransport` рассинхронизирован при `asyncio.gather` | `_handle_lock = asyncio.Lock()` сериализует match-and-record; **создаётся в `__init__`**, не лениво (лениво — гонка на самой инициализации lock'а). Закреплено тестом `test_async_fake_transport_concurrent_handle`. | +| M1 smoke проходит через `AsyncFakeTransport` без auth provider и не проверяет OAuth/401 refresh | `AsyncFakeTransport.as_client(authenticated=True)` и `build(authenticated=True)` создают `AsyncAuthProvider` + async token clients на том же `MockTransport`; smoke обязан проверять реальные `/token` вызовы, `Authorization`, invalidate после 401 и повторный token fetch. | | Существующие `async def test_*` в репозитории молча скипаются после `asyncio_mode = "strict"` | Pre-flight `grep -rn "^async def test_" tests/` фиксирует все такие тесты до M1; маркер `@pytest.mark.asyncio` добавляется отдельным pre-flight commit'ом. | | `len(PaginatedList)` / `paginated[0]` в коде ломаются при попытке мигрировать на `AsyncPaginatedList` | Pre-flight `grep` фиксирует все list-API usage. `AsyncPaginatedList` не повторяет list-API намеренно; каждый кейс заменяется на `await materialize()` / `loaded_count` в async-двойнике или остаётся sync-only. | -| Скрытая работа «на потом» в доменных PR (TODO/FIXME/skip) | DoD M3…M12 явно требует пустой выхлоп `grep -E "TODO|FIXME|@pytest.mark.skip|xfail"` по diff'у; счётчики sync- и async-тестов сравниваются равенством; PR не мерджится при частичном покрытии домена. | +| Скрытая работа «на потом» в доменных PR (TODO/FIXME/skip) | DoD M3…M12 явно требует пустой выхлоп `grep -E "TODO|FIXME|@pytest.mark.skip|xfail"` по diff'у; async-тестов должно быть не меньше sync-тестов, а PR description содержит mapping `sync test -> async test`; PR не мерджится при частичном покрытии домена. | | PoC обнаруживает, что фундамент (M1) недостаточен | Это и есть назначение PoC: feedback от M2-PoC → правки фундамента в этом же PR или M1.5-PR; `tariffs`-домен после доработок закрыт на 100%, как и остальные. M3 не стартует, пока M2-PoC не зелёный. | | `AsyncTokenClient._request_token` закольцован через основной auth-провайдер | Внутри создаётся независимый `AsyncTransport` с `auth_provider=None` (зеркало sync `TokenClient._build_transport()`). | | Sync поведение незаметно изменилось в Phase 1 | DoD M1 включает baseline-diff только по nodeid существующих тестов с main; новые async tests не участвуют в сравнении. Любое расхождение по старым nodeid блокирует merge. Phase 1a — отдельный коммит для bisect. | -| `_gen_reference.py` строит reference только из sync `*/domain.py` → `Async` молча отсутствуют в reference, `make docs-strict` остаётся зелёным, но публикация неполна | M1 обязан расширить builder (`public_domain_packages` подхватывает `async_domain.py`, `public_domain_classes` фильтрует `Async` через `AsyncDomainObject`-наследование, `public_domain_methods` — через `value.__qualname__.startswith(f"{cls.__name__}.")`). Pre-flight фиксирует текущие точки фильтрации. M2-PoC валидирует на `tariffs`. | +| `_gen_reference.py` строит reference только из sync `*/domain.py` или пишет один общий `::: avito.` → `Async` молча отсутствуют в reference, `make docs-strict` остаётся зелёным, но публикация неполна | M1 обязан расширить builder (`public_domain_packages` подхватывает `async_domain.py`, `public_domain_classes` фильтрует `Async` через `AsyncDomainObject`-наследование, `public_domain_methods` — через `value.__qualname__.startswith(f"{cls.__name__}.")`) и перевести `write_domain_pages()` на явные class-директивы sync → async. Pre-flight фиксирует текущие точки фильтрации. M2-PoC валидирует на `tariffs`. | | Версия пакета не поднята в M-final → релиз 2.1.0 опубликован под старой версией | DoD M-final требует `poetry version 2.1.0` + `## [2.1.0] - YYYY-MM-DD` в CHANGELOG в одном PR. `git tag v2.1.0` после merge. | | `_safe_summary_async` вынесен в отдельный модуль, sync `_safe_summary` остался в `client.py` → симметричные хелперы в разных файлах | DoD M-final требует: либо оба в `avito/client.py`, либо оба в `avito/summary/_helpers.py`. Частичное вынесение запрещено. | | Concurrent iteration одного `AsyncPaginatedList` мутит общий `_cursor` → пользователь получает silent data corruption | Fail-fast контракт: второй `__aiter__` на активном instance бросает `RuntimeError`; fan-out делается через `await materialize()` или отдельный `AsyncPaginatedList` per consumer. | From a7707f898e42b9795cf2c3bd3e6e1a642bb5291f Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 12:59:03 +0300 Subject: [PATCH 08/26] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D1=8E=20=D0=BD=D0=B0=D0=B4=20todo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- todo.md | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 137 insertions(+), 12 deletions(-) diff --git a/todo.md b/todo.md index 9cef34d..ef1ed61 100644 --- a/todo.md +++ b/todo.md @@ -143,7 +143,7 @@ Module-level функция `_map_token_response` (`avito/auth/provider.py:35`) | `avito//__init__.py` | На каждом M2/M3…M12 добавляется export соответствующих `Async` классов; без этого `_gen_reference.py`, mkdocstrings и IDE-discovery не увидят async-поверхность. | | `docs/site/assets/_gen_reference.py` | + расширение `public_domain_packages()` / `public_domain_classes()` / `public_domain_methods()` для подхвата `async_domain.py` и `Async`-классов рядом с sync-аналогами. Builder не должен зависеть только от `avito..__all__`: он обязан импортировать `avito..domain` и `avito..async_domain` напрямую, затем сохранять порядок sync-класс → async-класс. Важно: текущий `write_domain_pages()` пишет только `::: avito.` и не использует helper-функции классов/методов; M1 обязан перевести генерацию domain pages на явные class-директивы (`::: avito..ClassName`) в порядке sync-класс → async-класс. `ensure_debug_info_exists()` расширяется на `AsyncAvitoClient.debug_info()`. Без этого `make docs-strict` после M2-PoC не докажет полноту reference. | | `avito/core/domain.py` | + `AsyncDomainObject` с async `_execute` и async `_resolve_user_id`. Sync `DomainObject` — без изменений. | -| `pyproject.toml` | + `pytest-asyncio = "^0.24"` в dev-deps. + `[tool.pytest.ini_options] asyncio_mode = "strict"`. | +| `pyproject.toml` | + `pytest-asyncio = "^0.24"` в dev-deps. + `[tool.pytest.ini_options] asyncio_mode = "strict"` и `asyncio_default_fixture_loop_scope = "function"`. Без явного `asyncio_default_fixture_loop_scope` `pytest-asyncio` 0.23+ выдаёт `PytestDeprecationWarning`, который при текущем `filterwarnings` ломает всю pytest-сессию. Закрепляется в M1 PR. | | `Makefile` | + цель `async-parity-lint`, включённая в `quality`; `make check` после M1 должен оставаться зелёным. | | `scripts/lint_architecture.py` | `LEGACY_FILENAMES` не трогаем, но public-method checks применяются к `domain.py` и `async_domain.py`; AST-парсер должен учитывать `ast.AsyncFunctionDef` наравне с `ast.FunctionDef`. | | `scripts/lint_docstrings.py` | Проверяет `avito/*/domain.py` и `avito/*/async_domain.py`, чтобы async public methods не получили generic/reference-плохие docstring-и. | @@ -283,6 +283,20 @@ Async wrapper намеренно держит `asyncio.Lock` во время о (пять параллельных корутин не уходят пачкой после ожидания, а сериализуются под `asyncio.Lock`). +**Connection pool и fan-out limits.** `AsyncTransport` создаёт `httpx.AsyncClient` +с **дефолтными** `httpx.Limits` (max_connections=100, max_keepalive_connections=20), +без переопределения. Это сознательное решение: явный тюнинг лимитов в M1 — отдельная +поведенческая ось, которая не должна вводиться вместе с async-фундаментом. При этом +**convenience-методы M-final ограничивают fan-out**: ни один агрегатор +(`account_health`, `listing_health`, `review_summary`, `promotion_summary`) не должен +порождать > 6 одновременно in-flight задач через `asyncio.TaskGroup` (текущий sync- +код имеет максимум 5–6 независимых веток в `account_health`). Если домен в будущем +требует параллельного fan-out > 6, это вводится отдельным PR с явной политикой +семафора (`asyncio.Semaphore`) — но не в 2.1.0. Закрепляется DoD M-final code review +checklist'ом и риск-таблицей. Если внешний `httpx.AsyncClient` передан пользователем, +его limits — ответственность пользователя; SDK их не переопределяет и документирует +этот факт в docstring `AsyncAvitoClient.__init__`. + **Семантика `AsyncTransport.download_binary`.** В M1 — **full-buffer**, как sync: внутри `await response.aread()` и возвращается `BinaryResponse` с полным `bytes`- контентом. Streaming-вариант (`async for chunk in response.aiter_bytes()`) — @@ -328,6 +342,17 @@ class AsyncOperationExecutor: Binary-ветка закрепляется M1 unit-тестом на executor и M12 domain-тестом `OrderLabel.download()` через `AsyncSwaggerFakeTransport`/`AsyncFakeTransport`. +**Retry-политика executor'а — точное зеркало sync.** `AsyncOperationExecutor.execute()` +выбирает retry в том же порядке, что sync `OperationExecutor`: `retry or spec.retry`, +с тем же defaulting, и пробрасывает её в `AsyncTransport.request()` идентичным аргументом. +Запрещено: (1) брать `retry` только из аргумента и игнорировать `spec.retry`, (2) брать +`spec.retry` всегда и игнорировать override. Закрепляется юнит-тестом +`tests/core/test_async_executor.py::test_executor_retry_resolution_matches_sync`, +который параметризован тремя кейсами `(retry=None, spec.retry=A) → A`, +`(retry=B, spec.retry=A) → B`, `(retry=B, spec.retry=None) → B` и сверяет результат с +sync `OperationExecutor` на одном и том же `OperationSpec`. Без этого теста расхождение +retry-семантики между sync и async может пройти незамеченным. + Замечание по типизации Protocol: для async-методов в `Protocol` используем `async def`, а не `Awaitable[T]` в return-аннотации синхронной сигнатуры. Это даёт mypy strict корректный runtime-protocol matching и избавляет от двойной оборачивания. @@ -478,6 +503,18 @@ class AsyncAuthProvider: def alternate_token_flow(self) -> AsyncAlternateTokenClient: ... ``` +**Контракт `invalidate_token()` — sync без await.** Метод выполняет одну операцию +`self._cache.access_token = None` (атомарное присваивание поля dataclass'а). Это +безопасно вне `_refresh_lock`, потому что в asyncio нет true-параллелизма между +корутинами одного loop'а: между двумя `await`-точками управление не передаётся, и +параллельная корутина не может «застать» полу-обновлённый state. **Запрещено** делать +`invalidate_token` корутиной с `async with self._refresh_lock` — это вводит ложную +видимость защиты, увеличивает latency 401-handling в `AsyncTransport.request()` и +противоречит sync-контракту, где `AuthProvider.invalidate_token()` тоже sync. Закрепляется +тестом `tests/auth/test_async_provider.py::test_invalidate_token_is_sync_and_idempotent`, +который проверяет, что метод можно вызвать вне корутины (например, из `__del__`-обёртки), +повторный вызов — no-op, и после него `get_access_token()` запускает refresh. + **Lock lifecycle.** В Python 3.10+ `asyncio.Lock()`, созданный вне event loop, лениво биндится к loop'у при первом `await`. Чтобы не получить cross-loop UB: `AsyncAuthProvider` создаётся внутри `AsyncAvitoClient.__aenter__` (или `_from_transport`), @@ -675,7 +712,7 @@ async def __aenter__(self) -> AsyncAvitoClient: | `business_summary` | **алиас** для `account_health` | `return self.account_health(...)` (`avito/client.py:184-204`) | `return await self.account_health(...)` — **никакого `TaskGroup`**, делегирование 1:1 | | `chat_summary` | leaf/sequential | `_resolve_user_id`, затем один вызов `messenger`-домена | последовательный `async def`; `TaskGroup` не нужен | | `order_summary` | leaf | один вызов `orders`-домена | один `await`; `TaskGroup` запрещён | -| `review_summary` | small aggregator | `review().list()` optional-safe, `rating_profile().get()` required (`avito/client.py:396-429`) | допускается **`asyncio.TaskGroup`**: reviews через `_safe_summary_async`, rating как required task. `rating` error пробрасывается; reviews error превращается в unavailable section. | +| `review_summary` | mixed required+optional | `review().list()` optional-safe, `rating_profile().get()` required (`avito/client.py:396-429`) | **последовательно**, без `TaskGroup`: сначала `reviews` через `_safe_summary_async` (optional, ошибка → unavailable section), затем `await rating_profile().get()` (required, ошибка пробрасывается). TaskGroup запрещён, см. блок «Важная тонкость TaskGroup» ниже. | | `promotion_summary` | conditional aggregator | `list_orders`; если `item_ids` переданы — дополнительно `list_services` (`avito/client.py:431-465`) | без `item_ids` один `await`; с `item_ids` допускается **`asyncio.TaskGroup`** на `list_orders` и `list_services`. | | `capabilities` | статическая справка | не делает сетевых probe-запросов, только строит `CapabilityDiscoveryResult` из текущей конфигурации (`avito/client.py:467-531`) | остаётся sync-shaped CPU-only методом без `TaskGroup` и без сетевых вызовов. Если позже capabilities станет probe-методом, это отдельное API/behavior изменение с тестами. | @@ -684,6 +721,35 @@ error semantics. Алиасы (`business_summary`), CPU-only методы (`capa leaf'ы (`order_summary`) не получают `TaskGroup`. Это записано в DoD M-final ниже как явная проверка через code review checklist. +**Важная тонкость TaskGroup для смешанных required+optional веток.** В sync-коде +`review_summary` сначала делает `review().list()` через `_safe_summary` (optional, ошибка +превращается в unavailable section), потом `rating_profile().get()` (required, ошибка +пробрасывается). Если в async положить обе задачи в **один** `TaskGroup`, и required +`rating` бросит — TaskGroup отменит ещё-не-завершённый optional `reviews`-task через +`CancelledError`. Это **меняет sync-semantics**: в sync `reviews` уже мог отработать +успешно к моменту `rating`-ошибки. Поэтому правильный async-паттерн для смешанных +веток — **sequential within branch, parallel across required-only**: + +```python +async def review_summary(self, ...) -> ReviewSummary: + # reviews — optional, всегда оборачивается в _safe_summary_async + reviews_result, reviews_unavailable = await _safe_summary_async( + "reviews", lambda: self.review(...).list(...).materialize() + ) + # rating — required, пробрасывает AvitoError + rating = await self.rating_profile().get() + return ReviewSummary(reviews=reviews_result, rating=rating, + unavailable_sections=reviews_unavailable) +``` + +`asyncio.TaskGroup` в `review_summary` допустим **только** если обе ветки идут через +`_safe_summary_async` (т.е. обе optional) — это меняет публичный контракт и **запрещено** +в M-final. Допустимый параллелизм: если бы обе были required и независимы. Текущая +смесь optional+required исключает TaskGroup-параллелизм для `review_summary`. +DoD M-final проверяет: `review_summary` async не использует TaskGroup, выполняется +последовательно reviews-then-rating. То же правило применяется к любому будущему +агрегатору со смешанным required/optional набором веток. + **Cancellation-safe паттерн для агрегаторов (обязательный).** Используется `asyncio.TaskGroup` (Python 3.11+, у нас floor 3.12+) с per-section try/except, конвертирующим `AvitoError → SummaryUnavailableSection` (как sync `_safe_summary`, @@ -796,9 +862,24 @@ access внутри одного event loop'а; создавать новый in 3. `swagger_linter.py`: - `_validate_single_binding_per_sdk_method` — без изменений: ключ `binding.sdk_method` уникален даже в async (т.к. `module.class.method` отличается). - `_validate_duplicate_bindings` — ключ `(operation_key, variant)` вместо `operation_key`. Допустимо иметь две независимые цепочки (sync + async) на одну swagger-операцию. - - `_validate_factory` становится variant-aware: sync binding проверяет factory на - `AvitoClient`, async binding — на `AsyncAvitoClient`. Иначе можно получить - зелёный swagger-lint при отсутствии async factory-метода. + - `_validate_factory` становится variant-aware с **class-gated coverage**, симметрично + `_validate_complete_bindings`: + - sync binding с заданным `factory` проверяет factory на `AvitoClient`. + - async binding с заданным `factory` проверяется на `AsyncAvitoClient` **только если** + соответствующий `Async` уже существует в домене (тот же class-gated предикат, + что в `_validate_complete_bindings`). Если `Async` ещё не появился — async + binding'и его класса вообще не должны существовать (per-class инвариант), а если + исключения — не проверяется. + - async binding **без** `factory` в декораторе (в первую очередь auth-bindings + `AsyncTokenClient.request_client_credentials_token`, + `AsyncAlternateTokenClient.*`) пропускается ровно так же, как sync без `factory`. + Так в M1 (когда в `AsyncAvitoClient` ещё нет ни одного domain-factory) async auth + bindings не падают на `_validate_factory`, а с M2-PoC `tariff()` factory обязан + появиться. + Без этого class-gated подхода либо M1 красный (ложный fail на auth), либо инвариант + ослаблен (зелёный swagger-lint при отсутствующем async factory в M3+). DoD M1 явно + включает проверку, что `_validate_factory(variant="async")` зелёный для async auth + bindings и не требует ни одного domain-factory на `AsyncAvitoClient`. - `_validate_complete_bindings(operations, bindings)` → `_validate_complete_bindings(operations, bindings, variant)`. Запускается дважды: - для `variant="sync"`: ожидаемое множество = все `operations` (как сейчас). - для `variant="async"`: ожидаемое множество = **per-class**, не per-domain. @@ -825,7 +906,22 @@ access внутри одного event loop'а; создавать новый in (Дробление допустимо только при явном решении, а не «сделаю остальное потом» — см. DoD M3…M12.) - `_validate_operation_spec_coverage` — без изменений (sync OperationSpec — единый источник истины для обоих режимов; реюз спеки между sync и async-методами не запрещён). `used_specs` — `set[id(spec)]`, поэтому одна и та же `OperationSpec` от sync и async binding'ов не дублируется и не теряется. - - `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) — резолвит spec через `unwrapped_method.__globals__`. Async-методы должны импортировать spec явно (`from avito..operations import LIST_SPEC`), иначе резолв вернёт `()` и spec будет считаться unbound. Pre-flight тест проверяет, что это работает; если нет — расширяем функцию в Phase 1b. + - `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) — резолвит spec через `unwrapped_method.__globals__`. Async-методы должны импортировать spec явно (`from avito..operations import LIST_SPEC`), иначе резолв вернёт `()` и spec будет считаться unbound. Pre-flight тест проверяет, что это работает; если нет — fallback-план для Phase 1b расписан **до** старта M1, не «по ситуации»: + 1. **Primary fallback** (минимум изменений): расширить `_operation_specs_for_sdk_method`, + чтобы помимо `__globals__` он также пробегал `inspect.getsourcefile(method)` → + `ast.parse` → искал в исходнике **локальные** ссылки на `OperationSpec`-объекты + и резолвил их через AST + `getattr` модуля. Это покрывает кейс, когда spec + вызывается через `self._execute(LIST_SPEC, ...)` без `from ... import LIST_SPEC` + на module-level. + 2. **Secondary fallback** (структурный): ввести class-level атрибут + `__operation_specs__: Mapping[str, OperationSpec]` на каждом domain-классе, + перечисляющий `(method_name, spec)` пары. `_operation_specs_for_sdk_method` + читает атрибут первым делом, до `__globals__`. Этот вариант требует туда же + дописать sync-классы (для симметрии), но даёт детерминированный резолв без AST. + Решение между primary и secondary принимается **по результату pre-flight**, не позже, + с оценкой scope в часах. Если ни один не работает — это blocker для M1 и план + откатывается на пересмотр (фундамент без работоспособного swagger-coverage гейта + не годен). - `_validate_json_body_model_coverage` — запускается по sync bindings; async bindings проверяются через `AsyncSwaggerFakeTransport` contract suite, чтобы не дублировать schema-lint ошибки на общих `OperationSpec`. @@ -936,6 +1032,15 @@ access внутри одного event loop'а; создавать новый in - [ ] Прочитать `avito/core/deprecation.py`: текущий `deprecated_method` возвращает sync-wrapper. M1 обязан добавить async-aware wrapper до портирования deprecated методов `cpa`/`ads`. +- [ ] Прочитать `avito/core/swagger_linter.py::_validate_factory` целиком и зафиксировать + текущее поведение: на каких полях binding'а он гейтится (`factory`, `factory_args`), + как резолвит factory на `AvitoClient`, что считает ошибкой. M1 обязан расширить + его с class-gated coverage (см. Swagger-секцию). Без полного понимания текущей + логики расширение рискует ослабить инвариант для sync-bindings. +- [ ] **Прогон pre-flight локально, фиксация результатов**: pre-flight тест на + `_operation_specs_for_sdk_method` для async-стаба фактически запущен; результат + (pass/fail) и выбранный fallback (none / primary / secondary) зафиксированы + в M1 PR description. Без фактического прогона M1 не открывается. ### M1 — Фундамент (1 PR) @@ -976,6 +1081,12 @@ DoD: baseline-тесту с `main` (см. pre-flight). Новые async tests не участвуют в сравнении. Любое расхождение по старым nodeid = blocker. - [ ] Phase 1a (`_merge_headers` рефакторинг) выделен отдельным коммитом внутри PR — для bisect-friendly history. +- [ ] **`pyproject.toml` содержит `asyncio_default_fixture_loop_scope = "function"`** в `[tool.pytest.ini_options]` рядом с `asyncio_mode = "strict"`. Без этого `pytest-asyncio` 0.23+ сыпет `PytestDeprecationWarning`, который при существующем `filterwarnings` ломает pytest-сессию. +- [ ] **`_validate_factory(variant="async")` зелёный для async auth bindings без единого domain-factory на `AsyncAvitoClient`**. Class-gated предикат: factory-check не запускается на async binding, чей класс ещё не имеет `Async` в домене, и пропускает binding'и без `factory` в декораторе. Закрепляется юнит-тестом `tests/core/test_swagger_linter.py::test_validate_factory_async_skips_unported_classes`. +- [ ] **Resolver `_operation_specs_for_sdk_method` для `async_domain.py`**: pre-flight smoke-тест зелёный (резолв через `__globals__` работает с `from ...operations import SOME_SPEC`). Если pre-flight красный — в этом же M1 PR применён primary fallback (AST-резолв из source-файла) **либо** secondary fallback (class-level `__operation_specs__`). Любой fallback зафиксирован в `swagger_linter.py` с тестом `tests/core/test_swagger_linter.py::test_resolve_specs_from_async_domain`. +- [ ] **`AsyncOperationExecutor` retry-резолюция зеркалит sync**: тест `tests/core/test_async_executor.py::test_executor_retry_resolution_matches_sync` параметризован `(retry, spec.retry)` тройкой и сверяет результат с sync `OperationExecutor`. +- [ ] **`AsyncAuthProvider.invalidate_token` sync и идемпотентен**: тест `tests/auth/test_async_provider.py::test_invalidate_token_is_sync_and_idempotent` зелёный. +- [ ] **`httpx.AsyncClient` создаётся с дефолтными limits** (без переопределения). Тест на запрет SDK-side тюнинга limits в M1 не нужен; в DoD M-final есть проверка fan-out ≤ 6. - [ ] CHANGELOG `## [Unreleased]` в корневом `CHANGELOG.md` дополнен: `- Фундамент Async API: AsyncTransport, AsyncAuthProvider, AsyncOperationExecutor, AsyncPaginatedList, @@ -1038,10 +1149,10 @@ DoD M2-PoC: | M7 | `messenger` | 18 | без пагинации | | M8 | `jobs` | 25 | webhook-методы (REST) | | M9 | `promotion` | 24 | без пагинации | -| M10 | `autoteka` | 26 | использует autoteka token flow → end-to-end проверка `AsyncAuthProvider.get_autoteka_access_token` + `_autoteka_refresh_lock` под нагрузкой (concurrent first-touch) | +| M10 | `autoteka` | 26 | использует autoteka token flow → end-to-end проверка `AsyncAuthProvider.get_autoteka_access_token` + `_autoteka_refresh_lock` под нагрузкой: **20 одновременных корутин** в `asyncio.gather(...)` стартуют первый `get_autoteka_access_token()`; counter мокированного `/token` route после `await gather(...)` обязан быть **ровно 1**. Закрепляется тестом `tests/auth/test_async_provider.py::test_autoteka_concurrent_first_touch_single_token_request`. | | M11 | `ads` | 28 | вторая и третья `AsyncPaginatedList` (`Ad.list`, `AutoloadReport.list`); сложный offset/limit first-page reuse в `Ad.list` (`avito/ads/domain.py:266`) | | M12 | `orders` | 45 | самый большой; идемпотентность критична | -| M-final | — | — | convenience-методы `AsyncAvitoClient`: `account_health`, `listing_health`, `review_summary` и `promotion_summary` используют `asyncio.TaskGroup` только там, где есть фактически независимые сетевые ветки; `business_summary` делегирует в `account_health`; `chat_summary`/`order_summary` остаются sequential leaf; `capabilities` остаётся CPU-only без сетевых probe-запросов. `asyncio.gather(return_exceptions=True)` запрещён. Финальный hardening; `docs/site/how-to/async.md`; CHANGELOG `## [Unreleased]` → `## [2.1.0]` (свод накопленных пунктов из M1…M12 + запись про convenience-методы). | +| M-final | — | — | convenience-методы `AsyncAvitoClient`: `account_health`, `listing_health` и `promotion_summary` (при `item_ids`) используют `asyncio.TaskGroup` только там, где все ветки **required-only** и фактически независимы; `review_summary` остаётся sequential reviews-then-rating (mixed required+optional, см. блок «Важная тонкость TaskGroup»); `business_summary` делегирует в `account_health`; `chat_summary`/`order_summary` остаются sequential leaf; `capabilities` остаётся CPU-only без сетевых probe-запросов. `asyncio.gather(return_exceptions=True)` запрещён. Fan-out агрегаторов ≤ 6 задач in-flight. Финальный hardening; `docs/site/how-to/async.md`; CHANGELOG `## [Unreleased]` → `## [2.1.0]` (свод накопленных пунктов из M1…M12 + запись про convenience-методы). | Содержимое каждого M3…M12: @@ -1134,10 +1245,17 @@ DoD M2-PoC: не оставляется частичный домен в main. - [ ] **CHANGELOG обновлён**: в корневом `CHANGELOG.md` (раздел `## [Unreleased]`) добавлена строка вида `- Async-поддержка домена : Async, Async - (#)`. M-final сводит накопленные `Unreleased`-строки в релиз 2.1.0, - добавляя только запись про convenience-методы и `AsyncAvitoClient`-агрегаторы. - Без этого history-readers не увидят, в каком PR домен стал async, и release - notes 2.1.0 не получится собрать механически. + (#)` **строго в раздел `## [Unreleased]`**, а не в `## [2.1.0]` + (раздел 2.1.0 ещё не существует на этих PR). Шаблон записи на каждый M3…M12 PR: + ```markdown + ## [Unreleased] + ### Added + - Async-поддержка домена : Async, Async (#) + ``` + M-final сводит накопленные `Unreleased`-строки в релиз 2.1.0, добавляя только + запись про convenience-методы и `AsyncAvitoClient`-агрегаторы. Без этого + history-readers не увидят, в каком PR домен стал async, и release notes 2.1.0 + не получится собрать механически. ### Definition of done M-final — релиз 2.1.0 @@ -1328,3 +1446,10 @@ git push --tags | Concurrent iteration одного `AsyncPaginatedList` мутит общий `_cursor` → пользователь получает silent data corruption | Fail-fast контракт: второй `__aiter__` на активном instance бросает `RuntimeError`; fan-out делается через `await materialize()` или отдельный `AsyncPaginatedList` per consumer. | | Английский в новых сообщениях ошибок `async_domain.py` (STYLEGUIDE.md violation) | DoD M3…M12 включает явный пункт «сообщения ошибок только на русском»; code review проверяет каждый `raise ("...")`. | | `AsyncSwaggerFakeTransport` не синхронизирован со sync `SwaggerFakeTransport` | Добавляется в M1 как thin async mirror поверх общих schema/argument helpers. `tests/contracts/test_async_swagger_contracts.py` проходит по discovered `variant="async"` bindings на каждом этапе и в M-final покрывает все 204 operations. | +| `pytest-asyncio` 0.23+ выдаёт `PytestDeprecationWarning` без `asyncio_default_fixture_loop_scope` → при `filterwarnings = error` ломает всю pytest-сессию | M1 обязан добавить `asyncio_default_fixture_loop_scope = "function"` в `[tool.pytest.ini_options]` рядом с `asyncio_mode = "strict"`. Закреплено в DoD M1. | +| `_validate_factory(variant="async")` падает на async auth bindings в M1 (нет ни одного domain-factory на `AsyncAvitoClient`) ИЛИ пропускает missing async factory в M3+ | Class-gated реализация: factory-check skip'ается на async binding'ах без `Async` в домене и на binding'ах без `factory` в декораторе. Тест `test_validate_factory_async_skips_unported_classes` фиксирует поведение для M1, тест `test_validate_factory_async_requires_factory_for_ported_class` — для M2-PoC+. | +| `_operation_specs_for_sdk_method` не находит spec из `async_domain.py`, и Phase 1b упирается в это в середине без плана | Fallback расписан **до** старта M1 (см. Swagger-секцию): primary — AST-резолв из source-файла, secondary — class-level `__operation_specs__`. Pre-flight smoke-тест выбирает один из вариантов **до** открытия M1 PR; решение зафиксировано в PR description. | +| `AsyncOperationExecutor` берёт retry только из аргумента или только из `spec.retry` → расхождение с sync executor незаметно | DoD M1 включает параметризованный тест `test_executor_retry_resolution_matches_sync` на три тройки `(retry, spec.retry, expected)`, сверяющий результат с sync `OperationExecutor`. | +| `httpx.AsyncClient` с дефолтными limits + неограниченный fan-out в convenience-методах M-final → starvation pool'а | M1 фиксирует дефолтные `httpx.Limits` (без переопределения). DoD M-final требует fan-out ≤ 6 in-flight задач на агрегатор. Текущие sync-агрегаторы укладываются в этот предел (max ~5 веток в `account_health`). | +| `review_summary` async с TaskGroup отменяет в-полёте optional `reviews`-task при ошибке required `rating` → меняет sync semantics | `review_summary` async **обязан** быть sequential reviews-then-rating без TaskGroup, как зафиксировано в таблице классификации и блоке «Важная тонкость TaskGroup». DoD M-final code review checklist это явно проверяет. | +| `AsyncAuthProvider.invalidate_token` сделан корутиной с `async with self._refresh_lock` → ложная защита, рост latency 401-handling, расхождение с sync | Контракт явно `def invalidate_token(self) -> None`, без await; тест `test_invalidate_token_is_sync_and_idempotent` фиксирует синхронность и идемпотентность. | From d9deee77f1d9bafe37825bc6070d205fa0a2062b Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 13:28:50 +0300 Subject: [PATCH 09/26] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D1=8E=20=D0=BD=D0=B0=D0=B4=20todo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- todo.md | 174 +++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 140 insertions(+), 34 deletions(-) diff --git a/todo.md b/todo.md index ef1ed61..389e3db 100644 --- a/todo.md +++ b/todo.md @@ -143,7 +143,7 @@ Module-level функция `_map_token_response` (`avito/auth/provider.py:35`) | `avito//__init__.py` | На каждом M2/M3…M12 добавляется export соответствующих `Async` классов; без этого `_gen_reference.py`, mkdocstrings и IDE-discovery не увидят async-поверхность. | | `docs/site/assets/_gen_reference.py` | + расширение `public_domain_packages()` / `public_domain_classes()` / `public_domain_methods()` для подхвата `async_domain.py` и `Async`-классов рядом с sync-аналогами. Builder не должен зависеть только от `avito..__all__`: он обязан импортировать `avito..domain` и `avito..async_domain` напрямую, затем сохранять порядок sync-класс → async-класс. Важно: текущий `write_domain_pages()` пишет только `::: avito.` и не использует helper-функции классов/методов; M1 обязан перевести генерацию domain pages на явные class-директивы (`::: avito..ClassName`) в порядке sync-класс → async-класс. `ensure_debug_info_exists()` расширяется на `AsyncAvitoClient.debug_info()`. Без этого `make docs-strict` после M2-PoC не докажет полноту reference. | | `avito/core/domain.py` | + `AsyncDomainObject` с async `_execute` и async `_resolve_user_id`. Sync `DomainObject` — без изменений. | -| `pyproject.toml` | + `pytest-asyncio = "^0.24"` в dev-deps. + `[tool.pytest.ini_options] asyncio_mode = "strict"` и `asyncio_default_fixture_loop_scope = "function"`. Без явного `asyncio_default_fixture_loop_scope` `pytest-asyncio` 0.23+ выдаёт `PytestDeprecationWarning`, который при текущем `filterwarnings` ломает всю pytest-сессию. Закрепляется в M1 PR. | +| `pyproject.toml` | + `pytest-asyncio = "^0.24"` в dev-deps. + `[tool.pytest.ini_options] asyncio_mode = "strict"` и `asyncio_default_fixture_loop_scope = "function"`. Без явного `asyncio_default_fixture_loop_scope` `pytest-asyncio` 0.23+ выдаёт `PytestDeprecationWarning` на каждом тесте — на момент M1 в `pyproject.toml` нет `filterwarnings = error` (проверено grep'ом), поэтому это не сломает pytest сразу, но будет накапливаться шум в выводе и заблокирует включение `filterwarnings = error` в будущем. Закрепляется в M1 PR превентивно. | | `Makefile` | + цель `async-parity-lint`, включённая в `quality`; `make check` после M1 должен оставаться зелёным. | | `scripts/lint_architecture.py` | `LEGACY_FILENAMES` не трогаем, но public-method checks применяются к `domain.py` и `async_domain.py`; AST-парсер должен учитывать `ast.AsyncFunctionDef` наравне с `ast.FunctionDef`. | | `scripts/lint_docstrings.py` | Проверяет `avito/*/domain.py` и `avito/*/async_domain.py`, чтобы async public methods не получили generic/reference-плохие docstring-и. | @@ -159,7 +159,10 @@ Module-level функция `_map_token_response` (`avito/auth/provider.py:35`) ``` avito/core/_transport_shared.py # IO-agnostic helpers, retry/error mapping/headers - # (_merge_headers принимает bearer_token: str | None) + # (_merge_headers принимает bearer_token: str | None; + # _request_binary_async зеркало sync _request_binary) +avito/core/_async_rate_limit.py # AsyncRateLimiter (asyncio.Lock + asyncio.sleep + # поверх shared RateLimitState) avito/core/async_transport.py # AsyncTransport (httpx.AsyncClient) avito/core/async_pagination.py # AsyncPaginatedList, AsyncPaginator, AsyncPageFetcher avito/auth/_cache.py # TokenCache + map_token_response @@ -223,17 +226,37 @@ class AsyncTransport: Реализует `AsyncOperationTransport` (Protocol, async-зеркало `OperationTransport` из `avito/core/operations.py`). -`AsyncTransport.request()` внутри: +`AsyncTransport.request()` внутри (точное зеркало sync `Transport.request()`, +`avito/core/transport.py:146-185`): 1. вызывает `bearer_token = await self._auth_provider.get_access_token()` (если требуется); 2. передаёт `bearer_token` в shared `_merge_headers(...)` — строго pure-функция; -3. петля retry-decisions делегирует в shared `_decide_*_retry`; -4. при 401 — `self._auth_provider.invalidate_token()` (sync-операция clear cache), +3. **на каждой попытке retry-loop** (включая первую): `delay = await + self._rate_limiter.acquire()` **перед** `await self._client.request(...)` — зеркало + sync `Transport.request()` строка 148. Если `delay > 0` — пишется тот же info-лог + `transport rate limit delay` с `reason="client_rate_limit"`, что и sync; +4. **после успешного ответа**: `self._rate_limiter.observe_response(headers= + response.headers)` — зеркало sync строка 183. `observe_response` остаётся sync- + методом `AsyncRateLimiter` (внутри только мутация state под `asyncio.Lock`, + без sleep'а, без IO; await не нужен); +5. петля retry-decisions делегирует в shared `_decide_*_retry`; +6. при 401 — `self._auth_provider.invalidate_token()` (sync-операция clear cache), повторный `await self._auth_provider.get_access_token()`, один retry; -5. ловит только `httpx.TimeoutException` и `httpx.NetworkError`, как sync +7. ловит только `httpx.TimeoutException` и `httpx.NetworkError`, как sync `Transport` на момент M1. `asyncio.CancelledError` и любой `BaseException` пробрасываются наружу без retry — см. контракт shared retry-петли выше. +**Запрещено** вызывать `self._client.request(...)` без предварительного `await +self._rate_limiter.acquire()`: иначе rate-limit headers (Retry-After, X-RateLimit-*) +обновят state, но реальная сериализация запросов через лимитер не сработает, и +параллельные корутины уйдут пачкой. Закрепляется тестом +`tests/core/test_async_transport.py::test_request_acquires_rate_limiter_before_httpx_call`, +который через `AsyncFakeTransport` запускает 5 параллельных корутин на один transport +и проверяет, что `RateLimitState._tokens` обновляется ровно по одному до каждого +httpx-вызова (а не пачкой), а второй тест +`test_request_calls_observe_response_after_success` проверяет, что +`observe_response` дёрнут с теми же headers, что вернул mock. + **Rate-limiter в async.** Один rate-limiter принадлежит одному `AsyncTransport` (а не каждой корутине-вызову). Все корутины, делящие транспорт, должны сериализоваться через `asyncio.Lock` внутри лимитера — иначе N параллельных запросов @@ -262,8 +285,10 @@ class AsyncTransport: вычислении/мутации state, sleep выполняется вне `threading.Lock`. Любое изменение sync-concurrency semantics — отдельный сознательный PR, не часть M1. -3. **`AsyncRateLimiter`** (новый, в `avito/core/async_transport.py` или отдельно - в `avito/core/_async_rate_limit.py` — выбор фиксируется в M1 PR): хранит +3. **`AsyncRateLimiter`** (новый, **в `avito/core/_async_rate_limit.py`** — + симметрично sync `avito/core/rate_limit.py`, чтобы grep `RateLimit` находил оба + модуля рядом и async-инфраструктура не размывалась внутрь `async_transport.py`). + Хранит **отдельный `RateLimitState`** (не shared с sync — состояние не делится между режимами; sync- и async-транспорты — независимые сущности с независимыми bucket'ами) + `asyncio.Lock` + `_clock` + `_sleep: Callable[[float], @@ -332,15 +357,28 @@ class AsyncOperationExecutor: `response_model.from_payload(payload)`; - `response_kind == "empty"`: `response = await transport.request(...)`, затем `EmptyResponse(status_code=response.status_code, headers=dict(response.headers))`; -- `response_kind == "binary"`: executor вызывает `await transport.request(...)` - с method/path из `OperationSpec`, затем строит `BinaryResponse` тем же helper-кодом, - что sync `_request_binary()` использует для `OrderLabel.download()`. `download_binary()` - остаётся низкоуровневым convenience-методом `AsyncTransport`, но **не** входит в - `AsyncOperationTransport` Protocol, иначе binary-ветка начнёт отличаться от sync - executor и потеряет method/path из `OperationSpec`. - -Binary-ветка закрепляется M1 unit-тестом на executor и M12 domain-тестом -`OrderLabel.download()` через `AsyncSwaggerFakeTransport`/`AsyncFakeTransport`. +- `response_kind == "binary"`: executor вызывает module-level helper + `_request_binary_async(transport, *, spec, path, context, params, headers, + idempotency_key)` — async-зеркало sync `_request_binary` (`avito/core/operations.py:254-278`). + Helper module-level и принимает `AsyncOperationTransport` Protocol (а не конкретный + `AsyncTransport`), как sync принимает `OperationTransport`. Внутри + `await transport.request(...)` с method/path из `OperationSpec`, затем строит + `BinaryResponse(content=response.content, content_type=..., + filename=_extract_filename(...), status_code=..., headers=dict(response.headers))`. + Helper живёт в **`avito/core/operations.py`** рядом с sync `_request_binary` (а не + в `_transport_shared.py`), потому что sync-версия уже там и работает с + `OperationTransport` Protocol — это два симметричных близнеца на одном уровне + абстракции, и разносить их по разным модулям только плодит навигацию. + `_extract_filename` уже module-level в том же файле — переиспользуется без копий. + `download_binary()` остаётся низкоуровневым convenience-методом `AsyncTransport`, + но **не** входит в `AsyncOperationTransport` Protocol, иначе binary-ветка начнёт + отличаться от sync executor и потеряет method/path из `OperationSpec`. + +Binary-ветка закрепляется M1 unit-тестом на executor +(`tests/core/test_async_executor.py::test_binary_branch_uses_request_binary_async_helper`, +проверяет, что `_request_binary_async` действительно вызван и `BinaryResponse` +собран из тех же полей, что sync) и M12 domain-тестом `OrderLabel.download()` через +`AsyncSwaggerFakeTransport`/`AsyncFakeTransport`. **Retry-политика executor'а — точное зеркало sync.** `AsyncOperationExecutor.execute()` выбирает retry в том же порядке, что sync `OperationExecutor`: `retry or spec.retry`, @@ -821,6 +859,26 @@ domain-тесты, как sync `FakeTransport.as_client()`, не требова Без этого M1 smoke может выглядеть «авторизованным», но фактически пройти через transport с `auth_provider=None` и не проверить refresh-семантику. +**Семантика `user_id` отдельно от `authenticated`.** `as_client(user_id=N, +authenticated=False)` — корректный паттерн для domain-тестов, которые вызывают +методы с `_resolve_user_id` (например, `AsyncAccount.get_balance()`). В этом +режиме: + +- `AsyncAvitoClient.settings.user_id == N` — `_resolve_user_id` берёт его как + fallback и **не** делает raw запрос на `/core/v1/accounts/self`; +- `AsyncTransport` создаётся с `auth_provider=None` — request-level header + `Authorization` не подставляется; `RequestContext.requires_auth=True` без auth + provider'а не падает (зеркало sync `Transport._merge_headers`: `if + context.requires_auth and self._auth_provider is not None: ...`); +- если домен-тест требует и `user_id`, и проверки auth pipeline (refresh, 401 + invalidate) — комбинируется `as_client(user_id=N, authenticated=True)`, но при + этом любой запрос на `/core/v1/accounts/self` всё равно не дёргается, потому что + `user_id` уже резолвлен. + +Это зеркало sync `FakeTransport.as_client(user_id=N)` контракта (без +`authenticated`). Закрепляется тестом +`tests/core/test_async_fake_transport.py::test_as_client_user_id_skips_self_lookup`. + **Concurrency policy.** `_handle` мутирует `self.requests.append(...)` и `route.pop(0)` для `route_sequence`-сценариев. Для тестов с `asyncio.gather(...)` (в первую очередь M-final convenience-методы) `_handle` берёт `self._handle_lock = asyncio.Lock()` и @@ -976,6 +1034,17 @@ access внутри одного event loop'а; создавать новый in тестов, потому что STYLEGUIDE разрешает в pytest только functional tests и Swagger-spec compliance tests. + Linter дополнительно экспортирует `iter_async_classes() -> Iterator[type[AsyncDomainObject]]` + как публичный API модуля (без `_`-префикса). Это **единственный источник истины** + для списка `Async` классов: M-final verification скрипт берёт его оттуда вместо + хардкода имён, так что добавление нового класса не требует правки M-final проверки. + Контракт `iter_async_classes()`: + - возвращает все `Async` классы из всех `avito//async_domain.py` + (исключая `EXCLUDED_PACKAGES = {"auth", "core", "testing"}` — auth-bindings + reference не получают); + - порядок: stable sort по `(package_name, class_name)`; + - не зависит от prior-state (можно вызывать до и после любого M-этапа). + ## Этапы ### Pre-flight для PR M1 @@ -1032,6 +1101,13 @@ access внутри одного event loop'а; создавать новый in - [ ] Прочитать `avito/core/deprecation.py`: текущий `deprecated_method` возвращает sync-wrapper. M1 обязан добавить async-aware wrapper до портирования deprecated методов `cpa`/`ads`. +- [ ] `grep -rn "@deprecated_method\|deprecated_method(" avito/cpa/ avito/ads/` — + зафиксировать **точное** число sync deprecated методов, требующих async-двойников. + На момент написания плана: 3 в `avito/cpa/domain.py:491,541,585` и 4 в + `avito/ads/domain.py:1416,1457,1523,1558` — итого 7. async-aware wrapper в + `deprecation.py` — обязательный артефакт M1, без него M6 (`cpa`) и M11 (`ads`) + не закроются. Если фактическое число расходится с зафиксированным — обновить + таблицу sequencing и DoD M6/M11 до старта M1. - [ ] Прочитать `avito/core/swagger_linter.py::_validate_factory` целиком и зафиксировать текущее поведение: на каких полях binding'а он гейтится (`factory`, `factory_args`), как резолвит factory на `AvitoClient`, что считает ошибкой. M1 обязан расширить @@ -1081,12 +1157,16 @@ DoD: baseline-тесту с `main` (см. pre-flight). Новые async tests не участвуют в сравнении. Любое расхождение по старым nodeid = blocker. - [ ] Phase 1a (`_merge_headers` рефакторинг) выделен отдельным коммитом внутри PR — для bisect-friendly history. -- [ ] **`pyproject.toml` содержит `asyncio_default_fixture_loop_scope = "function"`** в `[tool.pytest.ini_options]` рядом с `asyncio_mode = "strict"`. Без этого `pytest-asyncio` 0.23+ сыпет `PytestDeprecationWarning`, который при существующем `filterwarnings` ломает pytest-сессию. +- [ ] **`pyproject.toml` содержит `asyncio_default_fixture_loop_scope = "function"`** в `[tool.pytest.ini_options]` рядом с `asyncio_mode = "strict"`. На момент M1 `filterwarnings = error` в проекте не настроен, поэтому отсутствие этой опции не сломает pytest сразу, но `pytest-asyncio` 0.23+ начнёт сыпать `PytestDeprecationWarning` на каждом async-тесте — это накапливается в выводе и заблокирует будущее включение `filterwarnings = error`. Включаем превентивно. - [ ] **`_validate_factory(variant="async")` зелёный для async auth bindings без единого domain-factory на `AsyncAvitoClient`**. Class-gated предикат: factory-check не запускается на async binding, чей класс ещё не имеет `Async` в домене, и пропускает binding'и без `factory` в декораторе. Закрепляется юнит-тестом `tests/core/test_swagger_linter.py::test_validate_factory_async_skips_unported_classes`. - [ ] **Resolver `_operation_specs_for_sdk_method` для `async_domain.py`**: pre-flight smoke-тест зелёный (резолв через `__globals__` работает с `from ...operations import SOME_SPEC`). Если pre-flight красный — в этом же M1 PR применён primary fallback (AST-резолв из source-файла) **либо** secondary fallback (class-level `__operation_specs__`). Любой fallback зафиксирован в `swagger_linter.py` с тестом `tests/core/test_swagger_linter.py::test_resolve_specs_from_async_domain`. - [ ] **`AsyncOperationExecutor` retry-резолюция зеркалит sync**: тест `tests/core/test_async_executor.py::test_executor_retry_resolution_matches_sync` параметризован `(retry, spec.retry)` тройкой и сверяет результат с sync `OperationExecutor`. - [ ] **`AsyncAuthProvider.invalidate_token` sync и идемпотентен**: тест `tests/auth/test_async_provider.py::test_invalidate_token_is_sync_and_idempotent` зелёный. - [ ] **`httpx.AsyncClient` создаётся с дефолтными limits** (без переопределения). Тест на запрет SDK-side тюнинга limits в M1 не нужен; в DoD M-final есть проверка fan-out ≤ 6. +- [ ] **`AsyncTransport.request()` вызывает `await self._rate_limiter.acquire()` перед каждым httpx-вызовом и `observe_response()` после успешного ответа** — точное зеркало sync `Transport.request()` (строки 148, 183). Закреплено двумя тестами: `tests/core/test_async_transport.py::test_request_acquires_rate_limiter_before_httpx_call` (5 параллельных корутин на один transport — токены тратятся по одному, а не пачкой) и `::test_request_calls_observe_response_after_success` (post-condition). +- [ ] **`_request_binary_async` module-level helper в `avito/core/operations.py`** — async-зеркало sync `_request_binary`. Принимает `AsyncOperationTransport` Protocol, возвращает `BinaryResponse` с теми же полями. Closed-test: `tests/core/test_async_executor.py::test_binary_branch_uses_request_binary_async_helper`. +- [ ] **`AsyncRateLimiter` живёт в `avito/core/_async_rate_limit.py`** (не внутри `async_transport.py`). Симметрично sync `avito/core/rate_limit.py`. +- [ ] **`scripts/lint_async_parity.py` экспортирует `iter_async_classes()` как публичный API** — используется M-final verification скриптом и любым внешним инструментом, нуждающимся в каноническом списке `Async` классов. - [ ] CHANGELOG `## [Unreleased]` в корневом `CHANGELOG.md` дополнен: `- Фундамент Async API: AsyncTransport, AsyncAuthProvider, AsyncOperationExecutor, AsyncPaginatedList, @@ -1138,7 +1218,29 @@ DoD M2-PoC: ### M3…M12 + M-final — Закрытие доменов (по PR на домен) -Порядок (нарастающая сложность; самый простой шёл в PoC): +**Sequencing constraints** — что блокирует что (после зелёного M2-PoC): + +| Этап | Должен идти после | Причина | +|---|---|---| +| M3 `ratings` | M2-PoC | базовый шаблон без особенностей; служит вторым sanity-check'ом фундамента | +| M4 `accounts` | M2-PoC, M3 | первый домен с `AsyncPaginatedList` — валидирует пагинацию до M11 | +| M5 `realty` | M2-PoC | без пагинации; параллелен с M3/M6/M7/M8/M9 | +| M6 `cpa` | M2-PoC + async-aware `deprecated_method` уже мерджнут в M1 | 3 deprecated метода в `cpa/domain.py` | +| M7 `messenger` | M2-PoC | без пагинации; параллелен с M3/M5/M6/M8/M9 | +| M8 `jobs` | M2-PoC | webhook-методы (REST), без пагинации; параллелен | +| M9 `promotion` | M2-PoC | без пагинации; параллелен | +| M10 `autoteka` | M2-PoC | autoteka token flow — независимая часть auth | +| M11 `ads` | **M4 (`accounts`)** + async-aware `deprecated_method` из M1 | сложный `Ad.list` first-page reuse тестируется после простого `AsyncPaginatedList`; 4 deprecated метода в `ads/domain.py` | +| M12 `orders` | M2-PoC | независимый; идемпотентность критична, но не блокируется другим доменом | +| M-final | **все M3…M12 + M10** | `AsyncAvitoClient.account_health` собирает все домены; `_safe_summary_async` симметричен sync `_safe_summary`; M10 обязателен для autoteka concurrent first-touch теста (см. таблицу M3…M12 ниже) | + +**Параллельность**: после M2-PoC можно открывать M3, M5, M6, M7, M8, M9, M10, M12 в +любом порядке (включая параллельно). M4 — обязательный гейт перед M11. M-final — +последним. Cumulative parity invariant (см. DoD M3…M12) гарантирует, что merge +порядок параллельных PR'ов не имеет значения: каждый merge оставляет линтер зелёным +для всех уже портированных доменов. + +Порядок в таблице ниже (нарастающая сложность; самый простой шёл в PoC): | # | Домен | Sync-методов с binding | Особенности | |---|---|---|---| @@ -1262,7 +1364,7 @@ DoD M2-PoC: «Финальный hardening» определяется проверяемо: - [ ] **Convenience-методы реализованы по таблице классификации** (агрегатор / алиас / leaf / CPU-only). Code review проверяет: `asyncio.TaskGroup` стоит только в ветках с фактически независимыми сетевыми вызовами (`account_health`, `listing_health`, `review_summary`, `promotion_summary` при наличии `item_ids`); в `business_summary` — `return await self.account_health(...)` без `TaskGroup`; `chat_summary` и `order_summary` sequential; `capabilities` не делает сетевых probe-запросов и не использует `TaskGroup`. Любое нарушение = blocker. -- [ ] **`_safe_summary_async` живёт в одном модуле с sync `_safe_summary`** — `avito/client.py` (вынесение в общий `avito/summary/_helpers.py` допускается, но требует одновременного переноса sync `_safe_summary`; частичное вынесение запрещено, чтобы не разделять симметричные хелперы по разным файлам). Импорт в `avito/async_client.py` явный. +- [ ] **`_safe_summary_async` живёт в одном модуле с sync `_safe_summary`** — `avito/client.py` (вынесение в общий `avito/summary/_helpers.py` допускается, но требует одновременного переноса sync `_safe_summary`; частичное вынесение запрещено, чтобы не разделять симметричные хелперы по разным файлам). Импорт в `avito/async_client.py` явный (`from avito.client import _safe_summary, _safe_summary_async`). Циркулярность не возникает: `avito/client.py` не импортирует `avito/async_client.py`, поэтому import-граф остаётся ацикличным; проверяется командой `python -c "import avito.async_client"` без ошибок и `python -c "import avito.client"` без ошибок. - [ ] **Версия пакета поднята до 2.1.0**: `poetry version 2.1.0`, изменение в `pyproject.toml` зафиксировано в M-final PR. CHANGELOG `## [Unreleased]` → `## [2.1.0] - YYYY-MM-DD`, накопленные строки M1…M12 + запись про convenience-методы и `AsyncAvitoClient`-агрегаторы сведены в один раздел. `git tag v2.1.0` ставится после merge M-final. - [ ] **`AsyncSwaggerFakeTransport` contract suite полный**: `tests/contracts/test_async_swagger_contracts.py` вызывает все async bindings (204 Swagger operations, включая auth-bindings) @@ -1371,21 +1473,19 @@ poetry version 2.1.0 # бамп до 2.1.0 grep -E "^## \[2\.1\.0\]" CHANGELOG.md # секция 2.1.0 существует grep -E "^## \[Unreleased\]" CHANGELOG.md # Unreleased пуст или содержит только заголовок -# Reference после билда содержит обе поверхности на каждом домене +# Reference после билда содержит обе поверхности на каждом домене. +# Список Async классов получаем динамически из parity-linter (тот же источник +# истины, что используется в make async-parity-lint), а не хардкодим — иначе +# любое добавление/переименование класса требует ручной правки скрипта. poetry run mkdocs build --strict 2>&1 | tee /tmp/mkdocs.log -for cls in AsyncTariff AsyncReview AsyncReviewAnswer AsyncRatingProfile AsyncAccount \ - AsyncAccountHierarchy AsyncRealtyListing AsyncRealtyBooking AsyncRealtyPricing \ - AsyncRealtyAnalyticsReport AsyncCpaLead AsyncCpaChat AsyncCpaCall AsyncCpaArchive \ - AsyncCallTrackingCall AsyncChat AsyncChatMessage AsyncChatWebhook AsyncChatMedia \ - AsyncSpecialOfferCampaign AsyncVacancy AsyncApplication AsyncResume AsyncJobWebhook \ - AsyncJobDictionary AsyncPromotionOrder AsyncBbipPromotion AsyncTrxPromotion \ - AsyncCpaAuction AsyncTargetActionPricing AsyncAutostrategyCampaign AsyncAutotekaVehicle \ - AsyncAutotekaReport AsyncAutotekaMonitoring AsyncAutotekaScoring AsyncAutotekaValuation \ - AsyncAd AsyncAdStats AsyncAdPromotion AsyncAutoloadProfile AsyncAutoloadReport \ - AsyncAutoloadArchive AsyncOrder AsyncOrderLabel AsyncDeliveryOrder AsyncSandboxDelivery \ - AsyncDeliveryTask AsyncStock; do +poetry run python -c " +from scripts.lint_async_parity import iter_async_classes +for cls in iter_async_classes(): + print(cls.__name__) +" > /tmp/async_class_names.txt +while IFS= read -r cls; do grep -R -q "$cls" site/reference/domains || echo "MISSING async section: $cls" -done +done < /tmp/async_class_names.txt # После merge git tag v2.1.0 @@ -1446,10 +1546,16 @@ git push --tags | Concurrent iteration одного `AsyncPaginatedList` мутит общий `_cursor` → пользователь получает silent data corruption | Fail-fast контракт: второй `__aiter__` на активном instance бросает `RuntimeError`; fan-out делается через `await materialize()` или отдельный `AsyncPaginatedList` per consumer. | | Английский в новых сообщениях ошибок `async_domain.py` (STYLEGUIDE.md violation) | DoD M3…M12 включает явный пункт «сообщения ошибок только на русском»; code review проверяет каждый `raise ("...")`. | | `AsyncSwaggerFakeTransport` не синхронизирован со sync `SwaggerFakeTransport` | Добавляется в M1 как thin async mirror поверх общих schema/argument helpers. `tests/contracts/test_async_swagger_contracts.py` проходит по discovered `variant="async"` bindings на каждом этапе и в M-final покрывает все 204 operations. | -| `pytest-asyncio` 0.23+ выдаёт `PytestDeprecationWarning` без `asyncio_default_fixture_loop_scope` → при `filterwarnings = error` ломает всю pytest-сессию | M1 обязан добавить `asyncio_default_fixture_loop_scope = "function"` в `[tool.pytest.ini_options]` рядом с `asyncio_mode = "strict"`. Закреплено в DoD M1. | +| `pytest-asyncio` 0.23+ выдаёт `PytestDeprecationWarning` без `asyncio_default_fixture_loop_scope` → накапливается шум в pytest output, блокирует будущее включение `filterwarnings = error` | M1 обязан добавить `asyncio_default_fixture_loop_scope = "function"` в `[tool.pytest.ini_options]` рядом с `asyncio_mode = "strict"`. На момент M1 `filterwarnings = error` ещё не включён (превентивная защита). Закреплено в DoD M1. | | `_validate_factory(variant="async")` падает на async auth bindings в M1 (нет ни одного domain-factory на `AsyncAvitoClient`) ИЛИ пропускает missing async factory в M3+ | Class-gated реализация: factory-check skip'ается на async binding'ах без `Async` в домене и на binding'ах без `factory` в декораторе. Тест `test_validate_factory_async_skips_unported_classes` фиксирует поведение для M1, тест `test_validate_factory_async_requires_factory_for_ported_class` — для M2-PoC+. | | `_operation_specs_for_sdk_method` не находит spec из `async_domain.py`, и Phase 1b упирается в это в середине без плана | Fallback расписан **до** старта M1 (см. Swagger-секцию): primary — AST-резолв из source-файла, secondary — class-level `__operation_specs__`. Pre-flight smoke-тест выбирает один из вариантов **до** открытия M1 PR; решение зафиксировано в PR description. | | `AsyncOperationExecutor` берёт retry только из аргумента или только из `spec.retry` → расхождение с sync executor незаметно | DoD M1 включает параметризованный тест `test_executor_retry_resolution_matches_sync` на три тройки `(retry, spec.retry, expected)`, сверяющий результат с sync `OperationExecutor`. | | `httpx.AsyncClient` с дефолтными limits + неограниченный fan-out в convenience-методах M-final → starvation pool'а | M1 фиксирует дефолтные `httpx.Limits` (без переопределения). DoD M-final требует fan-out ≤ 6 in-flight задач на агрегатор. Текущие sync-агрегаторы укладываются в этот предел (max ~5 веток в `account_health`). | | `review_summary` async с TaskGroup отменяет в-полёте optional `reviews`-task при ошибке required `rating` → меняет sync semantics | `review_summary` async **обязан** быть sequential reviews-then-rating без TaskGroup, как зафиксировано в таблице классификации и блоке «Важная тонкость TaskGroup». DoD M-final code review checklist это явно проверяет. | | `AsyncAuthProvider.invalidate_token` сделан корутиной с `async with self._refresh_lock` → ложная защита, рост latency 401-handling, расхождение с sync | Контракт явно `def invalidate_token(self) -> None`, без await; тест `test_invalidate_token_is_sync_and_idempotent` фиксирует синхронность и идемпотентность. | +| `AsyncTransport.request()` забывает вызвать `await self._rate_limiter.acquire()` перед httpx-вызовом → state обновляется (через `observe_response`), но реальная сериализация не работает, параллельные корутины уходят пачкой | Шаг 3 контракта `AsyncTransport.request()` явно зеркалит sync `Transport.request()` строку 148: `await self._rate_limiter.acquire()` перед каждым `await self._client.request(...)`. Закреплено тестом `test_request_acquires_rate_limiter_before_httpx_call` (5 параллельных корутин на один transport — `RateLimitState._tokens` обновляется по одному до httpx-вызова). Парный тест `test_request_calls_observe_response_after_success` фиксирует пост-condition. | +| Binary-ветка `AsyncOperationExecutor` отличается от sync (другой helper, другая форма `BinaryResponse`) → расхождение для `OrderLabel.download()` и аналогов | Module-level `_request_binary_async(transport, *, spec, path, ...)` зеркало sync `_request_binary` (`avito/core/operations.py:254-278`), оба в одном файле, оба принимают свой `*OperationTransport` Protocol. Тест `test_binary_branch_uses_request_binary_async_helper` фиксирует совпадение полей `BinaryResponse`. M12 domain-тест `OrderLabel.download()` через `AsyncSwaggerFakeTransport` — обязательный финальный гейт. | +| `AsyncRateLimiter` location выбирается в PR review → bikeshedding, риск размытия async-инфраструктуры внутрь `async_transport.py` | Зафиксировано: **`avito/core/_async_rate_limit.py`**, симметрично sync `avito/core/rate_limit.py`. Любое отклонение требует явного обоснования в PR description. | +| Список deprecated методов в `cpa`/`ads` устаревает → async-aware wrapper в `deprecation.py` пропускает кейс, M6/M11 ловят парadox в середине разработки | Pre-flight grep `@deprecated_method` в `avito/cpa/` и `avito/ads/` фиксирует точное число (на момент написания плана: 3 + 4 = 7) и места (`cpa/domain.py:491,541,585`, `ads/domain.py:1416,1457,1523,1558`). Любое расхождение между pre-flight grep и текущим состоянием — обновление таблицы sequencing до старта M1. | +| M-final verification скрипт хардкодит ~50 `Async` имён → любое добавление/переименование класса требует ручной правки скрипта | M-final скрипт получает список из `scripts.lint_async_parity.iter_async_classes()` — единственный источник истины. Linter обязан экспортировать эту функцию как публичный API модуля. | +| `AsyncFakeTransport.as_client(user_id=N)` без `authenticated=True` ведёт себя непонятно для domain-тестов → тестовый сетап нарушает sync parity | Контракт `as_client(user_id=N, authenticated=False)` явно описан: `_resolve_user_id` берёт `settings.user_id` без сетевого запроса, `auth_provider=None` пропускает `Authorization` header. Симметрично sync `FakeTransport.as_client(user_id=N)`. Закреплено тестом `test_as_client_user_id_skips_self_lookup`. | From b82f97dbc7255063acbb273ee49e81953cf0f971 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 13:45:06 +0300 Subject: [PATCH 10/26] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D1=8E=20=D0=BD=D0=B0=D0=B4=20todo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- todo.md | 2007 +++++++++++++++++++++++++++---------------------------- 1 file changed, 1003 insertions(+), 1004 deletions(-) diff --git a/todo.md b/todo.md index 389e3db..703a594 100644 --- a/todo.md +++ b/todo.md @@ -1,40 +1,40 @@ -# Двухрежимный SDK (sync + async) +# Dual-mode SDK (sync + async) -## Контекст +## Context -SDK сейчас полностью синхронный: `AvitoClient` → `Transport` (`httpx.Client` + `time.sleep`) → -`AuthProvider` (`TokenClient` поверх sync-transport) → `DomainObject` подклассы -(11 API-пакетов + auth-bindings, 204 swagger-операции) → `PaginatedList[T]` -(наследник `list`). Цель — добавить вторую, -асинхронную, поверхность по образцу `httpx.Client`/`httpx.AsyncClient`, без слома sync-API, -с переиспользованием `OperationSpec`, моделей, request/query DTO, swagger-инвариантов и -ошибок. +The SDK is currently fully synchronous: `AvitoClient` → `Transport` (`httpx.Client` + `time.sleep`) → +`AuthProvider` (`TokenClient` on top of sync-transport) → `DomainObject` subclasses +(11 API packages + auth-bindings, 204 swagger operations) → `PaginatedList[T]` +(subclass of `list`). The goal is to add a second, +asynchronous, surface modeled after `httpx.Client`/`httpx.AsyncClient`, without breaking the sync API, +reusing `OperationSpec`, models, request/query DTOs, swagger invariants, and +errors. -## Принятые решения +## Decisions made -| Вопрос | Решение | +| Question | Decision | |---|---| -| Стиль | Параллельные классы вручную: рядом с каждым sync-слоем кладём `Async*` класс. Codegen не используем. | -| Размещение | `avito//async_domain.py` рядом с `domain.py`. | -| Swagger-binding | `@swagger_operation(..., variant="sync"\|"async")`. Уникальный ключ линтера — `(operation_key, variant)`. | -| Нормативные документы | M1 обновляет `STYLEGUIDE.md`, потому что сейчас он описывает SDK как sync-only и разрешает только `domain.py`. Без этого M1 конфликтует с главным style gate. | -| Sequencing | M1 — фундамент с тестами и async auth-bindings; M2-PoC — proof-of-concept шаблона на `tariffs` (валидация фундамента, может вернуть feedback); M3…M12 — закрытие каждого домена отдельным PR на 100%; M-final — convenience-методы и релиз. До появления первого доменного `Async` класса strict-coverage по `variant="async"` для API-доменов пуст и не падает; auth gated отдельно по `AsyncTokenClient` / `AsyncAlternateTokenClient`. | -| Pagination | `AsyncPaginatedList[ItemT]` — отдельный класс (не наследник `list`), без list-API parity (только `__aiter__` / `materialize` / `loaded_count` / `is_materialized` / `known_total` / `source_total`). | +| Style | Parallel classes by hand: next to each sync layer we place an `Async*` class. We do not use codegen. | +| Placement | `avito//async_domain.py` next to `domain.py`. | +| Swagger binding | `@swagger_operation(..., variant="sync"\|"async")`. The linter's unique key is `(operation_key, variant)`. | +| Normative documents | M1 updates `STYLEGUIDE.md`, because right now it describes the SDK as sync-only and only allows `domain.py`. Without this, M1 conflicts with the main style gate. | +| Sequencing | M1 — foundation with tests and async auth-bindings; M2-PoC — proof-of-concept of the template on `tariffs` (foundation validation, may return feedback); M3…M12 — closing each domain in a separate PR to 100%; M-final — convenience methods and release. Until the first domain `Async` class appears, strict-coverage by `variant="async"` for API domains is empty and does not fail; auth is gated separately by `AsyncTokenClient` / `AsyncAlternateTokenClient`. | +| Pagination | `AsyncPaginatedList[ItemT]` — a separate class (not a subclass of `list`), without list-API parity (only `__aiter__` / `materialize` / `loaded_count` / `is_materialized` / `known_total` / `source_total`). | -## Архитектура: что общее, что дублируем +## Architecture: what is shared, what is duplicated ``` - ┌────────── shared (без изменений по семантике) ─────────────┐ + ┌────────── shared (semantics unchanged) ────────────────────┐ │ │ │ OperationSpec, models, request/query DTO, ApiTimeouts, │ │ RequestContext, JsonPage, exceptions, RetryPolicy, │ - │ RateLimiter (логика "ждать сколько"), retries.RetryDecision│ + │ RateLimiter ("how long to wait" logic), retries.RetryDecision│ │ │ └─────────────────────┬──────────────────────────────────────┘ - │ используется обоими + │ used by both ┌───────────────┴───────────────┐ ▼ ▼ - ┌──────── SYNC (как есть) ───┐ ┌──────── ASYNC (новое) ─────┐ + ┌──────── SYNC (as is) ──────┐ ┌──────── ASYNC (new) ───────┐ │ Transport │ │ AsyncTransport │ │ ↓ httpx.Client │ │ ↓ httpx.AsyncClient │ │ ↓ time.sleep │ │ ↓ asyncio.sleep │ @@ -52,150 +52,151 @@ SDK сейчас полностью синхронный: `AvitoClient` → `Tra Swagger binding: variant="sync" variant="async" ↓ ↓ swagger_discovery + linter - (per-variant ключи) + (per-variant keys) ``` -Чтобы не разойтись retry-логике и маппингу ошибок, выносим в `avito/core/_transport_shared.py` -IO-agnostic вычисления (без httpx-вызова и sleep): `_decide_transport_retry`, +To keep retry logic and error mapping from drifting, we extract IO-agnostic +computations into `avito/core/_transport_shared.py` (no httpx call and no sleep): +`_decide_transport_retry`, `_decide_http_retry`, `_is_retryable_request`, `_get_retry_after_seconds`, `_map_http_error`, `_safe_payload`, `_extract_message`, `_extract_error_code`, `_extract_error_details`, `_extract_request_id`, `_normalize_path`, `_normalize_params`, `_normalize_files`, `_merge_headers`, `_build_user_agent`, `_extract_filename`, `build_httpx_timeout`, `_safe_endpoint`, `_log_http_exchange`, `_log_retry`, `_elapsed_ms`, -`RateLimitState` (pure token-bucket state с `compute_delay()`/`observe_response()`, -без `Lock` и без `sleep` — см. блок «Контракт shared-частей RateLimiter» ниже). -`Transport` и `AsyncTransport` остаются тонкими обёртками с тремя различиями: -формой sleep, формой client.request, и типом lock'а вокруг `RateLimitState` +`RateLimitState` (pure token-bucket state with `compute_delay()`/`observe_response()`, +without `Lock` and without `sleep` — see the "Contract for shared parts of RateLimiter" block below). +`Transport` and `AsyncTransport` remain thin wrappers with three differences: +the form of sleep, the form of client.request, and the type of lock around `RateLimitState` (`threading.Lock` vs `asyncio.Lock`). -**Контракт retry-петли в обоих режимах.** Catch-блок в `Transport.request()` / -`AsyncTransport.request()` ловит только явно retryable transport exceptions. -Для M1 это зеркало текущего sync-поведения: `httpx.TimeoutException` и -`httpx.NetworkError`. Расширять catch до всего `httpx.RequestError` нельзя -незаметно: это изменение sync-семантики и возможно только отдельным deliberate -behavior PR с тестами. `BaseException` (включая `asyncio.CancelledError`, -`KeyboardInterrupt`, `SystemExit`) **никогда не уходит в retry** — пробрасывается -наружу немодифицированным. Это критично для async: иначе SDK будет ловить отмену -корутины и пытаться её ретраить, нарушая cancellation-семантику. Закрепляется тестом -`tests/core/test_async_transport.py::test_cancelled_error_is_not_retried` и sync -baseline-diff в M1. - -**Важное уточнение по `_merge_headers`.** Текущая реализация -(`avito/core/transport.py:410-428`) внутри себя делает синхронный вызов -`self._auth_provider.get_access_token()` — то есть couples token retrieval с merge. -Чтобы helper стал IO-agnostic, рефакторим его контракт: shared `_merge_headers` -принимает уже резолвнутый `bearer_token: str | None`, а резолв (включая `await` в -async-варианте) выполняют сами `Transport`/`AsyncTransport` отдельно. Это первый шаг -Phase 1 (без поведенческих изменений sync), и он blocking для всего остального M1. - -Аналогично: `avito/auth/_cache.py` содержит in-memory state (поля `_access_token`, -`_refresh_token`, `_autoteka_access_token`) и чистые helpers (`_is_token_fresh`). -Module-level функция `_map_token_response` (`avito/auth/provider.py:35`) переезжает -в `_cache.py` без изменения сигнатуры. `AuthProvider` и `AsyncAuthProvider` -делегируют кешу, сами добавляют только sync/async lock + IO. - -### Порядок зависимостей в M1 +**Retry-loop contract in both modes.** The catch block in `Transport.request()` / +`AsyncTransport.request()` catches only explicitly retryable transport exceptions. +For M1 this mirrors the current sync behavior: `httpx.TimeoutException` and +`httpx.NetworkError`. Expanding catch to all of `httpx.RequestError` cannot be done +silently: it changes sync semantics and is only possible as a separate deliberate +behavior PR with tests. `BaseException` (including `asyncio.CancelledError`, +`KeyboardInterrupt`, `SystemExit`) **never goes into retry** — it is propagated +outwards unmodified. This is critical for async: otherwise the SDK would catch +coroutine cancellation and try to retry it, breaking cancellation semantics. Locked in +by the test `tests/core/test_async_transport.py::test_cancelled_error_is_not_retried` and +a sync baseline-diff in M1. + +**Important clarification about `_merge_headers`.** The current implementation +(`avito/core/transport.py:410-428`) internally makes a synchronous call +`self._auth_provider.get_access_token()` — i.e. it couples token retrieval with merge. +To make the helper IO-agnostic, we refactor its contract: the shared `_merge_headers` +takes an already-resolved `bearer_token: str | None`, while resolution (including `await` in +the async variant) is performed by `Transport`/`AsyncTransport` themselves separately. This is the first step +of Phase 1 (without behavioral changes to sync), and it is blocking for everything else in M1. + +Similarly: `avito/auth/_cache.py` contains in-memory state (fields `_access_token`, +`_refresh_token`, `_autoteka_access_token`) and pure helpers (`_is_token_fresh`). +The module-level function `_map_token_response` (`avito/auth/provider.py:35`) moves +to `_cache.py` without changing its signature. `AuthProvider` and `AsyncAuthProvider` +delegate to the cache and only add the sync/async lock + IO themselves. + +### Dependency order in M1 ``` - Phase 0 pre-flight (см. раздел "Pre-flight для PR M1") + Phase 0 pre-flight (see "Pre-flight for PR M1" section) ↓ - Phase 1a рефактор Transport._merge_headers → принимает резолвнутый bearer_token - (sync без поведенческих изменений; baseline тестов pass/fail идентичен) + Phase 1a refactor Transport._merge_headers → accepts a resolved bearer_token + (sync without behavioral changes; baseline pass/fail of tests is identical) ↓ - Phase 1b _transport_shared.py ◀── остальной IO-agnostic экстракт из Transport + Phase 1b _transport_shared.py ◀── the rest of the IO-agnostic extract from Transport _cache.py ◀── TokenCache + map_token_response, AuthProvider - хранит TokenCache + property-shim'ы для + stores TokenCache + property-shims for _access_token/_refresh_token/_autoteka_access_token - (ради существующих тестов) + (for the sake of existing tests) ↓ Phase 2 AsyncTransport, AsyncOperationTransport, AsyncOperationExecutor - AsyncAuthProvider (с asyncio.Lock на refresh + отдельным autoteka lock) + AsyncAuthProvider (with asyncio.Lock on refresh + a separate autoteka lock) AsyncTokenClient, AsyncAlternateTokenClient AsyncPaginatedList, AsyncPaginator ↓ - Phase 3 variant="async" в swagger декораторе/discovery/linter - AsyncAvitoClient (без factory-методов; только lifecycle) + Phase 3 variant="async" in the swagger decorator/discovery/linter + AsyncAvitoClient (no factory methods; lifecycle only) avito/testing/async_fake_transport.py + tests/async_fake_transport.py - (re-export с DeprecationWarning) + (re-export with DeprecationWarning) ↓ - Phase 4 тесты + docs (включая baseline-diff prove sync без изменений) + Phase 4 tests + docs (including baseline-diff proving sync is unchanged) ``` -## Ключевые файлы и точки соединения +## Key files and join points -### Существующие, изменяются в M1 +### Existing, modified in M1 -| Файл | Что меняем | +| File | What we change | |---|---| -| `avito/core/transport.py` | Извлекаем IO-agnostic helpers в `_transport_shared.py` и переиспользуем. Поведение sync — без изменений. | -| `avito/core/operations.py` | + `AsyncOperationTransport` (Protocol, async зеркало `OperationTransport`), + `AsyncOperationExecutor` (async зеркало `OperationExecutor.execute`) с теми же ветками `json` / `empty` / `binary`, что и sync. Helpers `render_path`, `_serialize_query`, `_serialize_request`, `_merge_content_type`, `_extract_filename` уже module-level — переиспользуем без копий. | -| `avito/core/swagger.py` | + поле `variant: Literal["sync","async"] = "sync"` в `SwaggerOperationBinding`. + параметр `variant` в `swagger_operation(...)`. Ошибка `ConfigurationError` при двойном декоре одной функции — без изменений. | -| `avito/core/swagger_discovery.py` | `_iter_domain_modules` дополнительно ищет `.async_domain` (рядом с `.domain`). `DiscoveredSwaggerBinding` получает `variant`. `canonical_map` остаётся sync-only compatibility API для существующих sync contract tests; новый `canonical_map_by_variant` / `binding_for(operation_key, variant)` использует ключ `(operation_key, variant)`. | -| `avito/core/swagger_linter.py` | `_validate_duplicate_bindings` группирует по `(operation_key, variant)`. `_validate_complete_bindings` запускается per-variant; для `variant="async"` ожидаемое множество ограничено доменами, у которых уже найден `Async*` класс (class-gated coverage). `_validate_no_unbound_operation_specs` остаётся по `OperationSpec` (sync OperationSpec реюзается обоими режимами — счётчик использований единый). | -| `avito/core/swagger_report.py` | JSON report становится variant-aware: summary хранит `sync` и `async` coverage отдельно, `operations[].bindings` содержит mapping по variant. Старые поля `bound`/`unbound` остаются sync-only compatibility до отдельного report API bump. | -| `avito/auth/provider.py` | Извлекаем shared cache state в `_cache.py`. Сам `AuthProvider` остаётся sync. Сохраняем `_access_token`/`_refresh_token`/`_autoteka_access_token` как `@property` shim'ы поверх `TokenCache` (с сеттерами), потому что `tests/core/test_authentication.py:122-127` мутирует поле напрямую через `replace()`. | -| `avito/core/deprecation.py` | `deprecated_method(...)` становится async-aware: если исходный метод coroutine function, wrapper тоже `async def` и делает `return await method(...)`, сохраняя `__sdk_deprecation__`. Это нужно для deprecated async-двойников в `cpa` и `ads`. | -| `avito/core/transport.py` (отдельно) | Phase 1a: `_merge_headers` рефакторится первым — принимает уже резолвнутый bearer-token, резолв вызывается отдельной строкой выше. Все остальные shared helpers — Phase 1b. | -| `avito/__init__.py` | + экспорт `AsyncAvitoClient`, `AsyncPaginatedList`. `AsyncPaginator` не выносим на root level, потому что sync-root экспортирует `PaginatedList`, но не `Paginator`; `AsyncPaginator` остаётся доступен из `avito.core`. | -| `avito/core/__init__.py` | + экспорт `AsyncTransport`, `AsyncOperationExecutor`, `AsyncOperationTransport`, `AsyncPaginatedList`, `AsyncPaginator`. | -| `avito/auth/__init__.py` | + экспорт `AsyncAuthProvider`, `AsyncTokenClient`, `AsyncAlternateTokenClient`, если эти классы объявлены публичными для consumer-side тестов и type-hint'ов. | -| `avito/testing/__init__.py` | + экспорт `AsyncFakeTransport`, `AsyncSwaggerFakeTransport` и общих helpers, чтобы async test utilities были таким же публичным контрактом, как sync `FakeTransport`. | -| `avito//__init__.py` | На каждом M2/M3…M12 добавляется export соответствующих `Async` классов; без этого `_gen_reference.py`, mkdocstrings и IDE-discovery не увидят async-поверхность. | -| `docs/site/assets/_gen_reference.py` | + расширение `public_domain_packages()` / `public_domain_classes()` / `public_domain_methods()` для подхвата `async_domain.py` и `Async`-классов рядом с sync-аналогами. Builder не должен зависеть только от `avito..__all__`: он обязан импортировать `avito..domain` и `avito..async_domain` напрямую, затем сохранять порядок sync-класс → async-класс. Важно: текущий `write_domain_pages()` пишет только `::: avito.` и не использует helper-функции классов/методов; M1 обязан перевести генерацию domain pages на явные class-директивы (`::: avito..ClassName`) в порядке sync-класс → async-класс. `ensure_debug_info_exists()` расширяется на `AsyncAvitoClient.debug_info()`. Без этого `make docs-strict` после M2-PoC не докажет полноту reference. | -| `avito/core/domain.py` | + `AsyncDomainObject` с async `_execute` и async `_resolve_user_id`. Sync `DomainObject` — без изменений. | -| `pyproject.toml` | + `pytest-asyncio = "^0.24"` в dev-deps. + `[tool.pytest.ini_options] asyncio_mode = "strict"` и `asyncio_default_fixture_loop_scope = "function"`. Без явного `asyncio_default_fixture_loop_scope` `pytest-asyncio` 0.23+ выдаёт `PytestDeprecationWarning` на каждом тесте — на момент M1 в `pyproject.toml` нет `filterwarnings = error` (проверено grep'ом), поэтому это не сломает pytest сразу, но будет накапливаться шум в выводе и заблокирует включение `filterwarnings = error` в будущем. Закрепляется в M1 PR превентивно. | -| `Makefile` | + цель `async-parity-lint`, включённая в `quality`; `make check` после M1 должен оставаться зелёным. | -| `scripts/lint_architecture.py` | `LEGACY_FILENAMES` не трогаем, но public-method checks применяются к `domain.py` и `async_domain.py`; AST-парсер должен учитывать `ast.AsyncFunctionDef` наравне с `ast.FunctionDef`. | -| `scripts/lint_docstrings.py` | Проверяет `avito/*/domain.py` и `avito/*/async_domain.py`, чтобы async public methods не получили generic/reference-плохие docstring-и. | -| `scripts/lint_async_parity.py` | Новый static linter, не pytest: проверяет `Async ↔ X`, сигнатуры, return annotations (`PaginatedList[T] ↔ AsyncPaginatedList[T]`), `async def`, binding equality и отсутствие лишних/пропущенных public methods. | -| `scripts/lint_swagger_bindings.py` | Без изменений в CLI (логика вынесена в `swagger_linter.py`). | -| `tests/contracts/test_swagger_contracts.py` | Фильтруется на `variant="sync"` и продолжает проверять sync `SwaggerFakeTransport` без изменения behavioral coverage. | -| `STYLEGUIDE.md` | M1 нормативно разрешает двухрежимный SDK: `async_domain.py`, `AsyncDomainObject`, `AsyncTransport`/`httpx.AsyncClient`, async lifecycle и variant-aware Swagger bindings. Sync-only рекомендация заменяется на описание двух поверхностей. | -| `docs/site/explanations/swagger-binding-subsystem.md` | Раздел про `variant` и class-gated coverage. | -| `docs/site/explanations/domain-architecture-v2.md` | Параграф про `async_domain.py` как разрешённый файл, парный к `domain.py`. | -| `README.md`, `mkdocs.yml`, `docs/site/index.md`, `docs/site/reference/client.md`, `docs/site/reference/pagination.md`, `docs/site/reference/testing.md`, `docs/site/how-to/index.md` | В M-final обновляются с «синхронный SDK» на двухрежимный SDK и получают ссылки на async lifecycle/testing/pagination. | - -### Новые файлы (M1) +| `avito/core/transport.py` | Extract IO-agnostic helpers into `_transport_shared.py` and reuse them. Sync behavior is unchanged. | +| `avito/core/operations.py` | + `AsyncOperationTransport` (Protocol, async mirror of `OperationTransport`), + `AsyncOperationExecutor` (async mirror of `OperationExecutor.execute`) with the same `json` / `empty` / `binary` branches as sync. Helpers `render_path`, `_serialize_query`, `_serialize_request`, `_merge_content_type`, `_extract_filename` are already module-level — reused without copies. | +| `avito/core/swagger.py` | + a `variant: Literal["sync","async"] = "sync"` field on `SwaggerOperationBinding`. + a `variant` parameter on `swagger_operation(...)`. The `ConfigurationError` on double-decorating one function — unchanged. | +| `avito/core/swagger_discovery.py` | `_iter_domain_modules` additionally looks for `.async_domain` (next to `.domain`). `DiscoveredSwaggerBinding` gets `variant`. `canonical_map` remains a sync-only compatibility API for existing sync contract tests; the new `canonical_map_by_variant` / `binding_for(operation_key, variant)` uses the key `(operation_key, variant)`. | +| `avito/core/swagger_linter.py` | `_validate_duplicate_bindings` groups by `(operation_key, variant)`. `_validate_complete_bindings` runs per-variant; for `variant="async"` the expected set is limited to domains where an `Async*` class has already been found (class-gated coverage). `_validate_no_unbound_operation_specs` stays per `OperationSpec` (the sync OperationSpec is reused by both modes — the usage counter is unified). | +| `avito/core/swagger_report.py` | The JSON report becomes variant-aware: the summary stores `sync` and `async` coverage separately, `operations[].bindings` contains a mapping by variant. The old `bound`/`unbound` fields remain sync-only compatibility until a separate report API bump. | +| `avito/auth/provider.py` | Extract shared cache state into `_cache.py`. `AuthProvider` itself stays sync. We keep `_access_token`/`_refresh_token`/`_autoteka_access_token` as `@property` shims over `TokenCache` (with setters), because `tests/core/test_authentication.py:122-127` mutates the field directly via `replace()`. | +| `avito/core/deprecation.py` | `deprecated_method(...)` becomes async-aware: if the original method is a coroutine function, the wrapper is also `async def` and does `return await method(...)`, preserving `__sdk_deprecation__`. This is needed for deprecated async doubles in `cpa` and `ads`. | +| `avito/core/transport.py` (separately) | Phase 1a: `_merge_headers` is refactored first — it takes an already-resolved bearer token, and resolution is called as a separate line above. All other shared helpers are Phase 1b. | +| `avito/__init__.py` | + export `AsyncAvitoClient`, `AsyncPaginatedList`. `AsyncPaginator` is not raised to root level, because the sync-root exports `PaginatedList` but not `Paginator`; `AsyncPaginator` remains accessible from `avito.core`. | +| `avito/core/__init__.py` | + export `AsyncTransport`, `AsyncOperationExecutor`, `AsyncOperationTransport`, `AsyncPaginatedList`, `AsyncPaginator`. | +| `avito/auth/__init__.py` | + export `AsyncAuthProvider`, `AsyncTokenClient`, `AsyncAlternateTokenClient`, if these classes are declared public for consumer-side tests and type hints. | +| `avito/testing/__init__.py` | + export `AsyncFakeTransport`, `AsyncSwaggerFakeTransport` and shared helpers, so that async test utilities are the same public contract as sync `FakeTransport`. | +| `avito//__init__.py` | At every M2/M3…M12 we add the export of the corresponding `Async` classes; without this, `_gen_reference.py`, mkdocstrings and IDE-discovery will not see the async surface. | +| `docs/site/assets/_gen_reference.py` | + extension of `public_domain_packages()` / `public_domain_classes()` / `public_domain_methods()` to pick up `async_domain.py` and `Async` classes alongside their sync counterparts. The builder must not depend solely on `avito..__all__`: it must import `avito..domain` and `avito..async_domain` directly, then preserve the order sync-class → async-class. Important: the current `write_domain_pages()` writes only `::: avito.` and does not use the class/method helper functions; M1 must move domain page generation to explicit class directives (`::: avito..ClassName`) in the order sync-class → async-class. `ensure_debug_info_exists()` is extended to `AsyncAvitoClient.debug_info()`. Without this, `make docs-strict` after M2-PoC will not prove reference completeness. | +| `avito/core/domain.py` | + `AsyncDomainObject` with async `_execute` and async `_resolve_user_id`. Sync `DomainObject` — unchanged. | +| `pyproject.toml` | + `pytest-asyncio = "^0.24"` in dev-deps. + `[tool.pytest.ini_options] asyncio_mode = "strict"` and `asyncio_default_fixture_loop_scope = "function"`. Without an explicit `asyncio_default_fixture_loop_scope`, `pytest-asyncio` 0.23+ emits a `PytestDeprecationWarning` on every test — at the time of M1 there is no `filterwarnings = error` in `pyproject.toml` (verified by grep), so this won't break pytest immediately, but it will accumulate noise in output and block enabling `filterwarnings = error` in the future. Locked in M1 PR preventively. | +| `Makefile` | + an `async-parity-lint` target, included in `quality`; `make check` after M1 must remain green. | +| `scripts/lint_architecture.py` | We do not touch `LEGACY_FILENAMES`, but public-method checks apply to `domain.py` and `async_domain.py`; the AST parser must consider `ast.AsyncFunctionDef` on equal footing with `ast.FunctionDef`. | +| `scripts/lint_docstrings.py` | Checks `avito/*/domain.py` and `avito/*/async_domain.py`, so async public methods do not get generic / reference-poor docstrings. | +| `scripts/lint_async_parity.py` | A new static linter, not pytest: checks `Async ↔ X`, signatures, return annotations (`PaginatedList[T] ↔ AsyncPaginatedList[T]`), `async def`, binding equality, and the absence of extra/missing public methods. | +| `scripts/lint_swagger_bindings.py` | No CLI changes (the logic is moved into `swagger_linter.py`). | +| `tests/contracts/test_swagger_contracts.py` | Filtered to `variant="sync"` and continues to check sync `SwaggerFakeTransport` without changing behavioral coverage. | +| `STYLEGUIDE.md` | M1 normatively allows a dual-mode SDK: `async_domain.py`, `AsyncDomainObject`, `AsyncTransport`/`httpx.AsyncClient`, async lifecycle, and variant-aware Swagger bindings. The sync-only recommendation is replaced with a description of two surfaces. | +| `docs/site/explanations/swagger-binding-subsystem.md` | A section on `variant` and class-gated coverage. | +| `docs/site/explanations/domain-architecture-v2.md` | A paragraph on `async_domain.py` as an allowed file paired with `domain.py`. | +| `README.md`, `mkdocs.yml`, `docs/site/index.md`, `docs/site/reference/client.md`, `docs/site/reference/pagination.md`, `docs/site/reference/testing.md`, `docs/site/how-to/index.md` | In M-final updated from "synchronous SDK" to dual-mode SDK and given links to async lifecycle/testing/pagination. | + +### New files (M1) ``` avito/core/_transport_shared.py # IO-agnostic helpers, retry/error mapping/headers - # (_merge_headers принимает bearer_token: str | None; - # _request_binary_async зеркало sync _request_binary) + # (_merge_headers takes bearer_token: str | None; + # _request_binary_async mirrors sync _request_binary) avito/core/_async_rate_limit.py # AsyncRateLimiter (asyncio.Lock + asyncio.sleep - # поверх shared RateLimitState) + # over shared RateLimitState) avito/core/async_transport.py # AsyncTransport (httpx.AsyncClient) avito/core/async_pagination.py # AsyncPaginatedList, AsyncPaginator, AsyncPageFetcher avito/auth/_cache.py # TokenCache + map_token_response -avito/auth/async_provider.py # AsyncAuthProvider (отдельные asyncio.Lock для - # основного и autoteka токенов) +avito/auth/async_provider.py # AsyncAuthProvider (separate asyncio.Lock for + # the main and autoteka tokens) avito/auth/async_token_client.py # AsyncTokenClient, AsyncAlternateTokenClient - # (со @swagger_operation(..., variant="async")) + # (with @swagger_operation(..., variant="async")) avito/async_client.py # AsyncAvitoClient (lifecycle + auth/debug_info/closed-state; - # factory-методы доменов пустые в M1) + # domain factory methods empty in M1) avito/testing/async_fake_transport.py # AsyncFakeTransport (httpx.MockTransport+AsyncClient) avito/testing/async_swagger_fake_transport.py # AsyncSwaggerFakeTransport: async contract runner - # для discovered bindings с variant="async" -tests/async_fake_transport.py # тонкий re-export с DeprecationWarning (как у sync; - # шаблон скопирован 1:1 с tests/fake_transport.py) + # for discovered bindings with variant="async" +tests/async_fake_transport.py # thin re-export with DeprecationWarning (as in sync; + # template copied 1:1 from tests/fake_transport.py) tests/core/test_async_transport.py tests/core/test_async_pagination.py tests/core/test_async_executor.py tests/core/test_async_client_lifecycle.py tests/auth/test_async_provider.py tests/contracts/test_async_swagger_contracts.py - # Swagger-spec compliance для async bindings -scripts/lint_async_parity.py # static linter, не pytest + # Swagger-spec compliance for async bindings +scripts/lint_async_parity.py # static linter, not pytest ``` -### Новые файлы (M2-PoC + M3…M12, на каждый домен) +### New files (M2-PoC + M3…M12, per domain) ``` avito//async_domain.py tests/domains//test__async.py ``` -## Контракты новых классов +## Contracts of new classes ### `avito/core/async_transport.py` @@ -214,7 +215,7 @@ class AsyncTransport: data=None, files=None, headers=None, content=None, idempotency_key=None) -> httpx.Response: ... async def request_json(...) -> object: ... - async def download_binary(...) -> BinaryResponse: ... # full-buffer, см. ниже + async def download_binary(...) -> BinaryResponse: ... # full-buffer, see below async def aclose(self) -> None: ... async def __aenter__(self) -> AsyncTransport: ... async def __aexit__(self, *exc) -> None: ... @@ -223,121 +224,121 @@ class AsyncTransport: def debug_info(self) -> TransportDebugInfo: ... ``` -Реализует `AsyncOperationTransport` (Protocol, async-зеркало `OperationTransport` из +Implements `AsyncOperationTransport` (Protocol, async mirror of `OperationTransport` from `avito/core/operations.py`). -`AsyncTransport.request()` внутри (точное зеркало sync `Transport.request()`, +`AsyncTransport.request()` internally (an exact mirror of sync `Transport.request()`, `avito/core/transport.py:146-185`): -1. вызывает `bearer_token = await self._auth_provider.get_access_token()` (если требуется); -2. передаёт `bearer_token` в shared `_merge_headers(...)` — строго pure-функция; -3. **на каждой попытке retry-loop** (включая первую): `delay = await - self._rate_limiter.acquire()` **перед** `await self._client.request(...)` — зеркало - sync `Transport.request()` строка 148. Если `delay > 0` — пишется тот же info-лог - `transport rate limit delay` с `reason="client_rate_limit"`, что и sync; -4. **после успешного ответа**: `self._rate_limiter.observe_response(headers= - response.headers)` — зеркало sync строка 183. `observe_response` остаётся sync- - методом `AsyncRateLimiter` (внутри только мутация state под `asyncio.Lock`, - без sleep'а, без IO; await не нужен); -5. петля retry-decisions делегирует в shared `_decide_*_retry`; -6. при 401 — `self._auth_provider.invalidate_token()` (sync-операция clear cache), - повторный `await self._auth_provider.get_access_token()`, один retry; -7. ловит только `httpx.TimeoutException` и `httpx.NetworkError`, как sync - `Transport` на момент M1. `asyncio.CancelledError` и любой `BaseException` - пробрасываются наружу без retry — см. контракт shared retry-петли выше. - -**Запрещено** вызывать `self._client.request(...)` без предварительного `await -self._rate_limiter.acquire()`: иначе rate-limit headers (Retry-After, X-RateLimit-*) -обновят state, но реальная сериализация запросов через лимитер не сработает, и -параллельные корутины уйдут пачкой. Закрепляется тестом +1. calls `bearer_token = await self._auth_provider.get_access_token()` (if required); +2. passes `bearer_token` to the shared `_merge_headers(...)` — strictly a pure function; +3. **on every retry-loop attempt** (including the first): `delay = await + self._rate_limiter.acquire()` **before** `await self._client.request(...)` — mirrors + sync `Transport.request()` line 148. If `delay > 0` — the same info log + `transport rate limit delay` with `reason="client_rate_limit"` is written, as in sync; +4. **after a successful response**: `self._rate_limiter.observe_response(headers= + response.headers)` — mirrors sync line 183. `observe_response` remains a sync + method on `AsyncRateLimiter` (only state mutation under `asyncio.Lock` inside, + no sleep, no IO; await is not needed); +5. the loop of retry decisions delegates to the shared `_decide_*_retry`; +6. on 401 — `self._auth_provider.invalidate_token()` (sync clear-cache operation), + a repeated `await self._auth_provider.get_access_token()`, one retry; +7. catches only `httpx.TimeoutException` and `httpx.NetworkError`, like sync + `Transport` at the time of M1. `asyncio.CancelledError` and any `BaseException` + propagate outwards without retry — see the shared retry-loop contract above. + +**Forbidden** to call `self._client.request(...)` without first awaiting `await +self._rate_limiter.acquire()`: otherwise rate-limit headers (Retry-After, X-RateLimit-*) +will update the state, but the actual serialization of requests through the limiter will not work, and +parallel coroutines will go out in a batch. Locked in by the test `tests/core/test_async_transport.py::test_request_acquires_rate_limiter_before_httpx_call`, -который через `AsyncFakeTransport` запускает 5 параллельных корутин на один transport -и проверяет, что `RateLimitState._tokens` обновляется ровно по одному до каждого -httpx-вызова (а не пачкой), а второй тест -`test_request_calls_observe_response_after_success` проверяет, что -`observe_response` дёрнут с теми же headers, что вернул mock. - -**Rate-limiter в async.** Один rate-limiter принадлежит одному `AsyncTransport` -(а не каждой корутине-вызову). Все корутины, делящие транспорт, должны -сериализоваться через `asyncio.Lock` внутри лимитера — иначе N параллельных запросов -независимо посчитают «надо ждать X секунд» и улетят пачкой после ожидания, нарушив -лимит. - -**Контракт shared-частей RateLimiter.** Текущий `avito/core/rate_limit.py` содержит -*и* состояние token-bucket'а (`_tokens`, `_blocked_until`, `_updated_at`), *и* -`while True: self._sleep(delay)` внутри `acquire()` — sleep запечён в метод. Sync -`RateLimiter` нельзя «обернуть» в async без переделки, потому что внутри стоит -`threading.Lock`, который удерживать через `await` запрещено. Поэтому декомпозиция -строгая, в три части: - -1. **`RateLimitState`** (pure dataclass в `avito/core/_transport_shared.py`): - `_tokens: float`, `_updated_at: float`, `_blocked_until: float`, политика - (`rate`, `capacity`, `enabled`). Методы: - - `compute_delay(now: float) -> float` — pure-функция, **не** sleep'ает, - возвращает 0 если можно сразу, иначе нужную задержку. Резервирует токен, - если возвращает 0 (мутирует state). - - `observe_response(now: float, headers: Mapping[str, str]) -> None` — pure - обновление `_blocked_until` по rate-limit headers (без IO). - -2. **`RateLimiter`** (sync, остаётся в `avito/core/rate_limit.py`): хранит - `RateLimitState` + `threading.Lock` + `_sleep` + `_clock`. Чтобы не менять - sync-поведение, wrapper сохраняет текущий порядок: lock держится только на - вычислении/мутации state, sleep выполняется вне `threading.Lock`. Любое изменение - sync-concurrency semantics — отдельный сознательный PR, не часть M1. - -3. **`AsyncRateLimiter`** (новый, **в `avito/core/_async_rate_limit.py`** — - симметрично sync `avito/core/rate_limit.py`, чтобы grep `RateLimit` находил оба - модуля рядом и async-инфраструктура не размывалась внутрь `async_transport.py`). - Хранит - **отдельный `RateLimitState`** (не shared с sync — состояние не делится между - режимами; sync- и async-транспорты — независимые сущности с независимыми - bucket'ами) + `asyncio.Lock` + `_clock` + `_sleep: Callable[[float], - Awaitable[None]] = asyncio.sleep`. `async def acquire()` — это +which via `AsyncFakeTransport` runs 5 parallel coroutines on one transport +and verifies that `RateLimitState._tokens` is updated exactly one at a time before each +httpx call (and not in a batch), and the second test +`test_request_calls_observe_response_after_success` verifies that +`observe_response` was called with the same headers the mock returned. + +**Rate limiter in async.** One rate limiter belongs to one `AsyncTransport` +(not to each call coroutine). All coroutines sharing the transport must +serialize through `asyncio.Lock` inside the limiter — otherwise N parallel requests +will independently compute "must wait X seconds" and will go out in a batch after waiting, breaking +the limit. + +**Contract of shared parts of RateLimiter.** The current `avito/core/rate_limit.py` contains +*both* the token-bucket state (`_tokens`, `_blocked_until`, `_updated_at`), *and* +`while True: self._sleep(delay)` inside `acquire()` — sleep is baked into the method. The sync +`RateLimiter` cannot be "wrapped" in async without rework, because internally there is +a `threading.Lock` that is forbidden to hold across `await`. Therefore the decomposition +is strict, in three parts: + +1. **`RateLimitState`** (pure dataclass in `avito/core/_transport_shared.py`): + `_tokens: float`, `_updated_at: float`, `_blocked_until: float`, policy + (`rate`, `capacity`, `enabled`). Methods: + - `compute_delay(now: float) -> float` — a pure function that **does not** sleep, + returns 0 if it can go immediately, otherwise the required delay. Reserves a token + if it returns 0 (mutates state). + - `observe_response(now: float, headers: Mapping[str, str]) -> None` — a pure + update of `_blocked_until` from rate-limit headers (no IO). + +2. **`RateLimiter`** (sync, stays in `avito/core/rate_limit.py`): stores + `RateLimitState` + `threading.Lock` + `_sleep` + `_clock`. To avoid changing + sync behavior, the wrapper preserves the current order: the lock is held only on + computing/mutating state, and sleep is performed outside the `threading.Lock`. Any change + to sync-concurrency semantics — a separate deliberate PR, not part of M1. + +3. **`AsyncRateLimiter`** (new, **in `avito/core/_async_rate_limit.py`** — + symmetrically with sync `avito/core/rate_limit.py`, so that grep `RateLimit` finds both + modules side by side and the async infrastructure does not bleed into `async_transport.py`). + Stores + **a separate `RateLimitState`** (not shared with sync — state is not shared between + modes; sync and async transports are independent entities with independent + buckets) + `asyncio.Lock` + `_clock` + `_sleep: Callable[[float], + Awaitable[None]] = asyncio.sleep`. `async def acquire()` is `async with self._lock: while (delay := state.compute_delay(now())) > 0: await self._sleep(delay)`. -Async wrapper намеренно держит `asyncio.Lock` во время ожидания, чтобы несколько -корутин с одним transport-ом не просыпались одной пачкой после одинакового delay. -`asyncio.Lock` создаётся при создании `AsyncRateLimiter` внутри async lifecycle -(`AsyncAvitoClient.__aenter__`, `AsyncFakeTransport.as_client()` внутри тестового loop'а -или явное создание пользователем внутри loop'а) и биндится к event loop'у при первом -`await`. Запрещено переиспользовать один `AsyncRateLimiter` между event loop'ами. +The async wrapper deliberately holds `asyncio.Lock` during the wait, so that several +coroutines sharing one transport do not wake up in a single batch after the same delay. +`asyncio.Lock` is created when `AsyncRateLimiter` is created inside the async lifecycle +(`AsyncAvitoClient.__aenter__`, `AsyncFakeTransport.as_client()` inside the test loop, +or explicit creation by the user inside the loop) and is bound to the event loop on first +`await`. It is forbidden to reuse one `AsyncRateLimiter` across event loops. -**Закрепляется тестами**: `tests/core/test_rate_limit_state.py` (pure compute); +**Locked in by tests**: `tests/core/test_rate_limit_state.py` (pure compute); `tests/core/test_async_transport.py::test_async_rate_limiter_serializes_concurrent_acquires` -(пять параллельных корутин не уходят пачкой после ожидания, а сериализуются под +(five parallel coroutines do not go out in a batch after waiting, but serialize under `asyncio.Lock`). -**Connection pool и fan-out limits.** `AsyncTransport` создаёт `httpx.AsyncClient` -с **дефолтными** `httpx.Limits` (max_connections=100, max_keepalive_connections=20), -без переопределения. Это сознательное решение: явный тюнинг лимитов в M1 — отдельная -поведенческая ось, которая не должна вводиться вместе с async-фундаментом. При этом -**convenience-методы M-final ограничивают fan-out**: ни один агрегатор -(`account_health`, `listing_health`, `review_summary`, `promotion_summary`) не должен -порождать > 6 одновременно in-flight задач через `asyncio.TaskGroup` (текущий sync- -код имеет максимум 5–6 независимых веток в `account_health`). Если домен в будущем -требует параллельного fan-out > 6, это вводится отдельным PR с явной политикой -семафора (`asyncio.Semaphore`) — но не в 2.1.0. Закрепляется DoD M-final code review -checklist'ом и риск-таблицей. Если внешний `httpx.AsyncClient` передан пользователем, -его limits — ответственность пользователя; SDK их не переопределяет и документирует -этот факт в docstring `AsyncAvitoClient.__init__`. - -**Семантика `AsyncTransport.download_binary`.** В M1 — **full-buffer**, как sync: -внутри `await response.aread()` и возвращается `BinaryResponse` с полным `bytes`- -контентом. Streaming-вариант (`async for chunk in response.aiter_bytes()`) — -**out of scope для M1…M-final**: ни один публичный sync-метод не возвращает -chunked stream, `scripts/lint_async_parity.py` и async contract suite это бы поломали, -и пользователи Async API не получат -расхождения с sync. Если в будущем понадобится stream — это отдельный API -(`download_binary_stream` или итератор), вводимый отдельным минорным релизом -после 2.1.0 с симметричным sync-аналогом. Закрепляется тестом +**Connection pool and fan-out limits.** `AsyncTransport` creates `httpx.AsyncClient` +with **default** `httpx.Limits` (max_connections=100, max_keepalive_connections=20), +without overriding. This is a deliberate decision: explicit tuning of limits in M1 is a separate +behavioral axis that should not be introduced together with the async foundation. At the same time +**the convenience methods of M-final limit fan-out**: no aggregator +(`account_health`, `listing_health`, `review_summary`, `promotion_summary`) should +spawn > 6 simultaneously in-flight tasks via `asyncio.TaskGroup` (current sync +code has at most 5–6 independent branches in `account_health`). If a domain in the future +requires parallel fan-out > 6, this is introduced in a separate PR with an explicit +semaphore policy (`asyncio.Semaphore`) — but not in 2.1.0. Locked in by the M-final DoD code review +checklist and risk table. If an external `httpx.AsyncClient` is passed by the user, +its limits are the user's responsibility; the SDK does not override them and documents +this fact in the `AsyncAvitoClient.__init__` docstring. + +**Semantics of `AsyncTransport.download_binary`.** In M1 — **full-buffer**, like sync: +internally `await response.aread()` and a `BinaryResponse` is returned with the full `bytes` +content. The streaming variant (`async for chunk in response.aiter_bytes()`) is +**out of scope for M1…M-final**: no public sync method returns a chunked stream, +`scripts/lint_async_parity.py` and the async contract suite would break it, +and Async API users would not see a divergence +from sync. If a stream is needed in the future — that is a separate API +(`download_binary_stream` or an iterator), introduced in a separate minor release +after 2.1.0 with a symmetric sync analog. Locked in by the test `tests/core/test_async_transport.py::test_download_binary_full_buffer_matches_sync`. -### `avito/core/operations.py` (расширение) +### `avito/core/operations.py` (extension) ```python class AsyncOperationTransport(Protocol): - async def request(...) -> httpx.Response: ... # async def, не Awaitable[T] + async def request(...) -> httpx.Response: ... # async def, not Awaitable[T] async def request_json(...) -> object: ... class AsyncOperationExecutor: @@ -350,52 +351,52 @@ class AsyncOperationExecutor: ``` `render_path`, `_serialize_query`, `_serialize_request`, `_merge_content_type`, -`_extract_filename` — общие, переиспользуются обоими executor'ами без копирования. -`AsyncOperationExecutor.execute()` повторяет все три ветки sync-executor'а: +`_extract_filename` are common, reused by both executors without copying. +`AsyncOperationExecutor.execute()` repeats all three branches of the sync executor: -- `response_kind == "json"`: `payload = await transport.request_json(...)`, затем +- `response_kind == "json"`: `payload = await transport.request_json(...)`, then `response_model.from_payload(payload)`; -- `response_kind == "empty"`: `response = await transport.request(...)`, затем +- `response_kind == "empty"`: `response = await transport.request(...)`, then `EmptyResponse(status_code=response.status_code, headers=dict(response.headers))`; -- `response_kind == "binary"`: executor вызывает module-level helper +- `response_kind == "binary"`: the executor calls a module-level helper `_request_binary_async(transport, *, spec, path, context, params, headers, - idempotency_key)` — async-зеркало sync `_request_binary` (`avito/core/operations.py:254-278`). - Helper module-level и принимает `AsyncOperationTransport` Protocol (а не конкретный - `AsyncTransport`), как sync принимает `OperationTransport`. Внутри - `await transport.request(...)` с method/path из `OperationSpec`, затем строит + idempotency_key)` — async mirror of sync `_request_binary` (`avito/core/operations.py:254-278`). + The helper is module-level and accepts an `AsyncOperationTransport` Protocol (not a concrete + `AsyncTransport`), as sync accepts `OperationTransport`. Inside, + `await transport.request(...)` with method/path from `OperationSpec`, then it builds `BinaryResponse(content=response.content, content_type=..., filename=_extract_filename(...), status_code=..., headers=dict(response.headers))`. - Helper живёт в **`avito/core/operations.py`** рядом с sync `_request_binary` (а не - в `_transport_shared.py`), потому что sync-версия уже там и работает с - `OperationTransport` Protocol — это два симметричных близнеца на одном уровне - абстракции, и разносить их по разным модулям только плодит навигацию. - `_extract_filename` уже module-level в том же файле — переиспользуется без копий. - `download_binary()` остаётся низкоуровневым convenience-методом `AsyncTransport`, - но **не** входит в `AsyncOperationTransport` Protocol, иначе binary-ветка начнёт - отличаться от sync executor и потеряет method/path из `OperationSpec`. - -Binary-ветка закрепляется M1 unit-тестом на executor + The helper lives in **`avito/core/operations.py`** next to sync `_request_binary` (not + in `_transport_shared.py`), because the sync version is already there and works with + the `OperationTransport` Protocol — these are two symmetric twins on the same level + of abstraction, and splitting them across different modules only multiplies navigation. + `_extract_filename` is already module-level in the same file — reused without copies. + `download_binary()` remains a low-level convenience method of `AsyncTransport`, + but is **not** part of the `AsyncOperationTransport` Protocol, otherwise the binary branch will + diverge from sync executor and lose method/path from `OperationSpec`. + +The binary branch is locked in by an M1 unit test on the executor (`tests/core/test_async_executor.py::test_binary_branch_uses_request_binary_async_helper`, -проверяет, что `_request_binary_async` действительно вызван и `BinaryResponse` -собран из тех же полей, что sync) и M12 domain-тестом `OrderLabel.download()` через +verifies that `_request_binary_async` is actually called and `BinaryResponse` +is built from the same fields as sync) and an M12 domain test for `OrderLabel.download()` via `AsyncSwaggerFakeTransport`/`AsyncFakeTransport`. -**Retry-политика executor'а — точное зеркало sync.** `AsyncOperationExecutor.execute()` -выбирает retry в том же порядке, что sync `OperationExecutor`: `retry or spec.retry`, -с тем же defaulting, и пробрасывает её в `AsyncTransport.request()` идентичным аргументом. -Запрещено: (1) брать `retry` только из аргумента и игнорировать `spec.retry`, (2) брать -`spec.retry` всегда и игнорировать override. Закрепляется юнит-тестом +**Executor retry policy — exact mirror of sync.** `AsyncOperationExecutor.execute()` +chooses retry in the same order as sync `OperationExecutor`: `retry or spec.retry`, +with the same defaulting, and propagates it to `AsyncTransport.request()` with an identical argument. +Forbidden: (1) take `retry` only from the argument and ignore `spec.retry`, (2) take +`spec.retry` always and ignore the override. Locked in by the unit test `tests/core/test_async_executor.py::test_executor_retry_resolution_matches_sync`, -который параметризован тремя кейсами `(retry=None, spec.retry=A) → A`, -`(retry=B, spec.retry=A) → B`, `(retry=B, spec.retry=None) → B` и сверяет результат с -sync `OperationExecutor` на одном и том же `OperationSpec`. Без этого теста расхождение -retry-семантики между sync и async может пройти незамеченным. +parameterized with three cases `(retry=None, spec.retry=A) → A`, +`(retry=B, spec.retry=A) → B`, `(retry=B, spec.retry=None) → B` and comparing the result with +sync `OperationExecutor` on the same `OperationSpec`. Without this test, divergence in +retry semantics between sync and async could go unnoticed. -Замечание по типизации Protocol: для async-методов в `Protocol` используем `async def`, а -не `Awaitable[T]` в return-аннотации синхронной сигнатуры. Это даёт mypy strict корректный -runtime-protocol matching и избавляет от двойной оборачивания. +A note on Protocol typing: for async methods in `Protocol` we use `async def`, not +`Awaitable[T]` in the return annotation of a sync signature. This gives mypy strict correct +runtime-protocol matching and avoids double wrapping. -### `avito/core/domain.py` (расширение) +### `avito/core/domain.py` (extension) ```python @dataclass(slots=True, frozen=True) @@ -409,15 +410,15 @@ class AsyncDomainObject: async def _resolve_user_id(self, user_id: int | str | None = None) -> int: ... ``` -Async-двойник sync-`DomainObject._resolve_user_id`: тот же fallback-порядок, что и -текущий sync-код в `avito/core/domain.py`: сначала аргумент, затем `settings.user_id`, -затем internal raw request на `/core/v1/accounts/self` через transport. Это -осознанный exception для базового helper-а: `core` не импортирует -`avito.accounts.operations.GET_SELF`, чтобы не создавать зависимость core → domain. -Swagger-binding для `/core/v1/accounts/self` покрывается публичным -`Account.get_self()` / `AsyncAccount.get_self()`, а `_resolve_user_id` остаётся -internal helper без отдельного binding-а. Если в будущем sync `_resolve_user_id` -переводится на executor, async меняется в том же PR. +Async double of sync `DomainObject._resolve_user_id`: the same fallback order as the +current sync code in `avito/core/domain.py`: first the argument, then `settings.user_id`, +then an internal raw request to `/core/v1/accounts/self` via transport. This is +a deliberate exception for a base helper: `core` does not import +`avito.accounts.operations.GET_SELF`, to avoid creating a core → domain dependency. +The Swagger binding for `/core/v1/accounts/self` is covered by the public +`Account.get_self()` / `AsyncAccount.get_self()`, while `_resolve_user_id` remains an +internal helper without a separate binding. If sync `_resolve_user_id` is moved +to the executor in the future, async changes in the same PR. ### `avito/core/async_pagination.py` @@ -454,29 +455,29 @@ class AsyncPaginator[ItemT]: ) -> AsyncPaginatedList[ItemT]: ... ``` -`AsyncPaginatedList` **не** наследует `list[T]` — async-итерация и list-индексация -несовместимы. Документируем это явно в docstring и в `pagination` how-to. Семантика -страничного перехода идентична sync `PaginatedList._consume_page` (включая `next_cursor`, +`AsyncPaginatedList` does **not** inherit `list[T]` — async iteration and list indexing +are incompatible. We document this explicitly in the docstring and in the `pagination` how-to. The +page transition semantics are identical to sync `PaginatedList._consume_page` (including `next_cursor`, `page+per_page`, `has_next_page`). -**Concurrency contract.** `AsyncPaginatedList` не поддерживает concurrent iteration -одного instance из нескольких корутин. Но это не должно превращаться в silent data -corruption: класс хранит флаг активной итерации (`_active_iterator`) и fail-fast -бросает `RuntimeError("AsyncPaginatedList уже итерируется; используйте materialize() или создайте отдельный список.")`, -если второй `__aiter__` стартует до завершения первого. Если нужен fan-out — -вызывайте `await materialize()` один раз и итерируйтесь по полученному `list[T]`, -либо создавайте отдельный `AsyncPaginatedList` per consumer. Документируется -в docstring класса и в `docs/site/explanations/pagination-semantics.md` -(дополнение в M-final). Закрепляется поведением +**Concurrency contract.** `AsyncPaginatedList` does not support concurrent iteration +of one instance from multiple coroutines. But this should not turn into silent data +corruption: the class stores an active-iteration flag (`_active_iterator`) and fail-fast +raises `RuntimeError("AsyncPaginatedList уже итерируется; используйте materialize() или создайте отдельный список.")`, +if a second `__aiter__` starts before the first finishes. If fan-out is needed — +call `await materialize()` once and iterate over the resulting `list[T]`, +or create a separate `AsyncPaginatedList` per consumer. Documented +in the class docstring and in `docs/site/explanations/pagination-semantics.md` +(addition in M-final). Locked in by the behavior of `tests/core/test_async_pagination.py::test_concurrent_aiter_raises_runtime_error`. -`AsyncPaginator` обязателен как implementation helper: sync-домены используют -`Paginator(...).as_list(...)` в 4 местах (`avito/ads/domain.py:266,1183`, -`avito/accounts/domain.py:170,383`). Текущая публичная поверхность не возвращает -`Paginator` напрямую, поэтому async public methods возвращают `AsyncPaginatedList[T]`, -а не `AsyncPaginator[T]`. Сам `AsyncPaginator` остаётся доступен из `avito.core` для -симметрии core API: `iter_pages()` — `AsyncIterator`, `collect()` — корутина, -`as_list()` создаёт `AsyncPaginatedList`, передавая `first_page` как sync-аналог. +`AsyncPaginator` is mandatory as an implementation helper: sync domains use +`Paginator(...).as_list(...)` in 4 places (`avito/ads/domain.py:266,1183`, +`avito/accounts/domain.py:170,383`). The current public surface does not return +`Paginator` directly, so async public methods return `AsyncPaginatedList[T]`, +not `AsyncPaginator[T]`. `AsyncPaginator` itself remains accessible from `avito.core` for +core API symmetry: `iter_pages()` — `AsyncIterator`, `collect()` — coroutine, +`as_list()` creates an `AsyncPaginatedList`, passing `first_page` like its sync analog. ### `avito/auth/_cache.py` @@ -494,12 +495,12 @@ class TokenCache: def map_token_response(payload: object, *, now: datetime | None = None) -> TokenResponse: ... ``` -`AuthProvider` и `AsyncAuthProvider` хранят `TokenCache` и используют общий `map_token_response`. +`AuthProvider` and `AsyncAuthProvider` store `TokenCache` and use the shared `map_token_response`. -**Compat-shim для существующих тестов.** `tests/core/test_authentication.py:122-127` -напрямую читает и присваивает `provider._access_token` через `dataclasses.replace(...)`. -Чтобы не трогать тесты в M1 PR (риск scope-creep), `AuthProvider` сохраняет три -атрибут-shim'а через `@property`/setter: +**Compat-shim for existing tests.** `tests/core/test_authentication.py:122-127` +directly reads and assigns `provider._access_token` via `dataclasses.replace(...)`. +To avoid touching tests in the M1 PR (scope-creep risk), `AuthProvider` keeps three +attribute shims via `@property`/setter: ```python @property @@ -507,17 +508,17 @@ def _access_token(self) -> AccessToken | None: return self._cache.access_token @_access_token.setter def _access_token(self, value: AccessToken | None) -> None: self._cache.access_token = value -# аналогично _refresh_token, _autoteka_access_token +# similarly for _refresh_token, _autoteka_access_token ``` -Shim-ы помечены `# legacy private accessor — see PR M1` и удаляются позже отдельным PR -с миграцией тестов. +The shims are marked `# legacy private accessor — see PR M1` and are removed later in a separate PR +along with test migration. ### `avito/auth/async_provider.py` ```python class AsyncTokenFetcher(Protocol): - """Async-зеркало sync `TokenFetcher` (avito/auth/provider.py:67-70).""" + """Async mirror of sync `TokenFetcher` (avito/auth/provider.py:67-70).""" async def __call__(self, settings: AuthSettings) -> TokenResponse: ... @@ -534,34 +535,34 @@ class AsyncAuthProvider: async def get_access_token(self) -> str: ... # double-checked + _refresh_lock async def refresh_access_token(self) -> TokenResponse: ... - def invalidate_token(self) -> None: ... # sync clear cache, без await + def invalidate_token(self) -> None: ... # sync clear cache, no await async def aclose(self) -> None: ... async def get_autoteka_access_token(self) -> str: ... # double-checked + _autoteka_refresh_lock def token_flow(self) -> AsyncTokenClient: ... def alternate_token_flow(self) -> AsyncAlternateTokenClient: ... ``` -**Контракт `invalidate_token()` — sync без await.** Метод выполняет одну операцию -`self._cache.access_token = None` (атомарное присваивание поля dataclass'а). Это -безопасно вне `_refresh_lock`, потому что в asyncio нет true-параллелизма между -корутинами одного loop'а: между двумя `await`-точками управление не передаётся, и -параллельная корутина не может «застать» полу-обновлённый state. **Запрещено** делать -`invalidate_token` корутиной с `async with self._refresh_lock` — это вводит ложную -видимость защиты, увеличивает latency 401-handling в `AsyncTransport.request()` и -противоречит sync-контракту, где `AuthProvider.invalidate_token()` тоже sync. Закрепляется -тестом `tests/auth/test_async_provider.py::test_invalidate_token_is_sync_and_idempotent`, -который проверяет, что метод можно вызвать вне корутины (например, из `__del__`-обёртки), -повторный вызов — no-op, и после него `get_access_token()` запускает refresh. - -**Lock lifecycle.** В Python 3.10+ `asyncio.Lock()`, созданный вне event loop, -лениво биндится к loop'у при первом `await`. Чтобы не получить cross-loop UB: -`AsyncAuthProvider` создаётся внутри `AsyncAvitoClient.__aenter__` (или `_from_transport`), -и не переиспользуется между разными event loop'ами. Документируем это в docstring -`AsyncAvitoClient` и в risk-секции. - -Отдельный `_autoteka_refresh_lock` нужен потому, что concurrent first-touch -`get_autoteka_access_token()` вызывал бы дублирующиеся OAuth-запросы Автотеки. Sync-провайдер -этой защиты не имеет (GIL не помогает между потоками), но в async это уже явная гонка. +**Contract of `invalidate_token()` — sync, no await.** The method performs one operation +`self._cache.access_token = None` (atomic assignment of a dataclass field). This +is safe outside `_refresh_lock`, because in asyncio there is no true parallelism between +coroutines of the same loop: between two `await` points control is not transferred, and +a parallel coroutine cannot "catch" half-updated state. **Forbidden** to make +`invalidate_token` a coroutine with `async with self._refresh_lock` — this introduces a false +appearance of protection, increases latency of 401-handling in `AsyncTransport.request()`, and +contradicts the sync contract, where `AuthProvider.invalidate_token()` is also sync. Locked in +by the test `tests/auth/test_async_provider.py::test_invalidate_token_is_sync_and_idempotent`, +which verifies that the method can be called outside a coroutine (e.g. from a `__del__` wrapper), +that a repeated call is a no-op, and that after it `get_access_token()` triggers a refresh. + +**Lock lifecycle.** In Python 3.10+ `asyncio.Lock()` created outside the event loop +lazily binds to the loop on first `await`. To avoid cross-loop UB: +`AsyncAuthProvider` is created inside `AsyncAvitoClient.__aenter__` (or `_from_transport`), +and is not reused across different event loops. We document this in the docstring of +`AsyncAvitoClient` and in the risk section. + +A separate `_autoteka_refresh_lock` is needed because concurrent first-touch +`get_autoteka_access_token()` would cause duplicate Autoteka OAuth requests. The sync provider +does not have this protection (the GIL doesn't help between threads), but in async this is already an explicit race. ### `avito/auth/async_token_client.py` @@ -588,21 +589,21 @@ class AsyncTokenClient: variant="async") async def request_autoteka_client_credentials_token(self, request) -> TokenResponse: ... - async def request_refresh_token(self, request) -> TokenResponse: ... # без binding (sync тоже без) + async def request_refresh_token(self, request) -> TokenResponse: ... # no binding (sync also has none) ``` -`AsyncAlternateTokenClient` — зеркало sync-аналога с `variant="async"` на двух методах +`AsyncAlternateTokenClient` is a mirror of the sync analog with `variant="async"` on two methods (`getAccessTokenAuthorizationCode`, `refreshAccessTokenAuthorizationCode`). -Внутри `AsyncTokenClient._request_token` создаётся **отдельный `AsyncTransport`** с -`auth_provider=None` (зеркало sync `TokenClient._build_transport()`, см. -`avito/auth/provider.py:345-350`). Использование основного `AsyncTransport` через -`AsyncAuthProvider` запрещено — это закольцует OAuth-запрос через сам же auth-провайдер. +Inside `AsyncTokenClient._request_token` a **separate `AsyncTransport`** is created with +`auth_provider=None` (mirror of sync `TokenClient._build_transport()`, see +`avito/auth/provider.py:345-350`). Use of the main `AsyncTransport` through +`AsyncAuthProvider` is forbidden — that would loop the OAuth request through the auth provider itself. -`avito/core/swagger_discovery.py._NON_DOMAIN_BINDING_MODULES` дополняем строго -`"avito.auth.async_token_client"` (а не `async_provider`) — потому что классы со swagger -binding-ами (`AsyncTokenClient`, `AsyncAlternateTokenClient`) живут именно там. Иначе -async-bindings auth-домена не попадут в discovery. +`avito/core/swagger_discovery.py._NON_DOMAIN_BINDING_MODULES` is augmented strictly with +`"avito.auth.async_token_client"` (not `async_provider`) — because the classes with swagger +bindings (`AsyncTokenClient`, `AsyncAlternateTokenClient`) live there. Otherwise +async bindings of the auth domain will not enter discovery. ### `avito/async_client.py` @@ -631,169 +632,168 @@ class AsyncAvitoClient: async def __aenter__(self) -> AsyncAvitoClient: ... async def __aexit__(self, *exc) -> None: ... - # M2-PoC: tariff() добавляется как валидация шаблона - # M3+: на каждом этапе добавляются ВСЕ factory-методы домена сразу + # M2-PoC: tariff() is added as template validation + # M3+: at each step ALL domain factory methods are added at once # def tariff(self) -> AsyncTariff: ... # M2-PoC # def account(self, user_id=None) -> AsyncAccount: ...# M4 # ... ``` -**Lifecycle `from_env` и `__init__`.** `from_env` — **синхронная** фабрика -(зеркало sync `AvitoClient.from_env`): читает `.env`/окружение, конструирует -`AvitoSettings` и возвращает не-инициализированный `AsyncAvitoClient`. SDK-managed -сетевых ресурсов (`httpx.AsyncClient`, `asyncio.Lock`) на этом этапе ещё нет — -они создаются лениво в `__aenter__` под текущий event loop. Исключение: если -пользователь явно передал внешний `http_client`, он уже существует, но transport -и auth-provider всё равно связываются с ним только в `__aenter__`. Это критично потому, -что: -- `httpx.AsyncClient`, созданный в одном loop'е и использованный в другом, даёт - неопределённое поведение; -- `asyncio.Lock` биндится к loop'у при первом `await` и не переносится между - loop'ами; -- `from_env` сам не `async` — пользователь не должен подключать SDK через +**Lifecycle of `from_env` and `__init__`.** `from_env` is a **synchronous** factory +(mirror of sync `AvitoClient.from_env`): it reads `.env`/environment, constructs +`AvitoSettings`, and returns an uninitialized `AsyncAvitoClient`. SDK-managed +network resources (`httpx.AsyncClient`, `asyncio.Lock`) do not yet exist at this stage — +they are created lazily in `__aenter__` for the current event loop. Exception: if +the user explicitly passes an external `http_client`, it already exists, but transport +and auth-provider are still bound to it only in `__aenter__`. This is critical because: +- `httpx.AsyncClient` created in one loop and used in another gives + undefined behavior; +- `asyncio.Lock` binds to the loop on first `await` and does not transfer between + loops; +- `from_env` itself is not `async` — the user should not connect the SDK via `await AsyncAvitoClient.from_env()`. -**Контракт использования — обязательные паттерны:** +**Usage contract — required patterns:** ```python -# (1) Рекомендованный: контекст-менеджер +# (1) Recommended: context manager async with AsyncAvitoClient.from_env() as client: ... -# (2) Допустимый: явный aclose +# (2) Allowed: explicit aclose client = AsyncAvitoClient.from_env() -async with client: # инициализация в __aenter__ +async with client: # initialization in __aenter__ ... -# или +# or client = AsyncAvitoClient.from_env() -await client.__aenter__() # эквивалент async with +await client.__aenter__() # equivalent of async with try: ... finally: await client.aclose() ``` -**Запрещено:** +**Forbidden:** ```python client = AsyncAvitoClient.from_env() -await client.transport.request_json(...) # transport ещё None — RuntimeError +await client.transport.request_json(...) # transport is still None — RuntimeError ``` -`transport`/`auth_provider` — `@property`, возвращают `RuntimeError("AsyncAvitoClient -не инициализирован: используйте 'async with' или дождитесь '__aenter__'")` до -первого `__aenter__`. Закрепляется тестом +`transport`/`auth_provider` are `@property`, return `RuntimeError("AsyncAvitoClient +не инициализирован: используйте 'async with' или дождитесь '__aenter__'")` until +the first `__aenter__`. Locked in by the test `tests/core/test_async_client_lifecycle.py::test_access_before_aenter_raises`. -**Публичный client-contract parity.** `AsyncAvitoClient` зеркалит публичный контракт -`AvitoClient`, который не зависит от конкретного домена: +**Public client-contract parity.** `AsyncAvitoClient` mirrors the public contract of +`AvitoClient` that does not depend on a specific domain: -- `debug_info()` доступен после `__aenter__`, возвращает тот же `TransportDebugInfo`, - что sync `AvitoClient.debug_info()`, и работает через `_require_transport()`; -- `auth()` проверяет `_ensure_open()` и возвращает `AsyncAuthProvider`; -- `aclose()` идемпотентен, выставляет `_closed=True` и закрывает `AsyncTransport` +- `debug_info()` is available after `__aenter__`, returns the same `TransportDebugInfo` + as sync `AvitoClient.debug_info()`, and works through `_require_transport()`; +- `auth()` checks `_ensure_open()` and returns `AsyncAuthProvider`; +- `aclose()` is idempotent, sets `_closed=True`, and closes `AsyncTransport` + `AsyncAuthProvider`; -- после `aclose()` публичные методы (`auth()`, `debug_info()`, factory-методы, - convenience-методы после M-final) бросают `ClientClosedError("Клиент закрыт; создайте новый AsyncAvitoClient.")`; -- доступ к `transport`/`auth_provider` до `__aenter__` остаётся ошибкой - инициализации, а после `aclose()` — ошибкой закрытого клиента. Если оба состояния - возможны, приоритет у `_closed`. - -Это не optional sugar: `debug_info()` входит в публичный diagnostic contract sync SDK -и должен появиться в M1, до первого домена. - -**Ownership внешнего `httpx.AsyncClient`.** В M1 нельзя незаметно менять текущую -sync-семантику. Сейчас sync `Transport.close()` закрывает `httpx.Client` даже если -он был передан извне. Поэтому `AsyncTransport.aclose()` в 2.1.0 зеркалит это -поведение: закрывает внутренний `httpx.AsyncClient` независимо от того, создан он -SDK или передан пользователем. Это фиксируется тестом, чтобы план не опирался на -неверное предположение про `_owns_client`. Если нужна политика "external client is -owned by caller", она вводится отдельным PR одновременно для sync и async с явным -CHANGELOG/deprecation-дизайном. Если `http_client` передан, его loop должен совпадать -с loop'ом, в котором будет вызван `__aenter__`; cross-loop ownership — UB, -проверяется только документацией. - -**Rollback при partial failure в `__aenter__`.** Если `__aenter__` бросает в -середине (например, `httpx.AsyncClient` уже создан, но `AsyncAuthProvider.__post_init__` -или ленивая инициализация локов даёт исключение), весь уже-созданный state должен -быть закрыт до проброса наружу. Реализация: +- after `aclose()` public methods (`auth()`, `debug_info()`, factory methods, + convenience methods after M-final) raise `ClientClosedError("Клиент закрыт; создайте новый AsyncAvitoClient.")`; +- access to `transport`/`auth_provider` before `__aenter__` remains an initialization + error, and after `aclose()` — a closed-client error. If both states are + possible, `_closed` has priority. + +This is not optional sugar: `debug_info()` is part of the public diagnostic contract of the sync SDK +and must appear in M1, before the first domain. + +**Ownership of an external `httpx.AsyncClient`.** In M1 we cannot quietly change the current +sync semantics. Currently, sync `Transport.close()` closes the `httpx.Client` even if +it was passed externally. Therefore `AsyncTransport.aclose()` in 2.1.0 mirrors this +behavior: it closes the internal `httpx.AsyncClient` regardless of whether it was created by +the SDK or passed by the user. This is locked in by a test, so the plan does not rely on a +wrong assumption about `_owns_client`. If an "external client is +owned by caller" policy is needed, it is introduced in a separate PR simultaneously for sync and async with an explicit +CHANGELOG/deprecation design. If `http_client` is passed, its loop must match +the loop in which `__aenter__` will be called; cross-loop ownership is UB, +verified only by documentation. + +**Rollback on partial failure in `__aenter__`.** If `__aenter__` raises in +the middle (for example, `httpx.AsyncClient` is already created, but `AsyncAuthProvider.__post_init__` +or lazy lock initialization throws an exception), all already-created state must +be closed before re-raising. Implementation: ```python async def __aenter__(self) -> AsyncAvitoClient: try: - # любая инициализация, которая может бросить + # any initialization that may raise await self._transport.__aenter__() return self except BaseException: - await self.aclose() # idempotent: безопасен на полу-инициализированном state + await self.aclose() # idempotent: safe on partially-initialized state raise ``` -`aclose()` идемпотентен и устойчив к закрытию полу-инициализированного состояния -(каждый под-ресурс проверяет `is None` перед `await x.aclose()`). Закрепляется -тестом `tests/core/test_async_client_lifecycle.py::test_aenter_rollback_on_partial_failure`. +`aclose()` is idempotent and resilient to closing partially-initialized state +(each sub-resource checks `is None` before `await x.aclose()`). Locked in by +the test `tests/core/test_async_client_lifecycle.py::test_aenter_rollback_on_partial_failure`. -В M1 `AsyncAvitoClient` без domain factory-методов — только lifecycle, `auth()`, -`debug_info()`, closed-state и smoke-вызов через сырой `transport.request_json(...)` -в тесте. **Convenience методы `account_health`, +In M1 `AsyncAvitoClient` has no domain factory methods — only lifecycle, `auth()`, +`debug_info()`, closed-state, and a smoke-call via raw `transport.request_json(...)` +in a test. **Convenience methods `account_health`, `business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, -`promotion_summary`, `capabilities`** на `AsyncAvitoClient` — отдельный (последний) -этап M-final, потому что часть из них комбинирует несколько доменов и не нужна до -того, как все домены портированы. +`promotion_summary`, `capabilities`** on `AsyncAvitoClient` are a separate (last) +stage, M-final, because some of them combine multiple domains and are not needed before +all domains are ported. -**Классификация методов M-final (важно для имплементации).** Не все 8 методов — -агрегаторы; путать паттерн нельзя. +**Classification of M-final methods (important for implementation).** Not all 8 methods are +aggregators; the pattern must not be conflated. -| Метод | Тип | Sync поведение | Async поведение | +| Method | Type | Sync behavior | Async behavior | |---|---|---|---| -| `account_health` | агрегатор с зависимостями | сначала `_resolve_user_id`; затем независимые ветки `balance`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`; `promotion_summary` зависит от `item_ids` из `listing_health` (`avito/client.py:206-263`) | **`asyncio.TaskGroup`** только для независимых веток после `user_id`; `promotion_summary` запускается после `listing_health`. Ошибки `balance`/`listing_health` пробрасываются как sync; chat/order/review/promotion остаются safe-секциями через `_safe_summary_async`. | -| `listing_health` | агрегатор с first-list dependency | сначала `ad.list(...)`, потом при наличии `item_ids` вызывает item stats, calls stats и spendings (`avito/client.py:265-368`) | список объявлений загружается первым; после получения `item_ids` **`asyncio.TaskGroup`** на независимые stats/calls/spendings. Spendings остаётся optional safe-секцией; stats/calls ошибки пробрасываются как sync. | -| `business_summary` | **алиас** для `account_health` | `return self.account_health(...)` (`avito/client.py:184-204`) | `return await self.account_health(...)` — **никакого `TaskGroup`**, делегирование 1:1 | -| `chat_summary` | leaf/sequential | `_resolve_user_id`, затем один вызов `messenger`-домена | последовательный `async def`; `TaskGroup` не нужен | -| `order_summary` | leaf | один вызов `orders`-домена | один `await`; `TaskGroup` запрещён | -| `review_summary` | mixed required+optional | `review().list()` optional-safe, `rating_profile().get()` required (`avito/client.py:396-429`) | **последовательно**, без `TaskGroup`: сначала `reviews` через `_safe_summary_async` (optional, ошибка → unavailable section), затем `await rating_profile().get()` (required, ошибка пробрасывается). TaskGroup запрещён, см. блок «Важная тонкость TaskGroup» ниже. | -| `promotion_summary` | conditional aggregator | `list_orders`; если `item_ids` переданы — дополнительно `list_services` (`avito/client.py:431-465`) | без `item_ids` один `await`; с `item_ids` допускается **`asyncio.TaskGroup`** на `list_orders` и `list_services`. | -| `capabilities` | статическая справка | не делает сетевых probe-запросов, только строит `CapabilityDiscoveryResult` из текущей конфигурации (`avito/client.py:467-531`) | остаётся sync-shaped CPU-only методом без `TaskGroup` и без сетевых вызовов. Если позже capabilities станет probe-методом, это отдельное API/behavior изменение с тестами. | - -Правило: параллелим только фактически независимые сетевые ветки и сохраняем sync -error semantics. Алиасы (`business_summary`), CPU-only методы (`capabilities`) и -leaf'ы (`order_summary`) не получают `TaskGroup`. Это записано в DoD M-final ниже -как явная проверка через code review checklist. - -**Важная тонкость TaskGroup для смешанных required+optional веток.** В sync-коде -`review_summary` сначала делает `review().list()` через `_safe_summary` (optional, ошибка -превращается в unavailable section), потом `rating_profile().get()` (required, ошибка -пробрасывается). Если в async положить обе задачи в **один** `TaskGroup`, и required -`rating` бросит — TaskGroup отменит ещё-не-завершённый optional `reviews`-task через -`CancelledError`. Это **меняет sync-semantics**: в sync `reviews` уже мог отработать -успешно к моменту `rating`-ошибки. Поэтому правильный async-паттерн для смешанных -веток — **sequential within branch, parallel across required-only**: +| `account_health` | aggregator with dependencies | first `_resolve_user_id`; then independent branches `balance`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`; `promotion_summary` depends on `item_ids` from `listing_health` (`avito/client.py:206-263`) | **`asyncio.TaskGroup`** only for independent branches after `user_id`; `promotion_summary` runs after `listing_health`. Errors of `balance`/`listing_health` propagate as in sync; chat/order/review/promotion remain safe sections via `_safe_summary_async`. | +| `listing_health` | aggregator with first-list dependency | first `ad.list(...)`, then if `item_ids` are present, calls item stats, calls stats and spendings (`avito/client.py:265-368`) | the list of ads is loaded first; after obtaining `item_ids`, **`asyncio.TaskGroup`** for independent stats/calls/spendings. Spendings remains an optional safe section; stats/calls errors propagate as in sync. | +| `business_summary` | **alias** for `account_health` | `return self.account_health(...)` (`avito/client.py:184-204`) | `return await self.account_health(...)` — **no `TaskGroup`**, 1:1 delegation | +| `chat_summary` | leaf/sequential | `_resolve_user_id`, then a single call to the `messenger` domain | sequential `async def`; no `TaskGroup` needed | +| `order_summary` | leaf | a single call to the `orders` domain | one `await`; `TaskGroup` forbidden | +| `review_summary` | mixed required+optional | `review().list()` is optional-safe, `rating_profile().get()` is required (`avito/client.py:396-429`) | **sequentially**, without `TaskGroup`: first `reviews` via `_safe_summary_async` (optional, error → unavailable section), then `await rating_profile().get()` (required, error propagates). TaskGroup forbidden, see "Important TaskGroup subtlety" block below. | +| `promotion_summary` | conditional aggregator | `list_orders`; if `item_ids` are passed — additionally `list_services` (`avito/client.py:431-465`) | without `item_ids` one `await`; with `item_ids` **`asyncio.TaskGroup`** is allowed for `list_orders` and `list_services`. | +| `capabilities` | static reference | does not make network probe requests, only builds `CapabilityDiscoveryResult` from current configuration (`avito/client.py:467-531`) | remains a sync-shaped CPU-only method without `TaskGroup` and without network calls. If capabilities later becomes a probe method, that is a separate API/behavior change with tests. | + +The rule: we parallelize only actually independent network branches and preserve sync +error semantics. Aliases (`business_summary`), CPU-only methods (`capabilities`), and +leaves (`order_summary`) do not get `TaskGroup`. This is recorded in the M-final DoD below +as an explicit code review checklist check. + +**Important TaskGroup subtlety for mixed required+optional branches.** In sync code, +`review_summary` first does `review().list()` via `_safe_summary` (optional, error +turns into an unavailable section), then `rating_profile().get()` (required, error +propagates). If in async we put both tasks into **one** `TaskGroup` and the required +`rating` raises — TaskGroup will cancel the not-yet-finished optional `reviews` task via +`CancelledError`. This **changes sync semantics**: in sync, `reviews` could already have +completed successfully by the time of the `rating` error. So the correct async pattern for +mixed branches is **sequential within branch, parallel across required-only**: ```python async def review_summary(self, ...) -> ReviewSummary: - # reviews — optional, всегда оборачивается в _safe_summary_async + # reviews — optional, always wrapped in _safe_summary_async reviews_result, reviews_unavailable = await _safe_summary_async( "reviews", lambda: self.review(...).list(...).materialize() ) - # rating — required, пробрасывает AvitoError + # rating — required, propagates AvitoError rating = await self.rating_profile().get() return ReviewSummary(reviews=reviews_result, rating=rating, unavailable_sections=reviews_unavailable) ``` -`asyncio.TaskGroup` в `review_summary` допустим **только** если обе ветки идут через -`_safe_summary_async` (т.е. обе optional) — это меняет публичный контракт и **запрещено** -в M-final. Допустимый параллелизм: если бы обе были required и независимы. Текущая -смесь optional+required исключает TaskGroup-параллелизм для `review_summary`. -DoD M-final проверяет: `review_summary` async не использует TaskGroup, выполняется -последовательно reviews-then-rating. То же правило применяется к любому будущему -агрегатору со смешанным required/optional набором веток. - -**Cancellation-safe паттерн для агрегаторов (обязательный).** Используется -`asyncio.TaskGroup` (Python 3.11+, у нас floor 3.12+) с per-section try/except, -конвертирующим `AvitoError → SummaryUnavailableSection` (как sync `_safe_summary`, -`avito/client.py:91-98`). `asyncio.gather(..., return_exceptions=True)` запрещён, -потому что он возвращает `CancelledError` как обычный результат — это глушит -cancellation семантику. Шаблон: +`asyncio.TaskGroup` in `review_summary` is allowed **only** if both branches go through +`_safe_summary_async` (i.e. both are optional) — that changes the public contract and is **forbidden** +in M-final. Allowed parallelism: if both were required and independent. The current +optional+required mix excludes TaskGroup parallelism for `review_summary`. +The M-final DoD checks: `review_summary` async does not use TaskGroup, runs +sequentially reviews-then-rating. The same rule applies to any future +aggregator with a mixed required/optional set of branches. + +**Cancellation-safe pattern for aggregators (mandatory).** Used: +`asyncio.TaskGroup` (Python 3.11+, our floor is 3.12+) with per-section try/except +converting `AvitoError → SummaryUnavailableSection` (like sync `_safe_summary`, +`avito/client.py:91-98`). `asyncio.gather(..., return_exceptions=True)` is forbidden, +because it returns `CancelledError` as an ordinary result — that swallows +cancellation semantics. Template: ```python async def _safe_summary_async[T]( @@ -802,7 +802,7 @@ async def _safe_summary_async[T]( try: return await factory(), [] except asyncio.CancelledError: - raise # отмена пробрасывается, никогда не глушим + raise # cancellation propagates, never swallowed except AvitoError as error: return None, [_summary_unavailable_section(section, error)] @@ -812,12 +812,12 @@ async def account_health(self, ...) -> AccountHealthSummary: t_listings = tg.create_task(self.listing_health(...)) t_chat = tg.create_task(_safe_summary_async("chat", lambda: ...)) ... - # После выхода из TaskGroup все таски завершены или отменены атомарно. - # Зависимая promotion ветка запускается после получения item_ids из listings. + # After exiting TaskGroup all tasks are completed or cancelled atomically. + # The dependent promotion branch starts after item_ids from listings are obtained. ``` -При отмене внешнего вызова `TaskGroup` отменит все child-таски и пробросит -`CancelledError` — без зависших корутин и без частичного state. +On cancellation of the outer call, `TaskGroup` will cancel all child tasks and raise +`CancelledError` — without hanging coroutines and without partial state. ### `avito/testing/async_fake_transport.py` @@ -837,565 +837,564 @@ class AsyncFakeTransport: requests: list[RecordedRequest] ``` -Зеркало sync `FakeTransport` (`avito/testing/fake_transport.py`). Использует -`httpx.MockTransport(self._handle)` поверх `httpx.AsyncClient`. `RecordedRequest`, -`JsonValue`, `json_response`, `route_sequence` — переиспользуем без копий из sync. -`sleep` — `lambda _: asyncio.sleep(0)`. +Mirror of sync `FakeTransport` (`avito/testing/fake_transport.py`). Uses +`httpx.MockTransport(self._handle)` over `httpx.AsyncClient`. `RecordedRequest`, +`JsonValue`, `json_response`, `route_sequence` — reused without copies from sync. +`sleep` is `lambda _: asyncio.sleep(0)`. -**Auth mode для fake transport.** По умолчанию `authenticated=False`, чтобы простые -domain-тесты, как sync `FakeTransport.as_client()`, не требовали `/token` route. -Для M1 auth/retry smoke и contract-тестов, где надо проверить реальный -`Authorization`, 401 invalidate и token refresh, используется `authenticated=True`: +**Auth mode for fake transport.** By default `authenticated=False`, so simple +domain tests, like sync `FakeTransport.as_client()`, do not require a `/token` route. +For M1 auth/retry smoke and contract tests, where it is needed to verify a real +`Authorization`, 401 invalidate, and token refresh, `authenticated=True` is used: -- `as_client(authenticated=True)` создаёт `AsyncAuthProvider` с `AsyncTokenClient` / - `AsyncAlternateTokenClient`, построенными на том же `httpx.MockTransport(self._handle)`; -- основной `AsyncTransport` получает этот `auth_provider`, поэтому первый - авторизованный запрос вызывает `/token`, а 401 сбрасывает кэш и делает второй +- `as_client(authenticated=True)` creates `AsyncAuthProvider` with `AsyncTokenClient` / + `AsyncAlternateTokenClient` built on the same `httpx.MockTransport(self._handle)`; +- the main `AsyncTransport` receives this `auth_provider`, so the first + authorized request triggers `/token`, and a 401 clears the cache and triggers a second `/token`; -- тест обязан явно зарегистрировать token routes через `add_json("POST", "/token", ...)`; -- `build(authenticated=True)` возвращает низкоуровневый `AsyncTransport` с таким же - auth provider-ом, чтобы core-тесты не обходили auth pipeline. - -Без этого M1 smoke может выглядеть «авторизованным», но фактически пройти через -transport с `auth_provider=None` и не проверить refresh-семантику. - -**Семантика `user_id` отдельно от `authenticated`.** `as_client(user_id=N, -authenticated=False)` — корректный паттерн для domain-тестов, которые вызывают -методы с `_resolve_user_id` (например, `AsyncAccount.get_balance()`). В этом -режиме: - -- `AsyncAvitoClient.settings.user_id == N` — `_resolve_user_id` берёт его как - fallback и **не** делает raw запрос на `/core/v1/accounts/self`; -- `AsyncTransport` создаётся с `auth_provider=None` — request-level header - `Authorization` не подставляется; `RequestContext.requires_auth=True` без auth - provider'а не падает (зеркало sync `Transport._merge_headers`: `if +- the test must explicitly register token routes via `add_json("POST", "/token", ...)`; +- `build(authenticated=True)` returns a low-level `AsyncTransport` with the same + auth provider, so core tests do not bypass the auth pipeline. + +Without this, the M1 smoke could look "authenticated" but actually go through +a transport with `auth_provider=None` and not verify refresh semantics. + +**Semantics of `user_id` separately from `authenticated`.** `as_client(user_id=N, +authenticated=False)` is the correct pattern for domain tests that call +methods with `_resolve_user_id` (for example, `AsyncAccount.get_balance()`). In this +mode: + +- `AsyncAvitoClient.settings.user_id == N` — `_resolve_user_id` takes it as a + fallback and **does not** make a raw request to `/core/v1/accounts/self`; +- `AsyncTransport` is created with `auth_provider=None` — the request-level header + `Authorization` is not set; `RequestContext.requires_auth=True` without an auth + provider does not fail (mirror of sync `Transport._merge_headers`: `if context.requires_auth and self._auth_provider is not None: ...`); -- если домен-тест требует и `user_id`, и проверки auth pipeline (refresh, 401 - invalidate) — комбинируется `as_client(user_id=N, authenticated=True)`, но при - этом любой запрос на `/core/v1/accounts/self` всё равно не дёргается, потому что - `user_id` уже резолвлен. +- if a domain test requires both `user_id` and a check of the auth pipeline (refresh, 401 + invalidate) — combine `as_client(user_id=N, authenticated=True)`, but in this case + any request to `/core/v1/accounts/self` is still not made, because + `user_id` is already resolved. -Это зеркало sync `FakeTransport.as_client(user_id=N)` контракта (без -`authenticated`). Закрепляется тестом +This is a mirror of the sync `FakeTransport.as_client(user_id=N)` contract (without +`authenticated`). Locked in by the test `tests/core/test_async_fake_transport.py::test_as_client_user_id_skips_self_lookup`. -**Concurrency policy.** `_handle` мутирует `self.requests.append(...)` и `route.pop(0)` -для `route_sequence`-сценариев. Для тестов с `asyncio.gather(...)` (в первую очередь -M-final convenience-методы) `_handle` берёт `self._handle_lock = asyncio.Lock()` и -сериализует match-and-record под ним. Без этого две параллельные корутины могут -одновременно дёрнуть `route.pop(0)` и получить непредсказуемый порядок ответов. - -**Инициализация lock'а в `__init__` (а не лениво).** Лениво создавать `asyncio.Lock` -из `_handle` нельзя: две корутины, одновременно прошедшие `if self._handle_lock is -None`, создадут разные lock-объекты — и сериализация сломается до первого `await`. -Поэтому `self._handle_lock = asyncio.Lock()` создаётся в `__init__`; экземпляр -`AsyncFakeTransport` создаётся внутри async-теста/loop'а, а lock биндится к loop'у -при первом `await`. Цена: `AsyncFakeTransport` нельзя переиспользовать между event -loop'ами (под `pytest-asyncio strict` это и так не происходит — каждый тест получает -свой loop). Документируется в docstring: «AsyncFakeTransport безопасен для concurrent -access внутри одного event loop'а; создавать новый instance в каждом тесте; не -переиспользовать между loop'ами». - -## Swagger binding — детали изменений +**Concurrency policy.** `_handle` mutates `self.requests.append(...)` and `route.pop(0)` +for `route_sequence` scenarios. For tests with `asyncio.gather(...)` (primarily +M-final convenience methods) `_handle` takes `self._handle_lock = asyncio.Lock()` and +serializes match-and-record under it. Without this, two parallel coroutines may +simultaneously call `route.pop(0)` and get an unpredictable order of responses. + +**Lock initialization in `__init__` (not lazy).** It is not allowed to lazily create `asyncio.Lock` +from `_handle`: two coroutines simultaneously passing `if self._handle_lock is +None` would create different lock objects — and serialization will break before the first `await`. +Therefore `self._handle_lock = asyncio.Lock()` is created in `__init__`; the +`AsyncFakeTransport` instance is created inside an async test/loop, and the lock is bound to the loop +on the first `await`. The cost: `AsyncFakeTransport` cannot be reused across event +loops (under `pytest-asyncio strict` this does not happen anyway — each test gets +its own loop). Documented in the docstring: "AsyncFakeTransport is safe for concurrent +access within a single event loop; create a new instance in each test; do not +reuse across loops." + +## Swagger binding — change details 1. `SwaggerOperationBinding` (`avito/core/swagger.py`): - `variant: Literal["sync","async"] = "sync"` (frozen field). - - Декоратор `swagger_operation(..., variant: Literal["sync","async"] = "sync")`. - - `__post_init__` валидирует runtime-значение: любое значение кроме `"sync"` / - `"async"` даёт `ConfigurationError`, потому что `Literal` не защищает вызов - из runtime-кода. - - Двойной декор одной функции остаётся `ConfigurationError`. + - The decorator `swagger_operation(..., variant: Literal["sync","async"] = "sync")`. + - `__post_init__` validates the runtime value: any value other than `"sync"` / + `"async"` gives `ConfigurationError`, because `Literal` does not protect a call + from runtime code. + - Double-decorating one function remains `ConfigurationError`. 2. `DiscoveredSwaggerBinding` (`avito/core/swagger_discovery.py`): - - `variant: Literal["sync","async"]` копируется из `SwaggerOperationBinding`. - - `_iter_domain_modules` ищет в каждом пакете оба модуля: `.domain` и `.async_domain`. Если `async_domain` нет — игнорируем (это нормальная стадия миграции). - - `canonical_map` остаётся sync-only compatibility property, чтобы текущие - `tests/contracts/test_swagger_contracts.py` и report builder не получили - silent semantic break. Реализация явно фильтрует `variant == "sync"`, а не - "последний binding wins". - - новый API: `canonical_map_by_variant: Mapping[Literal["sync","async"], - Mapping[str, DiscoveredSwaggerBinding]]` и/или `binding_for(operation_key, - variant)`. Внутренний уникальный ключ — `(operation_key, variant)`. + - `variant: Literal["sync","async"]` is copied from `SwaggerOperationBinding`. + - `_iter_domain_modules` looks for both modules in each package: `.domain` and `.async_domain`. If `async_domain` is not there — we ignore (this is a normal stage of migration). + - `canonical_map` remains a sync-only compatibility property, so that current + `tests/contracts/test_swagger_contracts.py` and the report builder do not get a + silent semantic break. The implementation explicitly filters `variant == "sync"`, not + "last binding wins". + - new API: `canonical_map_by_variant: Mapping[Literal["sync","async"], + Mapping[str, DiscoveredSwaggerBinding]]` and/or `binding_for(operation_key, + variant)`. The internal unique key is `(operation_key, variant)`. 3. `swagger_linter.py`: - - `_validate_single_binding_per_sdk_method` — без изменений: ключ `binding.sdk_method` уникален даже в async (т.к. `module.class.method` отличается). - - `_validate_duplicate_bindings` — ключ `(operation_key, variant)` вместо `operation_key`. Допустимо иметь две независимые цепочки (sync + async) на одну swagger-операцию. - - `_validate_factory` становится variant-aware с **class-gated coverage**, симметрично + - `_validate_single_binding_per_sdk_method` — unchanged: the key `binding.sdk_method` is unique even in async (because `module.class.method` differs). + - `_validate_duplicate_bindings` — key `(operation_key, variant)` instead of `operation_key`. It is allowed to have two independent chains (sync + async) for one swagger operation. + - `_validate_factory` becomes variant-aware with **class-gated coverage**, symmetrically to `_validate_complete_bindings`: - - sync binding с заданным `factory` проверяет factory на `AvitoClient`. - - async binding с заданным `factory` проверяется на `AsyncAvitoClient` **только если** - соответствующий `Async` уже существует в домене (тот же class-gated предикат, - что в `_validate_complete_bindings`). Если `Async` ещё не появился — async - binding'и его класса вообще не должны существовать (per-class инвариант), а если - исключения — не проверяется. - - async binding **без** `factory` в декораторе (в первую очередь auth-bindings + - sync binding with a given `factory` checks the factory on `AvitoClient`. + - async binding with a given `factory` is checked on `AsyncAvitoClient` **only if** + the corresponding `Async` already exists in the domain (the same class-gated predicate + as in `_validate_complete_bindings`). If `Async` has not yet appeared — async + bindings for its class must not exist at all (per-class invariant), and if there are + exceptions — it is not checked. + - an async binding **without** a `factory` in the decorator (primarily auth bindings `AsyncTokenClient.request_client_credentials_token`, - `AsyncAlternateTokenClient.*`) пропускается ровно так же, как sync без `factory`. - Так в M1 (когда в `AsyncAvitoClient` ещё нет ни одного domain-factory) async auth - bindings не падают на `_validate_factory`, а с M2-PoC `tariff()` factory обязан - появиться. - Без этого class-gated подхода либо M1 красный (ложный fail на auth), либо инвариант - ослаблен (зелёный swagger-lint при отсутствующем async factory в M3+). DoD M1 явно - включает проверку, что `_validate_factory(variant="async")` зелёный для async auth - bindings и не требует ни одного domain-factory на `AsyncAvitoClient`. - - `_validate_complete_bindings(operations, bindings)` → `_validate_complete_bindings(operations, bindings, variant)`. Запускается дважды: - - для `variant="sync"`: ожидаемое множество = все `operations` (как сейчас). - - для `variant="async"`: ожидаемое множество = **per-class**, не per-domain. - Для каждого sync-класса в домене (``) проверяем: существует ли - `Async` (по имени, `cls.__name__.startswith("Async") and - cls.__name__.removeprefix("Async") == sync_cls.__name__`, в том же пакете). - Если да — все swagger-операции, привязанные к sync-методам этого класса, - обязаны иметь async-двойник в `Async`. Если нет — класс считается - «ещё не портированным», и его операции не входят в expected для - `variant="async"` на этом этапе. - - Помимо `_API_DOMAINS`, для `domain == "auth"` берём операции из - `Авторизация.json` и `Автотека.json`, если найден `AsyncTokenClient` / - `AsyncAlternateTokenClient` соответственно (та же per-class логика). - - Это даёт два важных свойства: - 1. M1 фундамент мерджится: для API-доменов ни одного `Async` нет → - domain expected = ∅; для auth expected включает только - `AsyncTokenClient` / `AsyncAlternateTokenClient` bindings. Линтер зелёный. - 2. Большой домен (например, M11 `ads` с 3 классами `Ad`/`AutoloadProfile`/ - `AutoloadReport`) теоретически можно разбить на под-PR'ы по классу; - DoD M3…M12 всё равно требует закрытия домена на 100%, но per-class - гранулярность даёт безопасную точку выхода, если PR раздувается. - (Дробление допустимо только при явном решении, а не «сделаю остальное - потом» — см. DoD M3…M12.) - - `_validate_operation_spec_coverage` — без изменений (sync OperationSpec — единый источник истины для обоих режимов; реюз спеки между sync и async-методами не запрещён). `used_specs` — `set[id(spec)]`, поэтому одна и та же `OperationSpec` от sync и async binding'ов не дублируется и не теряется. - - `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) — резолвит spec через `unwrapped_method.__globals__`. Async-методы должны импортировать spec явно (`from avito..operations import LIST_SPEC`), иначе резолв вернёт `()` и spec будет считаться unbound. Pre-flight тест проверяет, что это работает; если нет — fallback-план для Phase 1b расписан **до** старта M1, не «по ситуации»: - 1. **Primary fallback** (минимум изменений): расширить `_operation_specs_for_sdk_method`, - чтобы помимо `__globals__` он также пробегал `inspect.getsourcefile(method)` → - `ast.parse` → искал в исходнике **локальные** ссылки на `OperationSpec`-объекты - и резолвил их через AST + `getattr` модуля. Это покрывает кейс, когда spec - вызывается через `self._execute(LIST_SPEC, ...)` без `from ... import LIST_SPEC` - на module-level. - 2. **Secondary fallback** (структурный): ввести class-level атрибут - `__operation_specs__: Mapping[str, OperationSpec]` на каждом domain-классе, - перечисляющий `(method_name, spec)` пары. `_operation_specs_for_sdk_method` - читает атрибут первым делом, до `__globals__`. Этот вариант требует туда же - дописать sync-классы (для симметрии), но даёт детерминированный резолв без AST. - Решение между primary и secondary принимается **по результату pre-flight**, не позже, - с оценкой scope в часах. Если ни один не работает — это blocker для M1 и план - откатывается на пересмотр (фундамент без работоспособного swagger-coverage гейта - не годен). - - `_validate_json_body_model_coverage` — запускается по sync bindings; async - bindings проверяются через `AsyncSwaggerFakeTransport` contract suite, чтобы - не дублировать schema-lint ошибки на общих `OperationSpec`. - -4. `swagger_report.py` и docs report: - - `operations[].binding` остаётся sync-only compatibility field. - - добавляется `operations[].bindings_by_variant = {"sync": ..., "async": ...}`. - - `summary.bound/unbound/duplicate/ambiguous` остаются sync-only до отдельного + `AsyncAlternateTokenClient.*`) is skipped exactly as sync without `factory`. + So in M1 (when there are no domain factories on `AsyncAvitoClient` yet), async auth + bindings do not fail on `_validate_factory`, and starting from M2-PoC `tariff()` the factory must + appear. + Without this class-gated approach, either M1 is red (false fail on auth), or the invariant + is weakened (green swagger-lint with a missing async factory in M3+). The M1 DoD explicitly + includes a check that `_validate_factory(variant="async")` is green for async auth + bindings and does not require any domain factory on `AsyncAvitoClient`. + - `_validate_complete_bindings(operations, bindings)` → `_validate_complete_bindings(operations, bindings, variant)`. Runs twice: + - for `variant="sync"`: expected set = all `operations` (as it is now). + - for `variant="async"`: expected set = **per-class**, not per-domain. + For each sync class in the domain (``) we check: does + `Async` exist (by name, `cls.__name__.startswith("Async") and + cls.__name__.removeprefix("Async") == sync_cls.__name__`, in the same package). + If yes — all swagger operations bound to sync methods of this class + must have an async double in `Async`. If not — the class is considered + "not yet ported", and its operations do not enter expected for + `variant="async"` at this stage. + + In addition to `_API_DOMAINS`, for `domain == "auth"` we take operations from + `Авторизация.json` and `Автотека.json` if `AsyncTokenClient` / + `AsyncAlternateTokenClient` is found respectively (the same per-class logic). + + This gives two important properties: + 1. The M1 foundation is mergeable: for API domains there is no `Async` → + domain expected = ∅; for auth, expected only includes + `AsyncTokenClient` / `AsyncAlternateTokenClient` bindings. Linter is green. + 2. A large domain (e.g. M11 `ads` with 3 classes `Ad`/`AutoloadProfile`/ + `AutoloadReport`) can theoretically be split into sub-PRs by class; + the M3…M12 DoD still requires closing the domain to 100%, but per-class + granularity provides a safe exit point if the PR balloons. + (Splitting is allowed only on an explicit decision, not as "I'll do the rest + later" — see DoD M3…M12.) + - `_validate_operation_spec_coverage` — unchanged (sync OperationSpec is the single source of truth for both modes; reusing the spec between sync and async methods is not forbidden). `used_specs` is `set[id(spec)]`, so the same `OperationSpec` from sync and async bindings is not duplicated and not lost. + - `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) resolves the spec via `unwrapped_method.__globals__`. Async methods must import the spec explicitly (`from avito..operations import LIST_SPEC`), otherwise the resolution will return `()` and the spec will be considered unbound. A pre-flight test verifies this works; if it does not — a fallback plan for Phase 1b is laid out **before** the start of M1, not "as we go": + 1. **Primary fallback** (minimum changes): extend `_operation_specs_for_sdk_method` + so that in addition to `__globals__` it also goes through `inspect.getsourcefile(method)` → + `ast.parse` → looks in the source for **local** references to `OperationSpec` objects + and resolves them via AST + module `getattr`. This covers the case where a spec + is invoked through `self._execute(LIST_SPEC, ...)` without `from ... import LIST_SPEC` + at module level. + 2. **Secondary fallback** (structural): introduce a class-level attribute + `__operation_specs__: Mapping[str, OperationSpec]` on each domain class, + listing `(method_name, spec)` pairs. `_operation_specs_for_sdk_method` + reads the attribute first, before `__globals__`. This option requires writing + sync classes the same way (for symmetry), but provides deterministic resolution without AST. + The decision between primary and secondary is taken **by pre-flight result**, no later, + with a scope estimate in hours. If neither works — this is a blocker for M1, and the plan + is rolled back for review (a foundation without a working swagger-coverage gate + is not fit for purpose). + - `_validate_json_body_model_coverage` runs against sync bindings; async + bindings are checked through the `AsyncSwaggerFakeTransport` contract suite, so as + not to duplicate schema-lint errors on shared `OperationSpec`s. + +4. `swagger_report.py` and the docs report: + - `operations[].binding` remains a sync-only compatibility field. + - `operations[].bindings_by_variant = {"sync": ..., "async": ...}` is added. + - `summary.bound/unbound/duplicate/ambiguous` remain sync-only until a separate report API bump. - - добавляется `summary.variants.sync` и `summary.variants.async` с теми же - счётчиками. Для M1 async domain summary может быть `bound=0, expected=0`, - а async auth summary уже должен покрывать свои bindings; после M-final общий + - `summary.variants.sync` and `summary.variants.async` are added with the same + counters. For M1 the async domain summary may be `bound=0, expected=0`, + while the async auth summary must already cover its bindings; after M-final, total async expected/bound = 204. - - `docs/site/assets/_gen_reference.py` и `reference/operations.md` показывают обе - SDK-ссылки, когда async binding уже существует, но не ломают текущую sync-карту. + - `docs/site/assets/_gen_reference.py` and `reference/operations.md` show both + SDK links when an async binding already exists, but do not break the current sync map. 5. Contract tests: - - `tests/contracts/test_swagger_contracts.py` фильтрует bindings по - `variant="sync"` и сохраняет текущий exhaustive sync behavior. - - новый `tests/contracts/test_async_swagger_contracts.py` — Swagger-spec - compliance test, а не architecture/introspection test: для каждого discovered - binding с `variant="async"` `AsyncSwaggerFakeTransport` строит - `AsyncAvitoClient`, вызывает async SDK method через `await`, валидирует - фактический request против Swagger и проверяет success/error payload mapping. - В M1 он покрывает async auth-bindings; в M2+ автоматически расширяется на - портированные домены. - -6. `scripts/lint_async_parity.py` — static linter, проверяет для каждого Async-класса: - - имя `Async` ↔ существует sync `` в том же пакете; - - class-level metadata зеркальна sync-классу: `__swagger_domain__`, - `__sdk_factory__`, `__sdk_factory_args__` должны совпадать по значениям - (за исключением сознательно документированных legacy-wrapper'ов, если такие - появятся отдельным PR); - - множество публичных async-методов (`async def` без префикса `_`) совпадает с sync-методами; - - перебор методов фильтруется по `func.__qualname__.startswith(cls.__name__ + ".")`, - чтобы не учитывать унаследованные от `AsyncDomainObject` (`_execute`, `_resolve_user_id`) - или `object` методы; - - для каждой пары `(sync_method, async_method)`: - - `inspect.signature(sync).parameters` (без `self`) == `inspect.signature(async).parameters`; - - аннотация возврата либо совпадает, либо `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`, - либо `BinaryResponse`/wrapper-модель совпадает напрямую; `Paginator[T] ↔ - AsyncPaginator[T]` допускается только если в будущем появится публичный - sync-метод, который реально возвращает `Paginator[T]`; - - оба декорированы `@swagger_operation` на ту же `(spec, method, path, operation_id)`, отличаясь только `variant`. - - для каждой async class-level `__sdk_factory__` проверяет, что такой factory - существует на `AsyncAvitoClient`, имеет сигнатуру, совместимую с sync factory - на `AvitoClient`, и возвращает соответствующий `Async`. - Если metadata отсутствует, это blocker даже при наличии decorator-ов: - swagger discovery, reference builder и IDE-discovery должны видеть async-класс - тем же способом, что sync-класс. - Этот linter вызывается из `make quality`; pytest не содержит parity/introspection - тестов, потому что STYLEGUIDE разрешает в pytest только functional tests и - Swagger-spec compliance tests. - - Linter дополнительно экспортирует `iter_async_classes() -> Iterator[type[AsyncDomainObject]]` - как публичный API модуля (без `_`-префикса). Это **единственный источник истины** - для списка `Async` классов: M-final verification скрипт берёт его оттуда вместо - хардкода имён, так что добавление нового класса не требует правки M-final проверки. - Контракт `iter_async_classes()`: - - возвращает все `Async` классы из всех `avito//async_domain.py` - (исключая `EXCLUDED_PACKAGES = {"auth", "core", "testing"}` — auth-bindings - reference не получают); - - порядок: stable sort по `(package_name, class_name)`; - - не зависит от prior-state (можно вызывать до и после любого M-этапа). - -## Этапы - -### Pre-flight для PR M1 - -До открытия PR M1 (всё это делается локально и валидируется до коммита): + - `tests/contracts/test_swagger_contracts.py` filters bindings by + `variant="sync"` and preserves the current exhaustive sync behavior. + - new `tests/contracts/test_async_swagger_contracts.py` — a Swagger-spec + compliance test, not an architecture/introspection test: for each discovered + binding with `variant="async"`, `AsyncSwaggerFakeTransport` builds + `AsyncAvitoClient`, calls the async SDK method via `await`, validates + the actual request against Swagger, and checks success/error payload mapping. + In M1 it covers async auth bindings; in M2+ it automatically extends to + ported domains. + +6. `scripts/lint_async_parity.py` — a static linter, checks for each Async class: + - the name `Async` ↔ a sync `` exists in the same package; + - class-level metadata mirrors the sync class: `__swagger_domain__`, + `__sdk_factory__`, `__sdk_factory_args__` must match by value + (except for deliberately documented legacy wrappers, if such appear in a separate PR); + - the set of public async methods (`async def` without `_` prefix) matches sync methods; + - method enumeration is filtered by `func.__qualname__.startswith(cls.__name__ + ".")`, + so as not to count methods inherited from `AsyncDomainObject` (`_execute`, `_resolve_user_id`) + or `object`; + - for each pair `(sync_method, async_method)`: + - `inspect.signature(sync).parameters` (without `self`) == `inspect.signature(async).parameters`; + - the return annotation either matches, or `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`, + or `BinaryResponse`/wrapper-model matches directly; `Paginator[T] ↔ + AsyncPaginator[T]` is allowed only if a public sync method that actually returns + `Paginator[T]` appears in the future; + - both are decorated with `@swagger_operation` for the same `(spec, method, path, operation_id)`, differing only by `variant`. + - for each async class-level `__sdk_factory__` it checks that such a factory + exists on `AsyncAvitoClient`, has a signature compatible with the sync factory + on `AvitoClient`, and returns the corresponding `Async`. + If metadata is missing, it is a blocker even if decorators are present: + swagger discovery, the reference builder, and IDE-discovery must see the async class + the same way as the sync class. + This linter is invoked from `make quality`; pytest does not contain parity/introspection + tests, because the STYLEGUIDE only allows functional tests and + Swagger-spec compliance tests in pytest. + + The linter additionally exports `iter_async_classes() -> Iterator[type[AsyncDomainObject]]` + as a public module API (without `_` prefix). This is the **single source of truth** + for the list of `Async` classes: the M-final verification script takes it from there instead of + hardcoding names, so adding a new class does not require editing the M-final check. + Contract of `iter_async_classes()`: + - returns all `Async` classes from all `avito//async_domain.py` + (excluding `EXCLUDED_PACKAGES = {"auth", "core", "testing"}` — auth bindings + do not get a reference); + - order: stable sort by `(package_name, class_name)`; + - does not depend on prior state (can be called before and after any M stage). + +## Stages + +### Pre-flight for PR M1 + +Before opening PR M1 (all of this is done locally and validated before commit): - [ ] `grep -rn "\._access_token\|\._refresh_token\|\._autoteka_access_token" tests/` — - зафиксировать все private probes; убедиться, что compat-shim в `AuthProvider` - покроет каждый. Найденный сейчас кейс: `tests/core/test_authentication.py:122-127`. -- [ ] `grep -rn "\bPaginator\b" avito/` — зафиксировать все 4 usage-сайта + record all private probes; ensure that the compat-shim in `AuthProvider` + covers each. Currently found case: `tests/core/test_authentication.py:122-127`. +- [ ] `grep -rn "\bPaginator\b" avito/` — record all 4 usage sites (`avito/ads/domain.py:266,1183`, `avito/accounts/domain.py:170,383`). - Все текущие usage-сайты завершаются `.as_list(...)`; прямого публичного - возврата `Paginator` нет. `AsyncPaginator.as_list()` нужен уже к M4 - (`accounts`), но root-level export `AsyncPaginator` не нужен. -- [ ] `grep -rn "len(.*Paginated\|\\b[a-z_]*list\\[[0-9-]" avito/ tests/` — найти все - потребители list-API на `PaginatedList[T]` (индексация, `len`, `bool`, slice). - `AsyncPaginatedList` намеренно НЕ повторяет list-API: каждый такой кейс должен - быть либо безопасен (только sync), либо явно заменён на `await materialize()` / - `loaded_count` в async-двойнике. Список фиксируется в commit-message PoC. -- [ ] `grep -rn "^async def test_" tests/` — убедиться, что в существующих тестах нет - async-функций без `@pytest.mark.asyncio`. После включения - `asyncio_mode = "strict"` любой такой тест начнёт игнорироваться (warning, - не падение). Если найдены — добавить маркер в pre-flight commit, отдельно от M1. -- [ ] Подтвердить минимальную поддерживаемую версию Python в `pyproject.toml`. SDK уже - использует PEP 695 (`type PageFetcher[ItemT] = ...` в `avito/core/pagination.py:10`), - значит требуется Python **3.12+**. Все async-контракты (`type AsyncPageFetcher`, - `async def execute[ResponseT]`) сохраняют этот же floor; повышать не нужно, но - явно зафиксировать в M1 PR description. -- [ ] Прогон baseline на чистом `main` — сохранить **nodeid существующих тестов** и - их pass/fail статусы: + All current usage sites end with `.as_list(...)`; there is no direct public + return of `Paginator`. `AsyncPaginator.as_list()` is needed by M4 + (`accounts`), but a root-level export of `AsyncPaginator` is not needed. +- [ ] `grep -rn "len(.*Paginated\|\\b[a-z_]*list\\[[0-9-]" avito/ tests/` — find all + consumers of the list API on `PaginatedList[T]` (indexing, `len`, `bool`, slice). + `AsyncPaginatedList` deliberately does NOT replicate the list API: each such case must + either be safe (sync-only), or explicitly replaced with `await materialize()` / + `loaded_count` in the async double. The list is recorded in the PoC commit message. +- [ ] `grep -rn "^async def test_" tests/` — ensure that existing tests have no + async functions without `@pytest.mark.asyncio`. After enabling + `asyncio_mode = "strict"`, any such test will start being ignored (warning, + not failure). If found — add the marker in a pre-flight commit, separately from M1. +- [ ] Confirm the minimum supported Python version in `pyproject.toml`. The SDK already + uses PEP 695 (`type PageFetcher[ItemT] = ...` in `avito/core/pagination.py:10`), + which means Python **3.12+** is required. All async contracts (`type AsyncPageFetcher`, + `async def execute[ResponseT]`) keep this same floor; raising it is unnecessary, but + explicitly recorded in the M1 PR description. +- [ ] Baseline run on a clean `main` — save **nodeids of existing tests** and + their pass/fail statuses: `poetry run pytest --collect-only -q tests/core tests/auth tests/domains tests/contracts | grep '::' > /tmp/baseline_nodeids.txt` - и затем `poetry run pytest -q --tb=no $(cat /tmp/baseline_nodeids.txt) > - /tmp/baseline_main.txt`. Используется в DoD M1; новые async tests после M1 - не входят в baseline-сравнение. -- [ ] Проверить, что `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) - работает с `async_domain.py`: тест-стаб с `async def m(self): return self._execute(SOME_SPEC)` - и `from ...operations import SOME_SPEC` — функция должна найти `SOME_SPEC` через - `unwrapped_method.__globals__`. Если не работает — расширить функцию (Phase 1b), - иначе оставить без изменений. -- [ ] Прочитать `docs/site/assets/_gen_reference.py` целиком и зафиксировать - существующие точки фильтрации: `PACKAGE_ROOT.glob("*/domain.py")`, - `EXCLUDED_PACKAGES`, `public_domain_classes()` (фильтр по `DomainObject`-наследованию - и `value.__module__.startswith(f"avito.{package}.")`), `public_domain_methods()` - (фильтр по `value.__qualname__.startswith(f"{domain_class.__name__}.")`), - и `write_domain_pages()` (сейчас он пишет один `::: avito.` и не - использует class helper-ы). Расширение builder'а в M1 обязано переиспользовать - эту логику для `async_domain.py` + `AsyncDomainObject`-наследников, а - `write_domain_pages()` должен перейти на явные class-директивы sync → async - и не полагаться только на `avito..__all__`. Без этого reference будет - несимметричным. -- [ ] Прочитать `scripts/lint_architecture.py` и `scripts/lint_docstrings.py`: - текущие проверки смотрят только `domain.py` и `ast.FunctionDef`. M1 обязан - расширить их на `async_domain.py` и `ast.AsyncFunctionDef`. -- [ ] Прочитать `avito/core/deprecation.py`: текущий `deprecated_method` возвращает - sync-wrapper. M1 обязан добавить async-aware wrapper до портирования - deprecated методов `cpa`/`ads`. + and then `poetry run pytest -q --tb=no $(cat /tmp/baseline_nodeids.txt) > + /tmp/baseline_main.txt`. Used in the M1 DoD; new async tests after M1 + do not enter the baseline comparison. +- [ ] Verify that `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) + works with `async_domain.py`: a test stub with `async def m(self): return self._execute(SOME_SPEC)` + and `from ...operations import SOME_SPEC` — the function must find `SOME_SPEC` via + `unwrapped_method.__globals__`. If it does not work — extend the function (Phase 1b), + otherwise leave unchanged. +- [ ] Read `docs/site/assets/_gen_reference.py` in full and record + existing filter points: `PACKAGE_ROOT.glob("*/domain.py")`, + `EXCLUDED_PACKAGES`, `public_domain_classes()` (filter by `DomainObject` inheritance + and `value.__module__.startswith(f"avito.{package}.")`), `public_domain_methods()` + (filter by `value.__qualname__.startswith(f"{domain_class.__name__}.")`), + and `write_domain_pages()` (currently writes one `::: avito.` and does + not use class helpers). The builder extension in M1 must reuse + this logic for `async_domain.py` + `AsyncDomainObject` descendants, and + `write_domain_pages()` must move to explicit class directives sync → async + and not rely solely on `avito..__all__`. Without this, the reference will be + asymmetric. +- [ ] Read `scripts/lint_architecture.py` and `scripts/lint_docstrings.py`: + current checks look only at `domain.py` and `ast.FunctionDef`. M1 must + extend them to `async_domain.py` and `ast.AsyncFunctionDef`. +- [ ] Read `avito/core/deprecation.py`: the current `deprecated_method` returns a + sync wrapper. M1 must add an async-aware wrapper before porting the + deprecated methods of `cpa`/`ads`. - [ ] `grep -rn "@deprecated_method\|deprecated_method(" avito/cpa/ avito/ads/` — - зафиксировать **точное** число sync deprecated методов, требующих async-двойников. - На момент написания плана: 3 в `avito/cpa/domain.py:491,541,585` и 4 в - `avito/ads/domain.py:1416,1457,1523,1558` — итого 7. async-aware wrapper в - `deprecation.py` — обязательный артефакт M1, без него M6 (`cpa`) и M11 (`ads`) - не закроются. Если фактическое число расходится с зафиксированным — обновить - таблицу sequencing и DoD M6/M11 до старта M1. -- [ ] Прочитать `avito/core/swagger_linter.py::_validate_factory` целиком и зафиксировать - текущее поведение: на каких полях binding'а он гейтится (`factory`, `factory_args`), - как резолвит factory на `AvitoClient`, что считает ошибкой. M1 обязан расширить - его с class-gated coverage (см. Swagger-секцию). Без полного понимания текущей - логики расширение рискует ослабить инвариант для sync-bindings. -- [ ] **Прогон pre-flight локально, фиксация результатов**: pre-flight тест на - `_operation_specs_for_sdk_method` для async-стаба фактически запущен; результат - (pass/fail) и выбранный fallback (none / primary / secondary) зафиксированы - в M1 PR description. Без фактического прогона M1 не открывается. - -### M1 — Фундамент (1 PR) + record the **exact** number of sync deprecated methods that require async doubles. + At the time of writing the plan: 3 in `avito/cpa/domain.py:491,541,585` and 4 in + `avito/ads/domain.py:1416,1457,1523,1558` — totaling 7. The async-aware wrapper in + `deprecation.py` is a mandatory artifact of M1, without which M6 (`cpa`) and M11 (`ads`) + cannot close. If the actual number diverges from the recorded one — update + the sequencing table and DoD M6/M11 before the start of M1. +- [ ] Read `avito/core/swagger_linter.py::_validate_factory` in full and record + current behavior: which fields of the binding it gates on (`factory`, `factory_args`), + how it resolves the factory on `AvitoClient`, what it considers an error. M1 must extend + it with class-gated coverage (see Swagger section). Without full understanding of the current + logic, the extension risks weakening the invariant for sync bindings. +- [ ] **Run pre-flight locally, record results**: the pre-flight test on + `_operation_specs_for_sdk_method` for an async stub is actually run; the result + (pass/fail) and the chosen fallback (none / primary / secondary) are recorded + in the M1 PR description. Without an actual run, M1 is not opened. + +### M1 — Foundation (1 PR) DoD: -- [ ] `make check` зелёный: test, typecheck (mypy strict), lint (ruff), +- [ ] `make check` green: test, typecheck (mypy strict), lint (ruff), swagger-lint --strict, architecture-lint, async-parity-lint, docstring-lint, build. -- [ ] `make docs-strict` зелёный: M1 правит `STYLEGUIDE.md`, - `swagger-binding-subsystem.md` и `domain-architecture-v2.md` + расширяет - `_gen_reference.py` (см. таблицу «Существующие, изменяются в M1»). Без правки - `STYLEGUIDE.md` план формально противоречит нормативному sync-only тексту. - Без зелёного docs-strict нельзя гарантировать, что reference-builder в M2-PoC - увидит первый `Async`. Если на M1 ещё ни одного `Async` нет — builder - проверяется на нейтральность (sync reference генерится идентично baseline'у). -- [ ] Покрытие тестами фундамента не ниже sync-аналогов (sample проверка по `coverage report`). -- [ ] Smoke-тест: `AsyncAvitoClient` через `AsyncFakeTransport.as_client(authenticated=True)` - (без respx) делает один авторизованный запрос; `/token` реально вызывается - через `AsyncTokenClient`; после 401 кэш сбрасывается и `/token` вызывается - повторно; retry на 429 срабатывает; `Authorization` и `Idempotency-Key` - пробрасываются; `aclose()` корректно закрывает `httpx.AsyncClient` и +- [ ] `make docs-strict` green: M1 edits `STYLEGUIDE.md`, + `swagger-binding-subsystem.md` and `domain-architecture-v2.md` + extends + `_gen_reference.py` (see the table "Existing, modified in M1"). Without editing + `STYLEGUIDE.md`, the plan formally contradicts the normative sync-only text. + Without a green docs-strict, we cannot guarantee that the reference builder in M2-PoC + will see the first `Async`. If at M1 there is not a single `Async` yet — the builder + is verified to be neutral (sync reference is generated identically to baseline). +- [ ] Test coverage of the foundation is no lower than the sync analogs (sample check via `coverage report`). +- [ ] Smoke test: `AsyncAvitoClient` via `AsyncFakeTransport.as_client(authenticated=True)` + (without respx) makes one authorized request; `/token` is actually called + via `AsyncTokenClient`; after 401 the cache is cleared and `/token` is called + again; retry on 429 fires; `Authorization` and `Idempotency-Key` + are propagated; `aclose()` correctly closes `httpx.AsyncClient` and `AsyncAuthProvider`. -- [ ] Ownership test: `AsyncTransport.aclose()` закрывает переданный - `httpx.AsyncClient`, потому что это выбранная mirror-политика текущего sync - `Transport.close()`. Тест отдельно покрывает idempotent double-close. -- [ ] Async auth public surface зеркалит sync: `AsyncAvitoClient.auth()` возвращает - `AsyncAuthProvider`, а `token_flow()` / `alternate_token_flow()` возвращают - async token clients с `variant="async"` bindings. -- [ ] Async client diagnostic/closed contract зеркалит sync: `debug_info()` возвращает - `TransportDebugInfo` после `__aenter__`; `auth()` и `debug_info()` падают до - инициализации понятным `RuntimeError`; после `aclose()` они и будущие factory- - методы падают `ClientClosedError`; повторный `aclose()` no-op. -- [ ] Документация `swagger-binding-subsystem.md` отражает variant и class-gated coverage. -- [ ] `AsyncSwaggerFakeTransport` добавлен и экспортирован из `avito.testing`; async - contract suite зелёный для discovered async bindings (`auth` в M1, домены - появляются позже). -- [ ] Публичная sync-поверхность не изменилась — formal: pass/fail статусы - **только baseline nodeids из `/tmp/baseline_nodeids.txt`** идентичны - baseline-тесту с `main` (см. pre-flight). Новые async tests не участвуют - в сравнении. Любое расхождение по старым nodeid = blocker. -- [ ] Phase 1a (`_merge_headers` рефакторинг) выделен отдельным коммитом внутри PR — для bisect-friendly history. -- [ ] **`pyproject.toml` содержит `asyncio_default_fixture_loop_scope = "function"`** в `[tool.pytest.ini_options]` рядом с `asyncio_mode = "strict"`. На момент M1 `filterwarnings = error` в проекте не настроен, поэтому отсутствие этой опции не сломает pytest сразу, но `pytest-asyncio` 0.23+ начнёт сыпать `PytestDeprecationWarning` на каждом async-тесте — это накапливается в выводе и заблокирует будущее включение `filterwarnings = error`. Включаем превентивно. -- [ ] **`_validate_factory(variant="async")` зелёный для async auth bindings без единого domain-factory на `AsyncAvitoClient`**. Class-gated предикат: factory-check не запускается на async binding, чей класс ещё не имеет `Async` в домене, и пропускает binding'и без `factory` в декораторе. Закрепляется юнит-тестом `tests/core/test_swagger_linter.py::test_validate_factory_async_skips_unported_classes`. -- [ ] **Resolver `_operation_specs_for_sdk_method` для `async_domain.py`**: pre-flight smoke-тест зелёный (резолв через `__globals__` работает с `from ...operations import SOME_SPEC`). Если pre-flight красный — в этом же M1 PR применён primary fallback (AST-резолв из source-файла) **либо** secondary fallback (class-level `__operation_specs__`). Любой fallback зафиксирован в `swagger_linter.py` с тестом `tests/core/test_swagger_linter.py::test_resolve_specs_from_async_domain`. -- [ ] **`AsyncOperationExecutor` retry-резолюция зеркалит sync**: тест `tests/core/test_async_executor.py::test_executor_retry_resolution_matches_sync` параметризован `(retry, spec.retry)` тройкой и сверяет результат с sync `OperationExecutor`. -- [ ] **`AsyncAuthProvider.invalidate_token` sync и идемпотентен**: тест `tests/auth/test_async_provider.py::test_invalidate_token_is_sync_and_idempotent` зелёный. -- [ ] **`httpx.AsyncClient` создаётся с дефолтными limits** (без переопределения). Тест на запрет SDK-side тюнинга limits в M1 не нужен; в DoD M-final есть проверка fan-out ≤ 6. -- [ ] **`AsyncTransport.request()` вызывает `await self._rate_limiter.acquire()` перед каждым httpx-вызовом и `observe_response()` после успешного ответа** — точное зеркало sync `Transport.request()` (строки 148, 183). Закреплено двумя тестами: `tests/core/test_async_transport.py::test_request_acquires_rate_limiter_before_httpx_call` (5 параллельных корутин на один transport — токены тратятся по одному, а не пачкой) и `::test_request_calls_observe_response_after_success` (post-condition). -- [ ] **`_request_binary_async` module-level helper в `avito/core/operations.py`** — async-зеркало sync `_request_binary`. Принимает `AsyncOperationTransport` Protocol, возвращает `BinaryResponse` с теми же полями. Closed-test: `tests/core/test_async_executor.py::test_binary_branch_uses_request_binary_async_helper`. -- [ ] **`AsyncRateLimiter` живёт в `avito/core/_async_rate_limit.py`** (не внутри `async_transport.py`). Симметрично sync `avito/core/rate_limit.py`. -- [ ] **`scripts/lint_async_parity.py` экспортирует `iter_async_classes()` как публичный API** — используется M-final verification скриптом и любым внешним инструментом, нуждающимся в каноническом списке `Async` классов. -- [ ] CHANGELOG `## [Unreleased]` в корневом `CHANGELOG.md` дополнен: +- [ ] Ownership test: `AsyncTransport.aclose()` closes the passed + `httpx.AsyncClient`, because that is the chosen mirror policy of the current sync + `Transport.close()`. The test separately covers idempotent double-close. +- [ ] The async auth public surface mirrors sync: `AsyncAvitoClient.auth()` returns + `AsyncAuthProvider`, and `token_flow()` / `alternate_token_flow()` return + async token clients with `variant="async"` bindings. +- [ ] Async client diagnostic/closed contract mirrors sync: `debug_info()` returns + `TransportDebugInfo` after `__aenter__`; `auth()` and `debug_info()` fail before + initialization with an understandable `RuntimeError`; after `aclose()` they and future factory + methods fail with `ClientClosedError`; repeated `aclose()` is a no-op. +- [ ] The documentation `swagger-binding-subsystem.md` reflects variant and class-gated coverage. +- [ ] `AsyncSwaggerFakeTransport` is added and exported from `avito.testing`; the async + contract suite is green for discovered async bindings (`auth` in M1, domains + appear later). +- [ ] Public sync surface is unchanged — formal: pass/fail statuses + **only of baseline nodeids from `/tmp/baseline_nodeids.txt`** are identical to + the baseline test from `main` (see pre-flight). New async tests do not participate + in the comparison. Any divergence on old nodeids = blocker. +- [ ] Phase 1a (`_merge_headers` refactor) is split out as a separate commit inside the PR — for bisect-friendly history. +- [ ] **`pyproject.toml` contains `asyncio_default_fixture_loop_scope = "function"`** in `[tool.pytest.ini_options]` next to `asyncio_mode = "strict"`. At the time of M1 `filterwarnings = error` is not configured in the project, so the absence of this option will not break pytest immediately, but `pytest-asyncio` 0.23+ will start emitting `PytestDeprecationWarning` on every async test — this accumulates in output and blocks future enabling of `filterwarnings = error`. We enable it preventively. +- [ ] **`_validate_factory(variant="async")` is green for async auth bindings without a single domain factory on `AsyncAvitoClient`**. The class-gated predicate: factory-check is not run on an async binding whose class does not yet have `Async` in the domain, and skips bindings without `factory` in the decorator. Locked in by the unit test `tests/core/test_swagger_linter.py::test_validate_factory_async_skips_unported_classes`. +- [ ] **The resolver `_operation_specs_for_sdk_method` for `async_domain.py`**: the pre-flight smoke test is green (resolution via `__globals__` works with `from ...operations import SOME_SPEC`). If pre-flight is red — in this same M1 PR, the primary fallback (AST resolution from the source file) **or** the secondary fallback (class-level `__operation_specs__`) is applied. Any fallback is locked in `swagger_linter.py` with the test `tests/core/test_swagger_linter.py::test_resolve_specs_from_async_domain`. +- [ ] **`AsyncOperationExecutor` retry resolution mirrors sync**: the test `tests/core/test_async_executor.py::test_executor_retry_resolution_matches_sync` is parameterized with the `(retry, spec.retry)` triple and compares the result with sync `OperationExecutor`. +- [ ] **`AsyncAuthProvider.invalidate_token` is sync and idempotent**: the test `tests/auth/test_async_provider.py::test_invalidate_token_is_sync_and_idempotent` is green. +- [ ] **`httpx.AsyncClient` is created with default limits** (without override). A test forbidding SDK-side tuning of limits is not needed in M1; the M-final DoD has a fan-out ≤ 6 check. +- [ ] **`AsyncTransport.request()` calls `await self._rate_limiter.acquire()` before each httpx call and `observe_response()` after a successful response** — exact mirror of sync `Transport.request()` (lines 148, 183). Locked in by two tests: `tests/core/test_async_transport.py::test_request_acquires_rate_limiter_before_httpx_call` (5 parallel coroutines on one transport — tokens are spent one at a time, not in a batch) and `::test_request_calls_observe_response_after_success` (post-condition). +- [ ] **`_request_binary_async` module-level helper in `avito/core/operations.py`** is an async mirror of sync `_request_binary`. Accepts `AsyncOperationTransport` Protocol, returns `BinaryResponse` with the same fields. Closed-test: `tests/core/test_async_executor.py::test_binary_branch_uses_request_binary_async_helper`. +- [ ] **`AsyncRateLimiter` lives in `avito/core/_async_rate_limit.py`** (not inside `async_transport.py`). Symmetric to sync `avito/core/rate_limit.py`. +- [ ] **`scripts/lint_async_parity.py` exports `iter_async_classes()` as a public API** — used by the M-final verification script and any external tool that needs the canonical list of `Async` classes. +- [ ] CHANGELOG `## [Unreleased]` in the root `CHANGELOG.md` is updated with: `- Фундамент Async API: AsyncTransport, AsyncAuthProvider, AsyncOperationExecutor, AsyncPaginatedList, AsyncAvitoClient (без factory-методов доменов); RateLimitState вынесен в shared`. -### M2-PoC — Proof-of-concept шаблона (отдельный PR, до переработки доменов) +### M2-PoC — Proof-of-concept of the template (a separate PR, before reworking domains) -**Цель этого шага — валидировать шаблон на минимальном домене и при этом закрыть -`tariffs` полностью.** Это не "частичный доменный PR": к merge `tariffs` должен -иметь async-поверхность, тесты, swagger coverage и reference 1:1. PoC может вернуть -feedback вида «контракт `AsyncPaginator` нужно расширить», «discovery не видит -spec», «mypy strict ругается на covariance возврата» — и это нормальный ожидаемый -выход. Все правки контракта вносятся в **этот же PR**, а если правки требуют -переработки M1-фундамента — PoC откатывается, фундамент дорабатывается отдельным -PR, после чего PoC переоткрывается. M3 не начинается, пока M2-PoC не зелёный и -`tariffs` не закрыт на 100%. +**The goal of this step is to validate the template on a minimal domain and at the same time close +`tariffs` completely.** This is not a "partial domain PR": at merge time `tariffs` must +have an async surface, tests, swagger coverage, and reference 1:1. The PoC may return +feedback like "the `AsyncPaginator` contract needs to be extended", "discovery does not see +the spec", "mypy strict complains about return covariance" — and that is a normal expected +outcome. All contract changes are made in **the same PR**, and if the changes require +rework of the M1 foundation, the PoC is rolled back, the foundation is reworked in a separate +PR, after which the PoC is reopened. M3 does not start until M2-PoC is green and +`tariffs` is closed at 100%. -PoC берёт `tariffs` (1 sync-операция с binding) — минимальная поверхность без -пагинации, без autoteka-flow, без write-методов. Этого достаточно, чтобы ткнуть -все слои фундамента в один сценарий end-to-end. +The PoC takes `tariffs` (1 sync operation with binding) — minimal surface without +pagination, without autoteka-flow, without write methods. That is enough to poke +all foundation layers in one end-to-end scenario. DoD M2-PoC: -- [ ] `avito/tariffs/async_domain.py` создан, `AsyncTariff` зеркалит `Tariff` - ровно по 1 публичному методу. -- [ ] `AsyncTariff` содержит class-level metadata, зеркальную `Tariff`: +- [ ] `avito/tariffs/async_domain.py` is created, `AsyncTariff` mirrors `Tariff` + exactly on 1 public method. +- [ ] `AsyncTariff` contains class-level metadata mirroring `Tariff`: `__swagger_domain__ = "tariffs"`, `__sdk_factory__ = "tariff"`, `__sdk_factory_args__ = {"tariff_id": "path.tariff_id"}`. -- [ ] `avito/tariffs/__init__.py` экспортирует `AsyncTariff` рядом с `Tariff`. -- [ ] `AsyncAvitoClient.tariff()` factory-метод возвращает `AsyncTariff`. -- [ ] `tests/domains/tariffs/test_tariffs_async.py` содержит async-двойник sync - golden-path сценария и дополнительные async-риск сценарии: 401, 429, - transport error. Все тесты зелёные. -- [ ] `make check` зелёный, включая `swagger-lint --strict` (для `tariffs` теперь - требуется async-coverage 1:1). -- [ ] `scripts/lint_async_parity.py` зелёный. -- [ ] `tests/contracts/test_async_swagger_contracts.py` зелёный для async auth + +- [ ] `avito/tariffs/__init__.py` exports `AsyncTariff` next to `Tariff`. +- [ ] `AsyncAvitoClient.tariff()` factory method returns `AsyncTariff`. +- [ ] `tests/domains/tariffs/test_tariffs_async.py` contains an async double of the sync + golden-path scenario and additional async-risk scenarios: 401, 429, + transport error. All tests are green. +- [ ] `make check` is green, including `swagger-lint --strict` (for `tariffs` async-coverage + 1:1 is now required). +- [ ] `scripts/lint_async_parity.py` is green. +- [ ] `tests/contracts/test_async_swagger_contracts.py` is green for async auth + `tariffs`. -- [ ] Документация generated reference для `docs/site/reference/domains/tariffs.md` - содержит async-секцию. -- [ ] **`_gen_reference.py` валидируется на реальном домене**: после расширения builder'а в M1 на M2-PoC он впервые видит `AsyncTariff` и должен сгенерировать reference-страницу с обоими классами (`Tariff` + `AsyncTariff`). `make docs-strict` зелёный, в generated `site/reference/domains/tariffs/` или `site/reference/domains/tariffs.html` присутствуют обе секции. Если builder требует доработки — она входит в этот же PR (это и есть смысл PoC). Конкретно в `_gen_reference.py`: `public_domain_packages()` дополнительно возвращает пакет, если есть `*/async_domain.py`; `public_domain_classes()` импортирует `avito..domain` и `avito..async_domain` напрямую, а не только `avito..__all__`; `Async` фильтруется через `cls.__name__.startswith("Async")` + `issubclass(AsyncDomainObject)`; `write_domain_pages()` пишет явные mkdocstrings-директивы для каждого класса в порядке `Tariff` → `AsyncTariff`, а не один общий `::: avito.tariffs`; `EXCLUDED_PACKAGES` остаётся прежним; для `auth` (исключён) async-классы reference не получают. -- [ ] **Lessons learned зафиксированы** в `docs/site/explanations/async-domain-template.md` - (новый файл): шаблон файла `async_domain.py`, чек-лист переноса домена, - найденные подводные камни. Этот документ становится нормативным для M3+. -- [ ] Если в ходе PoC понадобились изменения контракта (`AsyncPaginator`/`AsyncFakeTransport`/ - `swagger_linter`/`AsyncAuthProvider`), они **внесены в этот же PR** или вынесены - в отдельный M1.5-PR, но **до** старта M3. -- [ ] Корневой `CHANGELOG.md` (`## [Unreleased]`) дополнен: +- [ ] The generated reference docs `docs/site/reference/domains/tariffs.md` + contain an async section. +- [ ] **`_gen_reference.py` is validated on a real domain**: after the builder extension in M1, on M2-PoC it sees `AsyncTariff` for the first time and must generate a reference page with both classes (`Tariff` + `AsyncTariff`). `make docs-strict` is green, in the generated `site/reference/domains/tariffs/` or `site/reference/domains/tariffs.html` both sections are present. If the builder requires polish — it is included in the same PR (this is what the PoC is for). Specifically in `_gen_reference.py`: `public_domain_packages()` additionally returns the package if `*/async_domain.py` exists; `public_domain_classes()` imports `avito..domain` and `avito..async_domain` directly, not just `avito..__all__`; `Async` is filtered through `cls.__name__.startswith("Async")` + `issubclass(AsyncDomainObject)`; `write_domain_pages()` writes explicit mkdocstrings directives for each class in the order `Tariff` → `AsyncTariff`, not one shared `::: avito.tariffs`; `EXCLUDED_PACKAGES` remains the same; for `auth` (excluded) async classes do not get a reference. +- [ ] **Lessons learned are recorded** in `docs/site/explanations/async-domain-template.md` + (a new file): the `async_domain.py` file template, a domain port checklist, + pitfalls discovered. This document becomes normative for M3+. +- [ ] If in the course of the PoC contract changes are needed (`AsyncPaginator`/`AsyncFakeTransport`/ + `swagger_linter`/`AsyncAuthProvider`), they are **made in the same PR** or split out + into a separate M1.5-PR, but **before** the start of M3. +- [ ] The root `CHANGELOG.md` (`## [Unreleased]`) is updated with: `- Async-поддержка домена tariffs: AsyncTariff (PoC шаблона)`. -### M3…M12 + M-final — Закрытие доменов (по PR на домен) +### M3…M12 + M-final — Closing domains (one PR per domain) -**Sequencing constraints** — что блокирует что (после зелёного M2-PoC): +**Sequencing constraints** — what blocks what (after a green M2-PoC): -| Этап | Должен идти после | Причина | +| Stage | Must come after | Reason | |---|---|---| -| M3 `ratings` | M2-PoC | базовый шаблон без особенностей; служит вторым sanity-check'ом фундамента | -| M4 `accounts` | M2-PoC, M3 | первый домен с `AsyncPaginatedList` — валидирует пагинацию до M11 | -| M5 `realty` | M2-PoC | без пагинации; параллелен с M3/M6/M7/M8/M9 | -| M6 `cpa` | M2-PoC + async-aware `deprecated_method` уже мерджнут в M1 | 3 deprecated метода в `cpa/domain.py` | -| M7 `messenger` | M2-PoC | без пагинации; параллелен с M3/M5/M6/M8/M9 | -| M8 `jobs` | M2-PoC | webhook-методы (REST), без пагинации; параллелен | -| M9 `promotion` | M2-PoC | без пагинации; параллелен | -| M10 `autoteka` | M2-PoC | autoteka token flow — независимая часть auth | -| M11 `ads` | **M4 (`accounts`)** + async-aware `deprecated_method` из M1 | сложный `Ad.list` first-page reuse тестируется после простого `AsyncPaginatedList`; 4 deprecated метода в `ads/domain.py` | -| M12 `orders` | M2-PoC | независимый; идемпотентность критична, но не блокируется другим доменом | -| M-final | **все M3…M12 + M10** | `AsyncAvitoClient.account_health` собирает все домены; `_safe_summary_async` симметричен sync `_safe_summary`; M10 обязателен для autoteka concurrent first-touch теста (см. таблицу M3…M12 ниже) | - -**Параллельность**: после M2-PoC можно открывать M3, M5, M6, M7, M8, M9, M10, M12 в -любом порядке (включая параллельно). M4 — обязательный гейт перед M11. M-final — -последним. Cumulative parity invariant (см. DoD M3…M12) гарантирует, что merge -порядок параллельных PR'ов не имеет значения: каждый merge оставляет линтер зелёным -для всех уже портированных доменов. - -Порядок в таблице ниже (нарастающая сложность; самый простой шёл в PoC): - -| # | Домен | Sync-методов с binding | Особенности | +| M3 `ratings` | M2-PoC | basic template without specifics; serves as the second sanity check of the foundation | +| M4 `accounts` | M2-PoC, M3 | first domain with `AsyncPaginatedList` — validates pagination before M11 | +| M5 `realty` | M2-PoC | no pagination; parallel with M3/M6/M7/M8/M9 | +| M6 `cpa` | M2-PoC + async-aware `deprecated_method` already merged in M1 | 3 deprecated methods in `cpa/domain.py` | +| M7 `messenger` | M2-PoC | no pagination; parallel with M3/M5/M6/M8/M9 | +| M8 `jobs` | M2-PoC | webhook methods (REST), no pagination; parallel | +| M9 `promotion` | M2-PoC | no pagination; parallel | +| M10 `autoteka` | M2-PoC | autoteka token flow — independent part of auth | +| M11 `ads` | **M4 (`accounts`)** + async-aware `deprecated_method` from M1 | the complex `Ad.list` first-page reuse is tested after the simple `AsyncPaginatedList`; 4 deprecated methods in `ads/domain.py` | +| M12 `orders` | M2-PoC | independent; idempotency is critical, but is not blocked by another domain | +| M-final | **all M3…M12 + M10** | `AsyncAvitoClient.account_health` aggregates all domains; `_safe_summary_async` is symmetric to sync `_safe_summary`; M10 is mandatory for the autoteka concurrent first-touch test (see the M3…M12 table below) | + +**Parallelism**: after M2-PoC you can open M3, M5, M6, M7, M8, M9, M10, M12 in +any order (including in parallel). M4 is a mandatory gate before M11. M-final is +last. The cumulative parity invariant (see DoD M3…M12) guarantees that the merge +order of parallel PRs does not matter: each merge leaves the linter green +for all already ported domains. + +The order in the table below (increasing complexity; the simplest went into the PoC): + +| # | Domain | Sync methods with binding | Specifics | |---|---|---|---| -| M3 | `ratings` | 4 | без пагинации | -| M4 | `accounts` | 8 | первая `AsyncPaginatedList` (`get_operations_history`, `list_items_by_employee`); async `_resolve_account_user_id` | -| M5 | `realty` | 7 | без пагинации | -| M6 | `cpa` | 14 | без пагинации | -| M7 | `messenger` | 18 | без пагинации | -| M8 | `jobs` | 25 | webhook-методы (REST) | -| M9 | `promotion` | 24 | без пагинации | -| M10 | `autoteka` | 26 | использует autoteka token flow → end-to-end проверка `AsyncAuthProvider.get_autoteka_access_token` + `_autoteka_refresh_lock` под нагрузкой: **20 одновременных корутин** в `asyncio.gather(...)` стартуют первый `get_autoteka_access_token()`; counter мокированного `/token` route после `await gather(...)` обязан быть **ровно 1**. Закрепляется тестом `tests/auth/test_async_provider.py::test_autoteka_concurrent_first_touch_single_token_request`. | -| M11 | `ads` | 28 | вторая и третья `AsyncPaginatedList` (`Ad.list`, `AutoloadReport.list`); сложный offset/limit first-page reuse в `Ad.list` (`avito/ads/domain.py:266`) | -| M12 | `orders` | 45 | самый большой; идемпотентность критична | -| M-final | — | — | convenience-методы `AsyncAvitoClient`: `account_health`, `listing_health` и `promotion_summary` (при `item_ids`) используют `asyncio.TaskGroup` только там, где все ветки **required-only** и фактически независимы; `review_summary` остаётся sequential reviews-then-rating (mixed required+optional, см. блок «Важная тонкость TaskGroup»); `business_summary` делегирует в `account_health`; `chat_summary`/`order_summary` остаются sequential leaf; `capabilities` остаётся CPU-only без сетевых probe-запросов. `asyncio.gather(return_exceptions=True)` запрещён. Fan-out агрегаторов ≤ 6 задач in-flight. Финальный hardening; `docs/site/how-to/async.md`; CHANGELOG `## [Unreleased]` → `## [2.1.0]` (свод накопленных пунктов из M1…M12 + запись про convenience-методы). | - -Содержимое каждого M3…M12: - -1. `avito//async_domain.py` с `Async(AsyncDomainObject)` для **каждого** - sync-`` в домене. Импортирует те же `OperationSpec` из - `avito//operations.py` **явно по именам** - (`from avito..operations import LIST_SPEC, GET_SPEC, ...`) — иначе - `_operation_specs_for_sdk_method` не сможет резолвнуть spec через `__globals__` - и swagger-lint выдаст `SWAGGER_OPERATION_SPEC_MISSING`. -2. **Каждый** `Async` содержит class-level metadata, зеркальную sync-классу: - `__swagger_domain__`, `__sdk_factory__`, `__sdk_factory_args__`. Metadata не - считается «дублированием» Swagger-контракта: это SDK discovery/factory metadata, - без которого async-класс может не попасть в discovery/reference или получить - зелёный decorator при отсутствующем factory. -3. **Каждый** публичный метод декорируется `@swagger_operation(..., variant="async")` - теми же аргументами `(method, path, spec, operation_id, factory, factory_args, - method_args, deprecated, legacy)`, что и sync. -4. `avito//__init__.py` экспортирует **все** `Async` класса домена рядом - с sync-классами, чтобы mkdocstrings, IDE и generated reference видели публичную - async-поверхность. -5. Регистрация **всех** `Async` домена в `AsyncAvitoClient` (factory-методы по - именам, идентичным sync). -6. `tests/domains//test__async.py` — зеркало - `tests/domains//test_.py`, через `AsyncFakeTransport`. Тесты - помечаем `@pytest.mark.asyncio`. **Каждый** sync-тест имеет async-двойник - с тем же сценарием. -7. Если в домене есть пагинация — соответствующие методы возвращают - `AsyncPaginatedList[T]` (зеркально sync `PaginatedList[T]`). M4 `accounts` — - первый домен с `AsyncPaginatedList`; M11 `ads` проверяет сложный first-page - reuse в `Ad.list`. -8. Generated reference `docs/site/reference/domains/.md` дополняется - async-секцией (или второй колонкой). -9. Если в домене есть write-методы с `dry_run` — async-двойник реализует тот же - контракт: при `dry_run=True` транспорт **не вызывается** (тест проверяет +| M3 | `ratings` | 4 | no pagination | +| M4 | `accounts` | 8 | first `AsyncPaginatedList` (`get_operations_history`, `list_items_by_employee`); async `_resolve_account_user_id` | +| M5 | `realty` | 7 | no pagination | +| M6 | `cpa` | 14 | no pagination | +| M7 | `messenger` | 18 | no pagination | +| M8 | `jobs` | 25 | webhook methods (REST) | +| M9 | `promotion` | 24 | no pagination | +| M10 | `autoteka` | 26 | uses autoteka token flow → end-to-end check of `AsyncAuthProvider.get_autoteka_access_token` + `_autoteka_refresh_lock` under load: **20 concurrent coroutines** in `asyncio.gather(...)` start the first `get_autoteka_access_token()`; the counter of the mocked `/token` route after `await gather(...)` must be **exactly 1**. Locked in by the test `tests/auth/test_async_provider.py::test_autoteka_concurrent_first_touch_single_token_request`. | +| M11 | `ads` | 28 | second and third `AsyncPaginatedList` (`Ad.list`, `AutoloadReport.list`); complex offset/limit first-page reuse in `Ad.list` (`avito/ads/domain.py:266`) | +| M12 | `orders` | 45 | the largest; idempotency is critical | +| M-final | — | — | convenience methods of `AsyncAvitoClient`: `account_health`, `listing_health`, and `promotion_summary` (when `item_ids` is given) use `asyncio.TaskGroup` only where all branches are **required-only** and actually independent; `review_summary` remains sequential reviews-then-rating (mixed required+optional, see the "Important TaskGroup subtlety" block); `business_summary` delegates to `account_health`; `chat_summary`/`order_summary` remain sequential leaves; `capabilities` remains CPU-only without network probe requests. `asyncio.gather(return_exceptions=True)` is forbidden. Aggregator fan-out ≤ 6 in-flight tasks. Final hardening; `docs/site/how-to/async.md`; CHANGELOG `## [Unreleased]` → `## [2.1.0]` (a roundup of accumulated entries from M1…M12 + a record of convenience methods). | + +Contents of each M3…M12: + +1. `avito//async_domain.py` with `Async(AsyncDomainObject)` for **every** + sync `` in the domain. Imports the same `OperationSpec` from + `avito//operations.py` **explicitly by name** + (`from avito..operations import LIST_SPEC, GET_SPEC, ...`) — otherwise + `_operation_specs_for_sdk_method` will not be able to resolve the spec via `__globals__` + and swagger-lint will emit `SWAGGER_OPERATION_SPEC_MISSING`. +2. **Every** `Async` contains class-level metadata mirroring the sync class: + `__swagger_domain__`, `__sdk_factory__`, `__sdk_factory_args__`. The metadata is not + considered "duplication" of the Swagger contract: this is SDK discovery/factory metadata + without which the async class may not enter discovery/reference or may receive + a green decorator with a missing factory. +3. **Every** public method is decorated with `@swagger_operation(..., variant="async")` + with the same arguments `(method, path, spec, operation_id, factory, factory_args, + method_args, deprecated, legacy)` as sync. +4. `avito//__init__.py` exports **all** `Async` of the domain next to + sync classes, so that mkdocstrings, the IDE, and the generated reference see the public + async surface. +5. Registration of **all** `Async` of the domain in `AsyncAvitoClient` (factory methods by + names identical to sync). +6. `tests/domains//test__async.py` is a mirror of + `tests/domains//test_.py` via `AsyncFakeTransport`. Tests are + marked with `@pytest.mark.asyncio`. **Every** sync test has an async double + with the same scenario. +7. If the domain has pagination — the corresponding methods return + `AsyncPaginatedList[T]` (mirroring sync `PaginatedList[T]`). M4 `accounts` is + the first domain with `AsyncPaginatedList`; M11 `ads` validates the complex first-page + reuse in `Ad.list`. +8. The generated reference `docs/site/reference/domains/.md` is augmented with + an async section (or a second column). +9. If the domain has write methods with `dry_run` — the async double implements the same + contract: when `dry_run=True` the transport is **not called** (the test verifies `count(method=..., path=...) == 0`). -10. Если в домене есть idempotency-key поведение — async-тесты явно проверяют - проброс заголовка `Idempotency-Key`. +10. If the domain has idempotency-key behavior — async tests explicitly verify + propagation of the `Idempotency-Key` header. -### Definition of done каждого M3…M12 — закрыть домен на 100%, без работы на потом +### Definition of done for each M3…M12 — close the domain at 100%, no work left over -«100%» определяется проверяемо. Все пункты ниже — **обязательные**, не «nice to have»: +"100%" is defined verifiably. All items below are **mandatory**, not "nice to have": -- [ ] **Покрытие методов 1:1**: для каждого публичного sync-метода домена есть - async-двойник; `scripts/lint_async_parity.py` зелёный для домена. - Локальная проверка: `python -c "from avito..domain import *; from +- [ ] **Method coverage 1:1**: for each public sync method of the domain there is an + async double; `scripts/lint_async_parity.py` is green for the domain. + Local check: `python -c "from avito..domain import *; from avito..async_domain import *"` + `scripts/lint_async_parity.py` - без allowlist/skip для текущего домена. -- [ ] **Покрытие тестов сценарий-в-сценарий**: каждый сценарий из - `tests/domains//test_.py` имеет async-двойник с тем же - бизнес-смыслом. Дополнительные async-тесты разрешены и обязательны там, - где закрывают async-специфичные риски (401 refresh через async auth, + without allowlist/skip for the current domain. +- [ ] **Test coverage scenario-by-scenario**: every scenario from + `tests/domains//test_.py` has an async double with the same + business meaning. Additional async tests are allowed and required where + they cover async-specific risks (401 refresh via async auth, cancellation, concurrent pagination/fake transport, async rate limiter). - Счётчики тестов не обязаны быть равны; async-count должен быть **не меньше** - sync-count, а PR description содержит короткую mapping-таблицу - `sync test -> async test`. Покрываются: golden path, 401, - 403, 422, 429, transport error/timeout, пагинация (если есть), idempotency - (для write), `dry_run` (если есть в sync). -- [ ] **Swagger-lint coverage 1:1 для домена**: `swagger-lint --strict` после этапа - требует async binding для **каждой** swagger-операции этого домена; class-gated - coverage гейт включён, и domain больше не «пуст по async». Никаких - исключений/skip'ов для отдельных методов. + The test counts do not have to be equal; the async count must be **no less** + than sync count, and the PR description contains a short mapping table + `sync test -> async test`. Covered: golden path, 401, + 403, 422, 429, transport error/timeout, pagination (if any), idempotency + (for write), `dry_run` (if there is one in sync). +- [ ] **Swagger-lint coverage 1:1 for the domain**: `swagger-lint --strict` after the stage + requires an async binding for **every** swagger operation of this domain; class-gated + coverage gating is enabled, and the domain is no longer "empty by async". No + exceptions/skips for individual methods. - [ ] **Async Swagger contract coverage**: `tests/contracts/test_async_swagger_contracts.py` - вызывает **каждый** async binding домена через `AsyncSwaggerFakeTransport` и - валидирует request/response/error contract. Это обязательный Swagger-spec - compliance test, поэтому он разрешён STYLEGUIDE. -- [ ] **Документация**: generated `docs/site/reference/domains/.md` содержит async-секцию для - **всех** портированных классов; `make docs-strict` зелёный; ссылки и примеры - кода скомпилированы. -- [ ] **Никаких TODO/FIXME/`pytest.skip`/`xfail` в добавленных файлах**: + calls **every** async binding of the domain via `AsyncSwaggerFakeTransport` and + validates the request/response/error contract. This is a mandatory Swagger-spec + compliance test, so it is allowed by the STYLEGUIDE. +- [ ] **Documentation**: the generated `docs/site/reference/domains/.md` contains an async section for + **all** ported classes; `make docs-strict` is green; links and code + examples compile. +- [ ] **No TODOs/FIXMEs/`pytest.skip`/`xfail` in added files**: `git diff main..HEAD -- avito// tests/domains// | grep -E - "TODO|FIXME|@pytest.mark.skip|xfail"` пуст. Любая отсрочка работы = blocker. -- [ ] **Сообщения ошибок только на русском** (STYLEGUIDE.md, секция «Errors»): - все новые `raise ("...")` в `async_domain.py` пишутся по-русски, - без английских вкраплений. Code review checklist; `make lint` напрямую этого - не ловит, но смешанные языки — формальный blocker. Если sync-аналог уже - использует английский (legacy) — оставляем как есть в sync, а в async - пишем по-русски и заводим отдельный issue на миграцию sync. -- [ ] **`make check` локально и в CI зелёный**. -- [ ] **AsyncAvitoClient полностью настроен для домена**: factory-методы возвращают - готовые объекты, lifecycle (`aclose`/`__aexit__`) корректно закрывает все - ресурсы домена. -- [ ] **Регрессия sync = 0**: список pass/fail sync-тестов идентичен предыдущему - этапу (sanity-проверка через сравнение `pytest -q --tb=no` до и после). -- [ ] **Cumulative parity invariant**: после merge'а `scripts/lint_async_parity.py` - и `tests/contracts/test_async_swagger_contracts.py` зелёные для **всех** уже - портированных доменов (включая текущий). Этап не может ослабить инвариант - для предыдущих доменов. -- [ ] **Нет работы «потом»**: переоткрытие PR с фразой «допилю в следующем PR» - запрещено. Если scope не закрывается — PR разделяется или раздвигается, но - не оставляется частичный домен в main. -- [ ] **CHANGELOG обновлён**: в корневом `CHANGELOG.md` (раздел `## [Unreleased]`) - добавлена строка вида `- Async-поддержка домена : Async, Async - (#)` **строго в раздел `## [Unreleased]`**, а не в `## [2.1.0]` - (раздел 2.1.0 ещё не существует на этих PR). Шаблон записи на каждый M3…M12 PR: + "TODO|FIXME|@pytest.mark.skip|xfail"` is empty. Any deferral of work = blocker. +- [ ] **Error messages in Russian only** (STYLEGUIDE.md, "Errors" section): + all new `raise ("...")` in `async_domain.py` are written in Russian, + without English inclusions. Code review checklist; `make lint` does not catch this directly, + but mixed languages are a formal blocker. If the sync analog already + uses English (legacy) — leave it as is in sync, and in async + write in Russian and open a separate issue for sync migration. +- [ ] **`make check` is green locally and in CI**. +- [ ] **AsyncAvitoClient is fully configured for the domain**: factory methods return + ready objects, lifecycle (`aclose`/`__aexit__`) correctly closes all + domain resources. +- [ ] **Sync regression = 0**: the list of pass/fail of sync tests is identical to the previous + stage (sanity check via comparing `pytest -q --tb=no` before and after). +- [ ] **Cumulative parity invariant**: after the merge `scripts/lint_async_parity.py` + and `tests/contracts/test_async_swagger_contracts.py` are green for **all** already + ported domains (including the current one). The stage cannot weaken the invariant + for previous domains. +- [ ] **No work "later"**: reopening a PR with the phrase "I'll finish it in the next PR" + is forbidden. If scope does not close — the PR is split or expanded, but + no partial domain is left in main. +- [ ] **CHANGELOG is updated**: in the root `CHANGELOG.md` (section `## [Unreleased]`) + a line is added of the form `- Async-поддержка домена : Async, Async + (#)` **strictly in the section `## [Unreleased]`**, not in `## [2.1.0]` + (the 2.1.0 section does not yet exist on these PRs). Entry template per M3…M12 PR: ```markdown ## [Unreleased] ### Added - Async-поддержка домена : Async, Async (#) ``` - M-final сводит накопленные `Unreleased`-строки в релиз 2.1.0, добавляя только - запись про convenience-методы и `AsyncAvitoClient`-агрегаторы. Без этого - history-readers не увидят, в каком PR домен стал async, и release notes 2.1.0 - не получится собрать механически. - -### Definition of done M-final — релиз 2.1.0 - -«Финальный hardening» определяется проверяемо: - -- [ ] **Convenience-методы реализованы по таблице классификации** (агрегатор / алиас / leaf / CPU-only). Code review проверяет: `asyncio.TaskGroup` стоит только в ветках с фактически независимыми сетевыми вызовами (`account_health`, `listing_health`, `review_summary`, `promotion_summary` при наличии `item_ids`); в `business_summary` — `return await self.account_health(...)` без `TaskGroup`; `chat_summary` и `order_summary` sequential; `capabilities` не делает сетевых probe-запросов и не использует `TaskGroup`. Любое нарушение = blocker. -- [ ] **`_safe_summary_async` живёт в одном модуле с sync `_safe_summary`** — `avito/client.py` (вынесение в общий `avito/summary/_helpers.py` допускается, но требует одновременного переноса sync `_safe_summary`; частичное вынесение запрещено, чтобы не разделять симметричные хелперы по разным файлам). Импорт в `avito/async_client.py` явный (`from avito.client import _safe_summary, _safe_summary_async`). Циркулярность не возникает: `avito/client.py` не импортирует `avito/async_client.py`, поэтому import-граф остаётся ацикличным; проверяется командой `python -c "import avito.async_client"` без ошибок и `python -c "import avito.client"` без ошибок. -- [ ] **Версия пакета поднята до 2.1.0**: `poetry version 2.1.0`, изменение в `pyproject.toml` зафиксировано в M-final PR. CHANGELOG `## [Unreleased]` → `## [2.1.0] - YYYY-MM-DD`, накопленные строки M1…M12 + запись про convenience-методы и `AsyncAvitoClient`-агрегаторы сведены в один раздел. `git tag v2.1.0` ставится после merge M-final. -- [ ] **`AsyncSwaggerFakeTransport` contract suite полный**: `tests/contracts/test_async_swagger_contracts.py` - вызывает все async bindings (204 Swagger operations, включая auth-bindings) - и проверяет success/error/request-body schema, как sync contract suite. -- [ ] **`docs/site/how-to/async.md` написан**: контракт lifecycle (`async with` обязателен), пример с `AsyncFakeTransport`, миграционный гайд «как переписать sync-вызов на async», ограничения (`AsyncPaginatedList` не list-API, full-buffer download, нет streaming). Ссылки из `docs/site/index.md` и `docs/site/how-to/index.md`. -- [ ] **README/site wording обновлены**: `README.md`, `mkdocs.yml`, `docs/site/index.md`, + M-final aggregates the accumulated `Unreleased` lines into release 2.1.0, adding only + the entry about convenience methods and `AsyncAvitoClient` aggregators. Without this, + history readers will not see in which PR the domain became async, and 2.1.0 release notes + cannot be assembled mechanically. + +### Definition of done for M-final — release 2.1.0 + +"Final hardening" is defined verifiably: + +- [ ] **Convenience methods are implemented per the classification table** (aggregator / alias / leaf / CPU-only). Code review verifies: `asyncio.TaskGroup` is placed only in branches with actually independent network calls (`account_health`, `listing_health`, `review_summary`, `promotion_summary` when `item_ids` is given); in `business_summary` — `return await self.account_health(...)` without `TaskGroup`; `chat_summary` and `order_summary` are sequential; `capabilities` does not make network probe requests and does not use `TaskGroup`. Any violation = blocker. +- [ ] **`_safe_summary_async` lives in the same module as sync `_safe_summary`** — `avito/client.py` (extraction into a shared `avito/summary/_helpers.py` is allowed, but requires simultaneous moving of sync `_safe_summary`; partial extraction is forbidden, so as not to split symmetric helpers across different files). The import in `avito/async_client.py` is explicit (`from avito.client import _safe_summary, _safe_summary_async`). Circularity does not arise: `avito/client.py` does not import `avito/async_client.py`, so the import graph remains acyclic; verified by the command `python -c "import avito.async_client"` without errors and `python -c "import avito.client"` without errors. +- [ ] **The package version is bumped to 2.1.0**: `poetry version 2.1.0`, the change in `pyproject.toml` is recorded in the M-final PR. CHANGELOG `## [Unreleased]` → `## [2.1.0] - YYYY-MM-DD`, the accumulated lines M1…M12 + the entry about convenience methods and `AsyncAvitoClient` aggregators are aggregated into one section. `git tag v2.1.0` is set after merging M-final. +- [ ] **`AsyncSwaggerFakeTransport` contract suite is complete**: `tests/contracts/test_async_swagger_contracts.py` + calls all async bindings (204 Swagger operations, including auth bindings) + and checks success/error/request-body schema, like the sync contract suite. +- [ ] **`docs/site/how-to/async.md` is written**: lifecycle contract (`async with` is mandatory), an example with `AsyncFakeTransport`, a migration guide "how to rewrite a sync call to async", limitations (`AsyncPaginatedList` not list-API, full-buffer download, no streaming). Links from `docs/site/index.md` and `docs/site/how-to/index.md`. +- [ ] **README/site wording is updated**: `README.md`, `mkdocs.yml`, `docs/site/index.md`, `docs/site/reference/client.md`, `docs/site/reference/pagination.md`, - `docs/site/reference/testing.md` больше не называют SDK только синхронным. -- [ ] **`make check` + `make docs-strict` зелёные**; `scripts/lint_async_parity.py` - и `tests/contracts/test_async_swagger_contracts.py` зелёные для всех 11 API-доменов - + auth-bindings. -- [ ] **Cumulative coverage**: после M-final swagger-lint --strict требует обоюдное 1:1 (sync + async) для всех 204 операций. Любой пропуск = blocker; никаких «допилим в 2.1.1». -- [ ] **CHANGELOG release-ready**: запись 2.1.0 содержит: фундамент Async API, по строке на каждый портированный домен (агрегируется из `## [Unreleased]`-записей M1…M12), convenience-методы `AsyncAvitoClient`. Release notes 2.1.0 собираются механически — это и есть проверка дисциплины M3…M12. + `docs/site/reference/testing.md` no longer call the SDK only synchronous. +- [ ] **`make check` + `make docs-strict` are green**; `scripts/lint_async_parity.py` + and `tests/contracts/test_async_swagger_contracts.py` are green for all 11 API domains + + auth bindings. +- [ ] **Cumulative coverage**: after M-final swagger-lint --strict requires a mutual 1:1 (sync + async) for all 204 operations. Any miss = blocker; no "we'll finish in 2.1.1". +- [ ] **CHANGELOG release-ready**: the 2.1.0 entry contains: the Async API foundation, one line per ported domain (aggregated from `## [Unreleased]` entries of M1…M12), `AsyncAvitoClient` convenience methods. 2.1.0 release notes are assembled mechanically — that is the discipline check of M3…M12. -## Верификация (как проверить, что план сработал) +## Verification (how to check that the plan worked) ### M1 ```bash poetry install -make test # sync + новые async unit-тесты -make typecheck # mypy strict — все Awaitable[T], AsyncPaginatedList[T] корректны +make test # sync + new async unit tests +make typecheck # mypy strict — all Awaitable[T], AsyncPaginatedList[T] are correct make lint # ruff -make swagger-lint # sync 1:1; async auth 1:1, domain expected пуст -make async-parity-lint # static Async ↔ X checks, не pytest -make check # финальный гейт +make swagger-lint # sync 1:1; async auth 1:1, domain expected is empty +make async-parity-lint # static Async ↔ X checks, not pytest +make check # final gate poetry run pytest tests/core/test_async_transport.py tests/core/test_async_pagination.py \ tests/core/test_async_executor.py tests/core/test_async_client_lifecycle.py \ tests/auth/test_async_provider.py tests/contracts/test_async_swagger_contracts.py ``` -Ручной smoke (M1, в тесте — не на проде; через `AsyncFakeTransport`, без `respx`): +Manual smoke (M1, in a test — not on production; via `AsyncFakeTransport`, without `respx`): ```python import asyncio from avito.testing.async_fake_transport import AsyncFakeTransport @@ -1420,40 +1419,40 @@ async def main(): asyncio.run(main()) ``` -`AsyncFakeTransport` строится на `httpx.MockTransport(self._handle)` поверх -`httpx.AsyncClient` — это уже самодостаточный механизм перехвата; `respx` поверх него -избыточен. Использовать `respx` стоит только если в smoke нужен уникальный матчер, -которого `add_json`/`add` не покрывает (на текущем этапе таких нет). +`AsyncFakeTransport` is built on `httpx.MockTransport(self._handle)` over +`httpx.AsyncClient` — that is already a self-sufficient interception mechanism; `respx` on top of it +is redundant. `respx` is worth using only if a smoke needs a unique matcher +that `add_json`/`add` does not cover (none such at the current stage). ### M2-PoC (proof-of-concept) ```bash -poetry run pytest tests/domains/tariffs/ # sync + async для tariffs -make async-parity-lint # parity для tariffs как static lint +poetry run pytest tests/domains/tariffs/ # sync + async for tariffs +make async-parity-lint # parity for tariffs as a static lint poetry run pytest tests/contracts/test_async_swagger_contracts.py -make swagger-lint # async-coverage 1:1 для tariffs +make swagger-lint # async-coverage 1:1 for tariffs make check -# Артефакт: docs/site/explanations/async-domain-template.md создан +# Artifact: docs/site/explanations/async-domain-template.md is created ``` -### Каждый M3…M12 (закрытие домена на 100%) +### Each M3…M12 (closing the domain at 100%) ```bash # Sync regression baseline (sanity) poetry run pytest -q --tb=no tests/domains//test_.py > /tmp/sync_before.txt -# После применения изменений: +# After applying changes: poetry run pytest tests/domains// # sync + async poetry run pytest -q --tb=no tests/domains//test_.py > /tmp/sync_after.txt -diff /tmp/sync_before.txt /tmp/sync_after.txt # должен быть пустой +diff /tmp/sync_before.txt /tmp/sync_after.txt # must be empty -make async-parity-lint # parity для всех закрытых доменов +make async-parity-lint # parity for all closed domains poetry run pytest tests/contracts/test_async_swagger_contracts.py -make swagger-lint # async-coverage 1:1 для этого домена +make swagger-lint # async-coverage 1:1 for this domain -# Грязные следы — пустой выхлоп +# Dirty traces — empty output git diff main..HEAD -- avito// tests/domains// \ | grep -E "TODO|FIXME|@pytest.mark.skip|xfail" || echo "OK: no leftover work" -# Cumulative счётчики (async-тестов не меньше sync; mapping сценариев в PR description) +# Cumulative counters (async tests no fewer than sync; scenario mapping in the PR description) sync_count=$(poetry run pytest --collect-only -q tests/domains//test_.py | grep -c "::test_") async_count=$(poetry run pytest --collect-only -q tests/domains//test__async.py | grep -c "::test_") test "$async_count" -ge "$sync_count" && echo "OK: async $async_count >= sync $sync_count" @@ -1466,17 +1465,17 @@ make docs-strict ```bash make check make docs-strict -poetry run pytest # полный набор +poetry run pytest # full set -# Версия и release notes -poetry version 2.1.0 # бамп до 2.1.0 -grep -E "^## \[2\.1\.0\]" CHANGELOG.md # секция 2.1.0 существует -grep -E "^## \[Unreleased\]" CHANGELOG.md # Unreleased пуст или содержит только заголовок +# Version and release notes +poetry version 2.1.0 # bump to 2.1.0 +grep -E "^## \[2\.1\.0\]" CHANGELOG.md # the 2.1.0 section exists +grep -E "^## \[Unreleased\]" CHANGELOG.md # Unreleased is empty or contains only the heading -# Reference после билда содержит обе поверхности на каждом домене. -# Список Async классов получаем динамически из parity-linter (тот же источник -# истины, что используется в make async-parity-lint), а не хардкодим — иначе -# любое добавление/переименование класса требует ручной правки скрипта. +# After build, the reference contains both surfaces in each domain. +# We get the list of Async classes dynamically from the parity linter (the same source +# of truth used in make async-parity-lint), and do not hardcode — otherwise +# any addition/rename of a class requires manual editing of the script. poetry run mkdocs build --strict 2>&1 | tee /tmp/mkdocs.log poetry run python -c " from scripts.lint_async_parity import iter_async_classes @@ -1487,75 +1486,75 @@ while IFS= read -r cls; do grep -R -q "$cls" site/reference/domains || echo "MISSING async section: $cls" done < /tmp/async_class_names.txt -# После merge +# After merge git tag v2.1.0 git push --tags ``` -После M-final: -- swagger-lint --strict требует обоюдное 1:1 покрытие (sync + async) для всех 11 API-доменов и - auth-bindings; -- `scripts/lint_async_parity.py` и `tests/contracts/test_async_swagger_contracts.py` - зелёные для всех доменов; -- `pyproject.toml` версия = 2.1.0; корневой `CHANGELOG.md` содержит `## [2.1.0]` с агрегированной - историей M1…M12 + convenience-методы; -- `docs/site/reference/domains//` для каждого домена показывает обе классовые - поверхности (sync + async); -- релиз 2.1.0 с CHANGELOG: «двухрежимный SDK, AsyncAvitoClient». +After M-final: +- swagger-lint --strict requires mutual 1:1 coverage (sync + async) for all 11 API domains and + auth bindings; +- `scripts/lint_async_parity.py` and `tests/contracts/test_async_swagger_contracts.py` + are green for all domains; +- `pyproject.toml` version = 2.1.0; the root `CHANGELOG.md` contains `## [2.1.0]` with an aggregated + history of M1…M12 + convenience methods; +- `docs/site/reference/domains//` for each domain shows both class + surfaces (sync + async); +- 2.1.0 release with CHANGELOG: "dual-mode SDK, AsyncAvitoClient". -## Риски и mitigations +## Risks and mitigations -| Риск | Mitigation | +| Risk | Mitigation | |---|---| -| Расхождение retry/auth-логики sync vs async | Вся не-IO логика — в `_transport_shared.py` и `_cache.py`, обе обёртки делегируют. | -| `RateLimiter` неприменим к async (sleep + `threading.Lock` запечены в `acquire()`) | Декомпозиция в три части: pure `RateLimitState.compute_delay()` в shared (без sleep, без lock), sync `RateLimiter` поверх (`threading.Lock` + `time.sleep`), отдельный `AsyncRateLimiter` (`asyncio.Lock` + `await asyncio.sleep`). State **не** делится между режимами — sync и async транспорты независимы. | -| `_resolve_user_id` в async расходится с sync fallback-порядком | Async-двойник повторяет текущий sync helper: argument → `settings.user_id` → raw `/core/v1/accounts/self` через transport. Публичный Swagger binding `/core/v1/accounts/self` покрывается `AsyncAccount.get_self()`, не internal helper-ом. | -| `download_binary` в async может неявно стать streaming, расходясь с sync | M1 фиксирует full-buffer-семантику (`await response.aread()`), как sync. Streaming — отдельный API после 2.1.0 с симметричным sync-аналогом. Закреплено тестом `test_download_binary_full_buffer_matches_sync`. | -| Convenience-метод М-final реализован как «sync с обмазанным await» (потеря параллелизма) ИЛИ leaf/CPU-only метод обёрнут в ненужный `TaskGroup` | DoD M-final проверяет классификацию по фактическому sync-коду: `TaskGroup` только для независимых сетевых веток (`account_health`, `listing_health`, `review_summary`, `promotion_summary` при `item_ids`); `business_summary` — алиас; `chat_summary`/`order_summary` — sequential; `capabilities` — CPU-only без network probes. | -| Class-gated swagger-coverage применён per-domain → большой домен (`ads`) нельзя разбить, либо мини-домен с двумя классами требует доделки до merge'а | Class-gated применяется **per-class**: `Async` существует ↔ все операции класса `` обязаны иметь async-binding. Отсутствие `Async` в том же домене не блокирует мердж класса `Async`. DoD M3…M12 всё равно требует домен закрыть на 100%. | -| `from_env` инициализирует loop-зависимые ресурсы вне loop'а → cross-loop UB | `from_env` синхронен, SDK-managed ресурсы (`httpx.AsyncClient`, `asyncio.Lock`) создаются в `__aenter__`. Если внешний `http_client` передан пользователем, transport связывается с ним только в `__aenter__`. Доступ к `transport`/`auth_provider` до `__aenter__` бросает `RuntimeError` с понятным сообщением. Закреплено тестом `test_access_before_aenter_raises`. | -| `AsyncAvitoClient` реализует только domain factories и забывает публичный diagnostic/closed contract sync-клиента | M1 включает `auth()`, `debug_info()`, `_ensure_open()`, `_require_transport()`, `ClientClosedError` после `aclose()` и проверку `AsyncAvitoClient.debug_info()` в `_gen_reference.py.ensure_debug_info_exists()`. | -| Release notes 2.1.0 невозможно собрать механически, потому что в PR M3…M12 нет CHANGELOG-записей | DoD M3…M12 требует `## [Unreleased]` строку в корневом `CHANGELOG.md` на каждый PR. M-final сводит накопленное в `## [2.1.0]`. | -| `_merge_headers` срытно делает sync IO (`get_access_token()`) | Phase 1a первым шагом рефакторит контракт: helper принимает уже резолвнутый `bearer_token: str | None`. Без этого shared слой не IO-agnostic, и vary-логика расползётся. | -| `AsyncPaginatedList` не наследует `list` → ломаются ожидания сервисов | Документируем в docstring; `scripts/lint_async_parity.py` допускает `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`. List-API не реплицируется намеренно. | -| `AsyncPaginator` не покрывает helper usage `Paginator(...).as_list(...)` | Контракт `AsyncPaginator` симметричен sync (`iter_pages`/`collect`/`as_list`); все 4 текущих usage-сайта покрыты через методы, возвращающие `AsyncPaginatedList[T]`. | -| Auth-bindings не попадают в async-coverage | `_NON_DOMAIN_BINDING_MODULES` дополнен строго `"avito.auth.async_token_client"`; class-gated coverage гейтится по присутствию `AsyncTokenClient`/`AsyncAlternateTokenClient`. | -| `Async` имеет decorators, но не имеет class-level `__sdk_factory__` / `__swagger_domain__` → discovery/reference/factory checks неполные | DoD M2…M12 требует зеркальную class metadata для каждого `Async`, а `scripts/lint_async_parity.py` сравнивает metadata sync/async и падает при отсутствии. | -| Двойной декор одной функции | Текущая защита `__swagger_binding__` остаётся; sync и async — разные функции. | -| Гонка на основном refresh-токене в async | `asyncio.Lock` (`_refresh_lock`) в `AsyncAuthProvider` + double-checked pattern (как sync, но через `await`). | -| Гонка на autoteka-токене в async | Отдельный `_autoteka_refresh_lock` + double-checked в `get_autoteka_access_token()`. Sync-провайдер остаётся без нового thread-safety контракта в M1, чтобы не менять sync semantics; async получает явную защиту, потому что concurrent first-touch через один event loop — штатный сценарий. | -| `asyncio.Lock` создан вне event loop'а → cross-loop UB | `AsyncAuthProvider` создаётся внутри `AsyncAvitoClient` (через `__aenter__` или `_from_transport`); в docstring явное предупреждение «не переиспользовать между event loop'ами». Python 3.10+ лениво биндит lock к loop'у при первом `await`. | -| Миграция `_access_token` в `TokenCache` ломает `tests/core/test_authentication.py:122-127` | `AuthProvider` сохраняет `@property`/setter shim'ы для всех трёх частных полей; шим помечен legacy-комментом и удаляется в отдельном PR. | -| `_operation_specs_for_sdk_method` не находит spec из `async_domain.py` | Pre-flight smoke-тест с async-методом + явным импортом spec; текущая реализация через `unwrapped_method.__globals__` (`swagger_linter.py:578-601`) обязана работать, потому что `from ...operations import SOME_SPEC` ставит spec в `__globals__` модуля. Если не работает — фикс в Phase 1b. | -| Convenience-методы (`account_health`, …) теряют main user-value async (параллелизм) или меняют error semantics | M-final требует `asyncio.TaskGroup` только для независимых подзапросов и сохраняет sync error semantics: required ветки пробрасывают `AvitoError`, optional ветки идут через `_safe_summary_async`. Запрещено реализовывать «sync, обмазанный await» и запрещено превращать required ошибку в unavailable section. | -| `asyncio.gather(return_exceptions=True)` глушит `CancelledError` в convenience-методах | Запрещён; используется `asyncio.TaskGroup` (Python 3.11+, у нас floor 3.12+). При отмене внешнего вызова TaskGroup атомарно отменяет все child-таски без потери cancellation. | -| Retry-петля ловит `asyncio.CancelledError` и зацикливает отмену | Shared `_decide_*_retry` и обёртки `Transport`/`AsyncTransport` ловят только retryable `httpx.TimeoutException` / `httpx.NetworkError`, не `BaseException` и не весь `httpx.RequestError`. Закреплено тестом `test_cancelled_error_is_not_retried`. | -| `AsyncAvitoClient.__aenter__` оставляет полу-инициализированный state при ошибке | `__aenter__` обёрнут `try/except BaseException`: при любом исключении вызывает идемпотентный `aclose()` и пробрасывает наружу. Закреплено тестом `test_aenter_rollback_on_partial_failure`. | -| Ownership внешнего `httpx.AsyncClient` не определён — потенциальный resource-leak или double-close | M1 явно выбирает mirror текущего sync-поведения: `AsyncTransport.aclose()` закрывает переданный `httpx.AsyncClient`. Это закреплено тестом. Альтернативная политика `_owns_client` возможна только отдельным PR одновременно для sync и async. | -| `AsyncFakeTransport` рассинхронизирован при `asyncio.gather` | `_handle_lock = asyncio.Lock()` сериализует match-and-record; **создаётся в `__init__`**, не лениво (лениво — гонка на самой инициализации lock'а). Закреплено тестом `test_async_fake_transport_concurrent_handle`. | -| M1 smoke проходит через `AsyncFakeTransport` без auth provider и не проверяет OAuth/401 refresh | `AsyncFakeTransport.as_client(authenticated=True)` и `build(authenticated=True)` создают `AsyncAuthProvider` + async token clients на том же `MockTransport`; smoke обязан проверять реальные `/token` вызовы, `Authorization`, invalidate после 401 и повторный token fetch. | -| Существующие `async def test_*` в репозитории молча скипаются после `asyncio_mode = "strict"` | Pre-flight `grep -rn "^async def test_" tests/` фиксирует все такие тесты до M1; маркер `@pytest.mark.asyncio` добавляется отдельным pre-flight commit'ом. | -| `len(PaginatedList)` / `paginated[0]` в коде ломаются при попытке мигрировать на `AsyncPaginatedList` | Pre-flight `grep` фиксирует все list-API usage. `AsyncPaginatedList` не повторяет list-API намеренно; каждый кейс заменяется на `await materialize()` / `loaded_count` в async-двойнике или остаётся sync-only. | -| Скрытая работа «на потом» в доменных PR (TODO/FIXME/skip) | DoD M3…M12 явно требует пустой выхлоп `grep -E "TODO|FIXME|@pytest.mark.skip|xfail"` по diff'у; async-тестов должно быть не меньше sync-тестов, а PR description содержит mapping `sync test -> async test`; PR не мерджится при частичном покрытии домена. | -| PoC обнаруживает, что фундамент (M1) недостаточен | Это и есть назначение PoC: feedback от M2-PoC → правки фундамента в этом же PR или M1.5-PR; `tariffs`-домен после доработок закрыт на 100%, как и остальные. M3 не стартует, пока M2-PoC не зелёный. | -| `AsyncTokenClient._request_token` закольцован через основной auth-провайдер | Внутри создаётся независимый `AsyncTransport` с `auth_provider=None` (зеркало sync `TokenClient._build_transport()`). | -| Sync поведение незаметно изменилось в Phase 1 | DoD M1 включает baseline-diff только по nodeid существующих тестов с main; новые async tests не участвуют в сравнении. Любое расхождение по старым nodeid блокирует merge. Phase 1a — отдельный коммит для bisect. | -| `_gen_reference.py` строит reference только из sync `*/domain.py` или пишет один общий `::: avito.` → `Async` молча отсутствуют в reference, `make docs-strict` остаётся зелёным, но публикация неполна | M1 обязан расширить builder (`public_domain_packages` подхватывает `async_domain.py`, `public_domain_classes` фильтрует `Async` через `AsyncDomainObject`-наследование, `public_domain_methods` — через `value.__qualname__.startswith(f"{cls.__name__}.")`) и перевести `write_domain_pages()` на явные class-директивы sync → async. Pre-flight фиксирует текущие точки фильтрации. M2-PoC валидирует на `tariffs`. | -| Версия пакета не поднята в M-final → релиз 2.1.0 опубликован под старой версией | DoD M-final требует `poetry version 2.1.0` + `## [2.1.0] - YYYY-MM-DD` в CHANGELOG в одном PR. `git tag v2.1.0` после merge. | -| `_safe_summary_async` вынесен в отдельный модуль, sync `_safe_summary` остался в `client.py` → симметричные хелперы в разных файлах | DoD M-final требует: либо оба в `avito/client.py`, либо оба в `avito/summary/_helpers.py`. Частичное вынесение запрещено. | -| Concurrent iteration одного `AsyncPaginatedList` мутит общий `_cursor` → пользователь получает silent data corruption | Fail-fast контракт: второй `__aiter__` на активном instance бросает `RuntimeError`; fan-out делается через `await materialize()` или отдельный `AsyncPaginatedList` per consumer. | -| Английский в новых сообщениях ошибок `async_domain.py` (STYLEGUIDE.md violation) | DoD M3…M12 включает явный пункт «сообщения ошибок только на русском»; code review проверяет каждый `raise ("...")`. | -| `AsyncSwaggerFakeTransport` не синхронизирован со sync `SwaggerFakeTransport` | Добавляется в M1 как thin async mirror поверх общих schema/argument helpers. `tests/contracts/test_async_swagger_contracts.py` проходит по discovered `variant="async"` bindings на каждом этапе и в M-final покрывает все 204 operations. | -| `pytest-asyncio` 0.23+ выдаёт `PytestDeprecationWarning` без `asyncio_default_fixture_loop_scope` → накапливается шум в pytest output, блокирует будущее включение `filterwarnings = error` | M1 обязан добавить `asyncio_default_fixture_loop_scope = "function"` в `[tool.pytest.ini_options]` рядом с `asyncio_mode = "strict"`. На момент M1 `filterwarnings = error` ещё не включён (превентивная защита). Закреплено в DoD M1. | -| `_validate_factory(variant="async")` падает на async auth bindings в M1 (нет ни одного domain-factory на `AsyncAvitoClient`) ИЛИ пропускает missing async factory в M3+ | Class-gated реализация: factory-check skip'ается на async binding'ах без `Async` в домене и на binding'ах без `factory` в декораторе. Тест `test_validate_factory_async_skips_unported_classes` фиксирует поведение для M1, тест `test_validate_factory_async_requires_factory_for_ported_class` — для M2-PoC+. | -| `_operation_specs_for_sdk_method` не находит spec из `async_domain.py`, и Phase 1b упирается в это в середине без плана | Fallback расписан **до** старта M1 (см. Swagger-секцию): primary — AST-резолв из source-файла, secondary — class-level `__operation_specs__`. Pre-flight smoke-тест выбирает один из вариантов **до** открытия M1 PR; решение зафиксировано в PR description. | -| `AsyncOperationExecutor` берёт retry только из аргумента или только из `spec.retry` → расхождение с sync executor незаметно | DoD M1 включает параметризованный тест `test_executor_retry_resolution_matches_sync` на три тройки `(retry, spec.retry, expected)`, сверяющий результат с sync `OperationExecutor`. | -| `httpx.AsyncClient` с дефолтными limits + неограниченный fan-out в convenience-методах M-final → starvation pool'а | M1 фиксирует дефолтные `httpx.Limits` (без переопределения). DoD M-final требует fan-out ≤ 6 in-flight задач на агрегатор. Текущие sync-агрегаторы укладываются в этот предел (max ~5 веток в `account_health`). | -| `review_summary` async с TaskGroup отменяет в-полёте optional `reviews`-task при ошибке required `rating` → меняет sync semantics | `review_summary` async **обязан** быть sequential reviews-then-rating без TaskGroup, как зафиксировано в таблице классификации и блоке «Важная тонкость TaskGroup». DoD M-final code review checklist это явно проверяет. | -| `AsyncAuthProvider.invalidate_token` сделан корутиной с `async with self._refresh_lock` → ложная защита, рост latency 401-handling, расхождение с sync | Контракт явно `def invalidate_token(self) -> None`, без await; тест `test_invalidate_token_is_sync_and_idempotent` фиксирует синхронность и идемпотентность. | -| `AsyncTransport.request()` забывает вызвать `await self._rate_limiter.acquire()` перед httpx-вызовом → state обновляется (через `observe_response`), но реальная сериализация не работает, параллельные корутины уходят пачкой | Шаг 3 контракта `AsyncTransport.request()` явно зеркалит sync `Transport.request()` строку 148: `await self._rate_limiter.acquire()` перед каждым `await self._client.request(...)`. Закреплено тестом `test_request_acquires_rate_limiter_before_httpx_call` (5 параллельных корутин на один transport — `RateLimitState._tokens` обновляется по одному до httpx-вызова). Парный тест `test_request_calls_observe_response_after_success` фиксирует пост-condition. | -| Binary-ветка `AsyncOperationExecutor` отличается от sync (другой helper, другая форма `BinaryResponse`) → расхождение для `OrderLabel.download()` и аналогов | Module-level `_request_binary_async(transport, *, spec, path, ...)` зеркало sync `_request_binary` (`avito/core/operations.py:254-278`), оба в одном файле, оба принимают свой `*OperationTransport` Protocol. Тест `test_binary_branch_uses_request_binary_async_helper` фиксирует совпадение полей `BinaryResponse`. M12 domain-тест `OrderLabel.download()` через `AsyncSwaggerFakeTransport` — обязательный финальный гейт. | -| `AsyncRateLimiter` location выбирается в PR review → bikeshedding, риск размытия async-инфраструктуры внутрь `async_transport.py` | Зафиксировано: **`avito/core/_async_rate_limit.py`**, симметрично sync `avito/core/rate_limit.py`. Любое отклонение требует явного обоснования в PR description. | -| Список deprecated методов в `cpa`/`ads` устаревает → async-aware wrapper в `deprecation.py` пропускает кейс, M6/M11 ловят парadox в середине разработки | Pre-flight grep `@deprecated_method` в `avito/cpa/` и `avito/ads/` фиксирует точное число (на момент написания плана: 3 + 4 = 7) и места (`cpa/domain.py:491,541,585`, `ads/domain.py:1416,1457,1523,1558`). Любое расхождение между pre-flight grep и текущим состоянием — обновление таблицы sequencing до старта M1. | -| M-final verification скрипт хардкодит ~50 `Async` имён → любое добавление/переименование класса требует ручной правки скрипта | M-final скрипт получает список из `scripts.lint_async_parity.iter_async_classes()` — единственный источник истины. Linter обязан экспортировать эту функцию как публичный API модуля. | -| `AsyncFakeTransport.as_client(user_id=N)` без `authenticated=True` ведёт себя непонятно для domain-тестов → тестовый сетап нарушает sync parity | Контракт `as_client(user_id=N, authenticated=False)` явно описан: `_resolve_user_id` берёт `settings.user_id` без сетевого запроса, `auth_provider=None` пропускает `Authorization` header. Симметрично sync `FakeTransport.as_client(user_id=N)`. Закреплено тестом `test_as_client_user_id_skips_self_lookup`. | +| Divergence of retry/auth logic between sync and async | All non-IO logic lives in `_transport_shared.py` and `_cache.py`; both wrappers delegate. | +| `RateLimiter` is not applicable to async (sleep + `threading.Lock` baked into `acquire()`) | Decomposition into three parts: pure `RateLimitState.compute_delay()` in shared (no sleep, no lock), sync `RateLimiter` on top (`threading.Lock` + `time.sleep`), separate `AsyncRateLimiter` (`asyncio.Lock` + `await asyncio.sleep`). State is **not** shared between modes — sync and async transports are independent. | +| `_resolve_user_id` in async diverges from the sync fallback order | The async double repeats the current sync helper: argument → `settings.user_id` → raw `/core/v1/accounts/self` via transport. The public Swagger binding for `/core/v1/accounts/self` is covered by `AsyncAccount.get_self()`, not the internal helper. | +| `download_binary` in async may implicitly become streaming, diverging from sync | M1 fixes the full-buffer semantics (`await response.aread()`), like sync. Streaming is a separate API after 2.1.0 with a symmetric sync analog. Locked in by the test `test_download_binary_full_buffer_matches_sync`. | +| An M-final convenience method is implemented as "sync with a wrapped await" (loss of parallelism) OR a leaf/CPU-only method is wrapped in an unnecessary `TaskGroup` | The M-final DoD verifies the classification by actual sync code: `TaskGroup` only for independent network branches (`account_health`, `listing_health`, `review_summary`, `promotion_summary` when `item_ids`); `business_summary` is an alias; `chat_summary`/`order_summary` are sequential; `capabilities` is CPU-only without network probes. | +| Class-gated swagger-coverage applied per-domain → a large domain (`ads`) cannot be split, or a mini-domain with two classes requires finishing before the merge | Class-gated is applied **per-class**: `Async` exists ↔ all operations of class `` must have an async binding. The absence of `Async` in the same domain does not block merging class `Async`. The M3…M12 DoD still requires closing the domain at 100%. | +| `from_env` initializes loop-dependent resources outside the loop → cross-loop UB | `from_env` is sync, SDK-managed resources (`httpx.AsyncClient`, `asyncio.Lock`) are created in `__aenter__`. If an external `http_client` is passed by the user, the transport binds to it only in `__aenter__`. Access to `transport`/`auth_provider` before `__aenter__` raises `RuntimeError` with an understandable message. Locked in by the test `test_access_before_aenter_raises`. | +| `AsyncAvitoClient` implements only domain factories and forgets the public diagnostic/closed contract of the sync client | M1 includes `auth()`, `debug_info()`, `_ensure_open()`, `_require_transport()`, `ClientClosedError` after `aclose()`, and a check of `AsyncAvitoClient.debug_info()` in `_gen_reference.py.ensure_debug_info_exists()`. | +| 2.1.0 release notes cannot be assembled mechanically because PR M3…M12 have no CHANGELOG entries | The M3…M12 DoD requires a `## [Unreleased]` line in the root `CHANGELOG.md` per PR. M-final aggregates the accumulated content into `## [2.1.0]`. | +| `_merge_headers` covertly does sync IO (`get_access_token()`) | Phase 1a as the first step refactors the contract: the helper takes an already-resolved `bearer_token: str | None`. Without this, the shared layer is not IO-agnostic, and the vary logic spreads. | +| `AsyncPaginatedList` does not inherit `list` → service expectations break | We document in the docstring; `scripts/lint_async_parity.py` allows `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`. The list API is not deliberately replicated. | +| `AsyncPaginator` does not cover the helper usage `Paginator(...).as_list(...)` | The contract of `AsyncPaginator` is symmetric to sync (`iter_pages`/`collect`/`as_list`); all 4 current usage sites are covered through methods that return `AsyncPaginatedList[T]`. | +| Auth bindings do not enter async coverage | `_NON_DOMAIN_BINDING_MODULES` is augmented strictly with `"avito.auth.async_token_client"`; class-gated coverage is gated on the presence of `AsyncTokenClient`/`AsyncAlternateTokenClient`. | +| `Async` has decorators but no class-level `__sdk_factory__` / `__swagger_domain__` → discovery/reference/factory checks are incomplete | The DoD M2…M12 requires mirror class metadata for each `Async`, and `scripts/lint_async_parity.py` compares sync/async metadata and fails on absence. | +| Double-decoration of one function | The current `__swagger_binding__` protection remains; sync and async are different functions. | +| Race on the main refresh token in async | `asyncio.Lock` (`_refresh_lock`) in `AsyncAuthProvider` + double-checked pattern (like sync, but via `await`). | +| Race on the autoteka token in async | A separate `_autoteka_refresh_lock` + double-checked in `get_autoteka_access_token()`. The sync provider remains without a new thread-safety contract in M1, so as not to change sync semantics; async gets explicit protection, because concurrent first-touch through one event loop is a regular scenario. | +| `asyncio.Lock` created outside an event loop → cross-loop UB | `AsyncAuthProvider` is created inside `AsyncAvitoClient` (via `__aenter__` or `_from_transport`); the docstring explicitly warns "do not reuse across event loops". Python 3.10+ lazily binds the lock to the loop on first `await`. | +| Migration of `_access_token` to `TokenCache` breaks `tests/core/test_authentication.py:122-127` | `AuthProvider` keeps `@property`/setter shims for all three private fields; the shim is marked with a legacy comment and is removed in a separate PR. | +| `_operation_specs_for_sdk_method` does not find a spec from `async_domain.py` | Pre-flight smoke test with an async method + explicit spec import; the current implementation via `unwrapped_method.__globals__` (`swagger_linter.py:578-601`) must work, because `from ...operations import SOME_SPEC` puts the spec into the module's `__globals__`. If it does not work — fix in Phase 1b. | +| Convenience methods (`account_health`, …) lose the main user-value of async (parallelism) or change error semantics | M-final requires `asyncio.TaskGroup` only for independent subqueries and preserves sync error semantics: required branches propagate `AvitoError`, optional branches go through `_safe_summary_async`. It is forbidden to implement "sync wrapped in await" and forbidden to turn a required error into an unavailable section. | +| `asyncio.gather(return_exceptions=True)` swallows `CancelledError` in convenience methods | Forbidden; `asyncio.TaskGroup` is used (Python 3.11+, our floor is 3.12+). On cancellation of an outer call, TaskGroup atomically cancels all child tasks without losing cancellation. | +| The retry loop catches `asyncio.CancelledError` and loops cancellation | Shared `_decide_*_retry` and the `Transport`/`AsyncTransport` wrappers catch only retryable `httpx.TimeoutException` / `httpx.NetworkError`, not `BaseException` and not all of `httpx.RequestError`. Locked in by the test `test_cancelled_error_is_not_retried`. | +| `AsyncAvitoClient.__aenter__` leaves partially-initialized state on error | `__aenter__` is wrapped in `try/except BaseException`: on any exception it calls the idempotent `aclose()` and re-raises. Locked in by the test `test_aenter_rollback_on_partial_failure`. | +| Ownership of an external `httpx.AsyncClient` is not defined — potential resource leak or double-close | M1 explicitly chooses to mirror the current sync behavior: `AsyncTransport.aclose()` closes the passed `httpx.AsyncClient`. This is locked in by a test. An alternative `_owns_client` policy is only possible in a separate PR for sync and async simultaneously. | +| `AsyncFakeTransport` desynchronizes on `asyncio.gather` | `_handle_lock = asyncio.Lock()` serializes match-and-record; **created in `__init__`**, not lazily (lazy creation is a race on lock initialization itself). Locked in by the test `test_async_fake_transport_concurrent_handle`. | +| The M1 smoke goes through `AsyncFakeTransport` without an auth provider and does not verify OAuth/401 refresh | `AsyncFakeTransport.as_client(authenticated=True)` and `build(authenticated=True)` create `AsyncAuthProvider` + async token clients on the same `MockTransport`; the smoke must verify real `/token` calls, `Authorization`, invalidate after 401, and a repeated token fetch. | +| Existing `async def test_*` in the repository are silently skipped after `asyncio_mode = "strict"` | Pre-flight `grep -rn "^async def test_" tests/` records all such tests before M1; the marker `@pytest.mark.asyncio` is added in a separate pre-flight commit. | +| `len(PaginatedList)` / `paginated[0]` in code break when trying to migrate to `AsyncPaginatedList` | Pre-flight `grep` records all list-API usages. `AsyncPaginatedList` deliberately does not replicate the list API; each case is replaced with `await materialize()` / `loaded_count` in the async double or remains sync-only. | +| Hidden work "later" in domain PRs (TODO/FIXME/skip) | The DoD M3…M12 explicitly requires empty output of `grep -E "TODO|FIXME|@pytest.mark.skip|xfail"` over the diff; async tests must be no fewer than sync tests, and the PR description contains a mapping `sync test -> async test`; the PR is not merged with partial coverage of the domain. | +| The PoC discovers that the foundation (M1) is insufficient | This is exactly the purpose of the PoC: feedback from M2-PoC → fixes to the foundation in the same PR or M1.5-PR; the `tariffs` domain after fixes is closed at 100%, like the rest. M3 does not start until M2-PoC is green. | +| `AsyncTokenClient._request_token` is looped through the main auth provider | Internally, an independent `AsyncTransport` with `auth_provider=None` is created (mirror of sync `TokenClient._build_transport()`). | +| Sync behavior changed silently in Phase 1 | The M1 DoD includes a baseline-diff only on nodeids of existing tests with main; new async tests do not participate in the comparison. Any divergence on old nodeids blocks the merge. Phase 1a — a separate commit for bisect. | +| `_gen_reference.py` builds the reference only from sync `*/domain.py` or writes one common `::: avito.` → `Async` are silently absent from the reference, `make docs-strict` remains green, but publishing is incomplete | M1 must extend the builder (`public_domain_packages` picks up `async_domain.py`, `public_domain_classes` filters `Async` through `AsyncDomainObject` inheritance, `public_domain_methods` — through `value.__qualname__.startswith(f"{cls.__name__}.")`) and move `write_domain_pages()` to explicit class directives sync → async. Pre-flight records the current filter points. M2-PoC validates on `tariffs`. | +| The package version is not bumped in M-final → 2.1.0 release published under the old version | The M-final DoD requires `poetry version 2.1.0` + `## [2.1.0] - YYYY-MM-DD` in CHANGELOG in one PR. `git tag v2.1.0` after merge. | +| `_safe_summary_async` is moved to a separate module, sync `_safe_summary` stays in `client.py` → symmetric helpers in different files | The M-final DoD requires: either both in `avito/client.py`, or both in `avito/summary/_helpers.py`. Partial extraction is forbidden. | +| Concurrent iteration of one `AsyncPaginatedList` mutates a shared `_cursor` → the user gets silent data corruption | Fail-fast contract: a second `__aiter__` on an active instance raises `RuntimeError`; fan-out is done via `await materialize()` or a separate `AsyncPaginatedList` per consumer. | +| English in new error messages of `async_domain.py` (STYLEGUIDE.md violation) | The M3…M12 DoD includes an explicit item "error messages in Russian only"; code review verifies every `raise ("...")`. | +| `AsyncSwaggerFakeTransport` is not synchronized with sync `SwaggerFakeTransport` | Added in M1 as a thin async mirror over shared schema/argument helpers. `tests/contracts/test_async_swagger_contracts.py` walks discovered `variant="async"` bindings at each stage and in M-final covers all 204 operations. | +| `pytest-asyncio` 0.23+ emits `PytestDeprecationWarning` without `asyncio_default_fixture_loop_scope` → noise accumulates in pytest output, blocks future enabling of `filterwarnings = error` | M1 must add `asyncio_default_fixture_loop_scope = "function"` in `[tool.pytest.ini_options]` next to `asyncio_mode = "strict"`. At the time of M1, `filterwarnings = error` is not yet enabled (preventive defense). Locked in the M1 DoD. | +| `_validate_factory(variant="async")` fails on async auth bindings in M1 (no domain factory on `AsyncAvitoClient`) OR misses a missing async factory in M3+ | Class-gated implementation: factory-check is skipped on async bindings without `Async` in the domain and on bindings without `factory` in the decorator. The test `test_validate_factory_async_skips_unported_classes` locks in the behavior for M1, the test `test_validate_factory_async_requires_factory_for_ported_class` — for M2-PoC+. | +| `_operation_specs_for_sdk_method` does not find a spec from `async_domain.py`, and Phase 1b runs into this in the middle without a plan | The fallback is laid out **before** the start of M1 (see Swagger section): primary — AST resolution from the source file, secondary — class-level `__operation_specs__`. The pre-flight smoke test selects one of the options **before** opening the M1 PR; the decision is recorded in the PR description. | +| `AsyncOperationExecutor` takes retry only from the argument or only from `spec.retry` → divergence with sync executor goes unnoticed | The M1 DoD includes a parameterized test `test_executor_retry_resolution_matches_sync` on three triples `(retry, spec.retry, expected)`, comparing the result with sync `OperationExecutor`. | +| `httpx.AsyncClient` with default limits + unlimited fan-out in M-final convenience methods → pool starvation | M1 fixes default `httpx.Limits` (no override). The M-final DoD requires fan-out ≤ 6 in-flight tasks per aggregator. The current sync aggregators fit within this limit (max ~5 branches in `account_health`). | +| `review_summary` async with TaskGroup cancels an in-flight optional `reviews` task on a required `rating` error → changes sync semantics | `review_summary` async **must** be sequential reviews-then-rating without TaskGroup, as recorded in the classification table and the "Important TaskGroup subtlety" block. The M-final DoD code review checklist explicitly verifies this. | +| `AsyncAuthProvider.invalidate_token` is made a coroutine with `async with self._refresh_lock` → false protection, increased latency of 401-handling, divergence with sync | The contract is explicitly `def invalidate_token(self) -> None`, no await; the test `test_invalidate_token_is_sync_and_idempotent` locks in synchronicity and idempotency. | +| `AsyncTransport.request()` forgets to call `await self._rate_limiter.acquire()` before the httpx call → state is updated (via `observe_response`), but real serialization does not work, parallel coroutines go out in a batch | Step 3 of the `AsyncTransport.request()` contract explicitly mirrors sync `Transport.request()` line 148: `await self._rate_limiter.acquire()` before each `await self._client.request(...)`. Locked in by the test `test_request_acquires_rate_limiter_before_httpx_call` (5 parallel coroutines on one transport — `RateLimitState._tokens` is updated one at a time before the httpx call). The paired test `test_request_calls_observe_response_after_success` locks in the post-condition. | +| The binary branch of `AsyncOperationExecutor` differs from sync (different helper, different `BinaryResponse` form) → divergence for `OrderLabel.download()` and analogs | Module-level `_request_binary_async(transport, *, spec, path, ...)` mirrors sync `_request_binary` (`avito/core/operations.py:254-278`), both in the same file, both accepting their own `*OperationTransport` Protocol. The test `test_binary_branch_uses_request_binary_async_helper` locks in matching of `BinaryResponse` fields. The M12 domain test `OrderLabel.download()` via `AsyncSwaggerFakeTransport` is a mandatory final gate. | +| The location of `AsyncRateLimiter` is chosen in PR review → bikeshedding, risk of blurring async infrastructure into `async_transport.py` | Locked in: **`avito/core/_async_rate_limit.py`**, symmetrically with sync `avito/core/rate_limit.py`. Any deviation requires explicit justification in the PR description. | +| The list of deprecated methods in `cpa`/`ads` becomes outdated → the async-aware wrapper in `deprecation.py` misses a case, M6/M11 catch the paradox in the middle of development | Pre-flight grep `@deprecated_method` in `avito/cpa/` and `avito/ads/` records the exact number (at the time of writing the plan: 3 + 4 = 7) and locations (`cpa/domain.py:491,541,585`, `ads/domain.py:1416,1457,1523,1558`). Any divergence between pre-flight grep and the current state — update of the sequencing table before the start of M1. | +| The M-final verification script hardcodes ~50 `Async` names → any addition/rename of a class requires manual editing of the script | The M-final script gets the list from `scripts.lint_async_parity.iter_async_classes()` — the single source of truth. The linter must export this function as a public API of the module. | +| `AsyncFakeTransport.as_client(user_id=N)` without `authenticated=True` behaves unclearly for domain tests → the test setup violates sync parity | The contract `as_client(user_id=N, authenticated=False)` is explicitly described: `_resolve_user_id` takes `settings.user_id` without a network request, `auth_provider=None` skips the `Authorization` header. Symmetrically with sync `FakeTransport.as_client(user_id=N)`. Locked in by the test `test_as_client_user_id_skips_self_lookup`. | From b633764633b1fa79f537dbe2955dc91614e1caf8 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 13:53:07 +0300 Subject: [PATCH 11/26] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D1=8E=20=D0=BD=D0=B0=D0=B4=20todo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- todo.md | 158 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 144 insertions(+), 14 deletions(-) diff --git a/todo.md b/todo.md index 703a594..5755860 100644 --- a/todo.md +++ b/todo.md @@ -471,6 +471,22 @@ in the class docstring and in `docs/site/explanations/pagination-semantics.md` (addition in M-final). Locked in by the behavior of `tests/core/test_async_pagination.py::test_concurrent_aiter_raises_runtime_error`. +**Lifecycle contract — behavior after transport `aclose()`.** `AsyncPaginatedList` +captures the `fetch_page` callable at creation time, which holds a reference to +`AsyncTransport`. If the user calls `await client.aclose()` while an +`AsyncPaginatedList` is mid-iteration (i.e. the first page is loaded but +subsequent pages are not), the next `__anext__` / next `aload_until` / +`materialize()` must raise `ClientClosedError("Клиент закрыт во время итерации +AsyncPaginatedList; пагинация прервана.")` rather than silently returning the +partial buffer or hanging on a closed `httpx.AsyncClient`. Implementation: the +`fetch_page` wrapper checks `transport._closed` (or the client's `_closed` flag, +propagated via an internal hook) before each network call; if closed, raises +`ClientClosedError`. Already-buffered items from previous pages are **not** +flushed — the iterator simply stops on the next page boundary. The same rule +applies to `AsyncPaginator.iter_pages()` and `collect()`. Locked in by +`tests/core/test_async_pagination.py::test_aiter_raises_after_client_aclose` and +`::test_materialize_raises_after_client_aclose`. + `AsyncPaginator` is mandatory as an implementation helper: sync domains use `Paginator(...).as_list(...)` in 4 places (`avito/ads/domain.py:266,1183`, `avito/accounts/domain.py:170,383`). The current public surface does not return @@ -1112,10 +1128,32 @@ Before opening PR M1 (all of this is done locally and validated before commit): how it resolves the factory on `AvitoClient`, what it considers an error. M1 must extend it with class-gated coverage (see Swagger section). Without full understanding of the current logic, the extension risks weakening the invariant for sync bindings. -- [ ] **Run pre-flight locally, record results**: the pre-flight test on - `_operation_specs_for_sdk_method` for an async stub is actually run; the result - (pass/fail) and the chosen fallback (none / primary / secondary) are recorded - in the M1 PR description. Without an actual run, M1 is not opened. +- [ ] **Run pre-flight locally, record results in a tracked artifact**: + a new file `docs/dev/preflight-async-m1.md` is created and committed in + a separate pre-flight commit (before opening M1) capturing **all** of the + following in machine-readable form: + (1) the actual list of `_access_token`/`_refresh_token`/`_autoteka_access_token` + probes in `tests/` (paths + line numbers); + (2) the actual `Paginator` usage sites in `avito/` (4 expected, paths + + line numbers); + (3) the actual `len(...)` / `[idx]` / `bool(...)` / slice usages on + `PaginatedList[T]` across `avito/` and `tests/`; + (4) the actual count and locations of `@deprecated_method` in + `avito/cpa/` and `avito/ads/` (7 expected, with line numbers); + (5) the existing `^async def test_` lines (expected: empty); + (6) the result (pass/fail) of the `_operation_specs_for_sdk_method` + smoke test on an async stub, and the chosen fallback (none / primary / + secondary) with a one-paragraph justification; + (7) the concrete diff baseline: `/tmp/baseline_nodeids.txt` and + `/tmp/baseline_main.txt` are produced and their sha256 sums are + recorded in the artifact (the actual files are not committed — + only the hashes, for later reproducibility); + (8) the Python interpreter version, Poetry lockfile hash, and `httpx` + version in use at pre-flight time. + Without `docs/dev/preflight-async-m1.md` in the M1 PR diff, the PR is + not opened. The artifact is referenced from the M1 PR description and + is not deleted by M-final (it remains permanent provenance for the + async migration). ### M1 — Foundation (1 PR) @@ -1164,6 +1202,25 @@ DoD: - [ ] **`httpx.AsyncClient` is created with default limits** (without override). A test forbidding SDK-side tuning of limits is not needed in M1; the M-final DoD has a fan-out ≤ 6 check. - [ ] **`AsyncTransport.request()` calls `await self._rate_limiter.acquire()` before each httpx call and `observe_response()` after a successful response** — exact mirror of sync `Transport.request()` (lines 148, 183). Locked in by two tests: `tests/core/test_async_transport.py::test_request_acquires_rate_limiter_before_httpx_call` (5 parallel coroutines on one transport — tokens are spent one at a time, not in a batch) and `::test_request_calls_observe_response_after_success` (post-condition). - [ ] **`_request_binary_async` module-level helper in `avito/core/operations.py`** is an async mirror of sync `_request_binary`. Accepts `AsyncOperationTransport` Protocol, returns `BinaryResponse` with the same fields. Closed-test: `tests/core/test_async_executor.py::test_binary_branch_uses_request_binary_async_helper`. +- [ ] **End-to-end binary-branch coverage in M1 (synthetic, before any domain port)**: + to prove the full async pipeline works for `response_kind == "binary"` + **before** M12 `orders` lights it up via `OrderLabel.download()`, M1 adds + one synthetic binding inside the test suite (not in production code) — + a `_TestBinaryDomain` with an `async def download(...)` method decorated + with `@swagger_operation(..., variant="async")` over a fake + `OperationSpec` with `response_kind == "binary"`. Test + `tests/core/test_async_executor.py::test_async_executor_full_binary_pipeline` + drives the spec end-to-end through `AsyncSwaggerFakeTransport` → + `AsyncOperationExecutor` → `_request_binary_async` → + `BinaryResponse`, and asserts that `content`, `content_type`, `filename`, + `status_code`, `headers` match the response body byte-for-byte. Without + this, M1 ships an executor whose binary branch is verified only at the + unit level (`test_binary_branch_uses_request_binary_async_helper`) — + regressions across executor + transport + fake-transport interaction + would only be caught in M12, weeks later. The synthetic binding lives + in `tests/_fixtures/synthetic_binary_domain.py` and is excluded from + `swagger_discovery._iter_domain_modules` (its module path does not + start with `avito.`). - [ ] **`AsyncRateLimiter` lives in `avito/core/_async_rate_limit.py`** (not inside `async_transport.py`). Symmetric to sync `avito/core/rate_limit.py`. - [ ] **`scripts/lint_async_parity.py` exports `iter_async_classes()` as a public API** — used by the M-final verification script and any external tool that needs the canonical list of `Async` classes. - [ ] CHANGELOG `## [Unreleased]` in the root `CHANGELOG.md` is updated with: @@ -1344,31 +1401,97 @@ Contents of each M3…M12: - [ ] **No work "later"**: reopening a PR with the phrase "I'll finish it in the next PR" is forbidden. If scope does not close — the PR is split or expanded, but no partial domain is left in main. -- [ ] **CHANGELOG is updated**: in the root `CHANGELOG.md` (section `## [Unreleased]`) - a line is added of the form `- Async-поддержка домена : Async, Async - (#)` **strictly in the section `## [Unreleased]`**, not in `## [2.1.0]` - (the 2.1.0 section does not yet exist on these PRs). Entry template per M3…M12 PR: +- [ ] **Per-class split escape hatch (M11/M12 only, by explicit decision)**: for + `M11 ads` (3 classes: `Ad`/`AutoloadProfile`/`AutoloadReport`, 28 ops) and + `M12 orders` (45 ops, the largest domain) the «no partial domain» rule is + **softened by exception**: it is allowed to split the domain into a sequence of + per-class PRs (`M11a Ad`, `M11b AutoloadProfile`, `M11c AutoloadReport`; + `M12a–M12N` partitioned by `OperationSpec` group), provided that **each + sub-PR is itself class-complete**: every method of the included class has + an async double, swagger-lint per-class is 1:1, async-parity-lint is green + for the included class. Class-gated coverage in `swagger_linter.py` + already supports this (see Swagger section). Constraints: + (1) the split must be declared in the M11/M12 design comment **before** the + first sub-PR is opened, with the full list of sub-PRs and their order; + (2) the cumulative parity invariant still applies — each sub-PR leaves + `make swagger-lint --strict` green for all already ported classes; + (3) the `M11`/`M12` row in the sequencing table is replaced with the + sub-PR list, and `M-final` waits for the **last** sub-PR. + For all other domains (M3…M10) the «no partial domain» rule is hard: + one PR closes one whole domain at 100%. The exception exists strictly to + keep code-review tractable on `ads` and `orders`; it must not be invoked + retroactively to «rescue» a stuck PR on other domains. +- [ ] **CHANGELOG is updated via per-PR fragments**: each M3…M12 PR adds **one + file** under `CHANGELOG.d/-async-.md` with the content: ```markdown - ## [Unreleased] ### Added - Async-поддержка домена : Async, Async (#) ``` - M-final aggregates the accumulated `Unreleased` lines into release 2.1.0, adding only - the entry about convenience methods and `AsyncAvitoClient` aggregators. Without this, - history readers will not see in which PR the domain became async, and 2.1.0 release notes - cannot be assembled mechanically. + The root `CHANGELOG.md` is **not** edited per-PR. M-final aggregates all + `CHANGELOG.d/*.md` fragments into one `## [2.1.0] - YYYY-MM-DD` section, + then deletes the fragments. Rationale: 12 parallel PRs editing a single + `## [Unreleased]` block are guaranteed to merge-conflict on every rebase; + separate fragment files have no shared lines and merge cleanly. + Implementation: + (1) M1 PR creates `CHANGELOG.d/.gitkeep` and `CHANGELOG.d/README.md` + describing the format; + (2) `make check` (via a new `scripts/check_changelog_fragments.py`) + verifies each fragment matches the schema (one `### Added`/`### Changed`/ + `### Fixed` block, no `## [...]` headings, valid markdown); + (3) M-final concatenates fragments in PR-number order, prepends + `## [2.1.0] - YYYY-MM-DD`, appends to `CHANGELOG.md`, and `git rm + CHANGELOG.d/*.md` (keeping `.gitkeep` and `README.md`). + M1 itself does **not** use a fragment — its CHANGELOG line («Фундамент + Async API») is added directly to `## [Unreleased]` of the root file + (single PR, no conflict risk), and M-final moves it into `## [2.1.0]` + together with the fragment aggregate. ### Definition of done for M-final — release 2.1.0 "Final hardening" is defined verifiably: - [ ] **Convenience methods are implemented per the classification table** (aggregator / alias / leaf / CPU-only). Code review verifies: `asyncio.TaskGroup` is placed only in branches with actually independent network calls (`account_health`, `listing_health`, `review_summary`, `promotion_summary` when `item_ids` is given); in `business_summary` — `return await self.account_health(...)` without `TaskGroup`; `chat_summary` and `order_summary` are sequential; `capabilities` does not make network probe requests and does not use `TaskGroup`. Any violation = blocker. +- [ ] **Fan-out ≤ 6 is enforced by a real test, not just code review**: `tests/test_async_client_aggregators.py::test_account_health_fanout_does_not_exceed_six` + drives `AsyncAvitoClient.account_health(...)` through `AsyncFakeTransport` + with an instrumented `_handle` that records the **maximum number of + simultaneously in-flight requests** observed during the call (counter + incremented at the start of `_handle`, decremented after the response is + returned, peak captured under `_handle_lock`). The assertion is + `assert peak <= 6`. The same instrumentation is applied to + `listing_health`, `review_summary` (peak ≤ 1 — sequential), + `promotion_summary(item_ids=[...])` (peak ≤ 2), and + `business_summary` (delegates to `account_health`, peak ≤ 6). A single + shared `FanoutPeakRecorder` helper in `avito/testing/async_fake_transport.py` + provides the counter; aggregator tests opt in via + `AsyncFakeTransport(fanout_recorder=recorder)`. This locks the contract + against future drift: if a domain in the future adds a new branch and + pushes peak past 6, the test fails before the PR is merged. - [ ] **`_safe_summary_async` lives in the same module as sync `_safe_summary`** — `avito/client.py` (extraction into a shared `avito/summary/_helpers.py` is allowed, but requires simultaneous moving of sync `_safe_summary`; partial extraction is forbidden, so as not to split symmetric helpers across different files). The import in `avito/async_client.py` is explicit (`from avito.client import _safe_summary, _safe_summary_async`). Circularity does not arise: `avito/client.py` does not import `avito/async_client.py`, so the import graph remains acyclic; verified by the command `python -c "import avito.async_client"` without errors and `python -c "import avito.client"` without errors. - [ ] **The package version is bumped to 2.1.0**: `poetry version 2.1.0`, the change in `pyproject.toml` is recorded in the M-final PR. CHANGELOG `## [Unreleased]` → `## [2.1.0] - YYYY-MM-DD`, the accumulated lines M1…M12 + the entry about convenience methods and `AsyncAvitoClient` aggregators are aggregated into one section. `git tag v2.1.0` is set after merging M-final. - [ ] **`AsyncSwaggerFakeTransport` contract suite is complete**: `tests/contracts/test_async_swagger_contracts.py` calls all async bindings (204 Swagger operations, including auth bindings) and checks success/error/request-body schema, like the sync contract suite. -- [ ] **`docs/site/how-to/async.md` is written**: lifecycle contract (`async with` is mandatory), an example with `AsyncFakeTransport`, a migration guide "how to rewrite a sync call to async", limitations (`AsyncPaginatedList` not list-API, full-buffer download, no streaming). Links from `docs/site/index.md` and `docs/site/how-to/index.md`. +- [ ] **`docs/site/how-to/async.md` is written**: lifecycle contract (`async with` is mandatory), an example with `AsyncFakeTransport`, a migration guide "how to rewrite a sync call to async", limitations (`AsyncPaginatedList` not list-API, full-buffer download, no streaming). Links from `docs/site/index.md` and `docs/site/how-to/index.md`. **Mandatory dedicated section "Использование под ASGI (FastAPI / aiohttp / Starlette)"** with concrete recipes: + (1) **FastAPI lifespan pattern** — `AsyncAvitoClient` is created and + `__aenter__`'d inside `@asynccontextmanager async def lifespan(app)`, + stored on `app.state.avito`, and `aclose()`'d on shutdown. The client + lives one event loop = the app's main loop; FastAPI dependencies access + it via `Depends(lambda req: req.app.state.avito)`. Code example + ≥ 15 lines, runnable. + (2) **aiohttp `cleanup_ctx`** — analog with `aiohttp.web.AppKey` and + `app.cleanup_ctx.append(avito_client_ctx)`. + (3) **Per-worker isolation under Gunicorn/Uvicorn** — one + `AsyncAvitoClient` per worker process (each worker has its own loop); + forbidden to share across processes via fork-after-init. + (4) **Forbidden pattern** — calling `AsyncAvitoClient.from_env()` at + module import time and `__aenter__`'ing it in a request handler: this + attaches `httpx.AsyncClient` to whichever loop touched it first, and any + subsequent loop change (test client, background scheduler) gives + cross-loop UB. Section explicitly shows the broken pattern with a `# ❌` + comment and explains the failure mode. + (5) **Background tasks (`asyncio.create_task`, `BackgroundTasks`)** — + same loop as the request → safe to reuse the app-level client; a + separate process-pool worker → not safe, must build its own client. - [ ] **README/site wording is updated**: `README.md`, `mkdocs.yml`, `docs/site/index.md`, `docs/site/reference/client.md`, `docs/site/reference/pagination.md`, `docs/site/reference/testing.md` no longer call the SDK only synchronous. @@ -1472,6 +1595,13 @@ poetry version 2.1.0 # bump to 2.1.0 grep -E "^## \[2\.1\.0\]" CHANGELOG.md # the 2.1.0 section exists grep -E "^## \[Unreleased\]" CHANGELOG.md # Unreleased is empty or contains only the heading +# CHANGELOG.d/ fragments are aggregated and removed (only .gitkeep + README.md remain) +ls CHANGELOG.d/ | grep -vE "^(\.gitkeep|README\.md)$" \ + && echo "FAIL: leftover changelog fragments" || echo "OK: fragments aggregated" + +# Fan-out ≤ 6 enforced for all aggregator convenience methods +poetry run pytest tests/test_async_client_aggregators.py -k "fanout" + # After build, the reference contains both surfaces in each domain. # We get the list of Async classes dynamically from the parity linter (the same source # of truth used in make async-parity-lint), and do not hardcode — otherwise From a2201178030a684d5560aca17e767e7cf49a6aa1 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 14:06:36 +0300 Subject: [PATCH 12/26] Pre-flight for PR M1 --- docs/dev/preflight-async-m1.md | 279 +++++++++++++++++++++++++++++++++ todo.md | 26 +-- 2 files changed, 292 insertions(+), 13 deletions(-) create mode 100644 docs/dev/preflight-async-m1.md diff --git a/docs/dev/preflight-async-m1.md b/docs/dev/preflight-async-m1.md new file mode 100644 index 0000000..39ce74e --- /dev/null +++ b/docs/dev/preflight-async-m1.md @@ -0,0 +1,279 @@ +# Pre-flight Async M1 + +Generated: 2026-05-08 + +Repository HEAD: `b633764633b1fa79f537dbe2955dc91614e1caf8` + +This artifact records the local pre-flight required before PR M1 of the dual-mode SDK +plan. The goal is provenance, not implementation: it captures the current sync-only +state, known probes, baseline hashes, and the resolver smoke decision. + +## Environment + +```yaml +python_requires: ">=3.12,<4.0" +python_runtime: "3.14.0 (main, Oct 28 2025, 12:11:51) [Clang 20.1.4 ]" +pytest: "8.4.2" +httpx: "0.28.1" +poetry_lock_sha256: "08a6425ee9317b1b9074184ee1e03f0f57ff793c5b37ade73cdc33316246e7b3" +``` + +`pyproject.toml` currently has no `pytest-asyncio`, `asyncio_mode`, +`asyncio_default_fixture_loop_scope`, or `filterwarnings` entries. M1 must add the +asyncio pytest settings described in `todo.md`. + +## Private Auth Cache Probes + +Command: + +```bash +rg -n "\._access_token|\._refresh_token|\._autoteka_access_token" tests +``` + +Result: + +```text +tests/core/test_authentication.py:123: provider._access_token = replace( +tests/core/test_authentication.py:124: provider._access_token, # type: ignore[arg-type, attr-defined] +``` + +Decision: the M1 `AuthProvider` property shim for `_access_token` is sufficient for +the current test suite. There are no direct `_refresh_token` or +`_autoteka_access_token` probes in `tests/` today, but the planned shims can still cover +all three fields for compatibility. + +## Paginator Usage + +Command: + +```bash +rg -n "\bPaginator\b" avito +``` + +Domain call sites: + +```text +avito/accounts/domain.py:170: return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) +avito/accounts/domain.py:383: return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) +avito/ads/domain.py:266: return Paginator( +avito/ads/domain.py:1183: return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) +``` + +Infrastructure/import references also found: + +```text +avito/accounts/domain.py:36: Paginator, +avito/ads/domain.py:76: Paginator, +avito/core/pagination.py:183:class Paginator[ItemT]: +avito/core/pagination.py:230:__all__ = ("PaginatedList", "Paginator", "PageFetcher") +avito/core/__init__.py:21:from avito.core.pagination import PaginatedList, Paginator +avito/core/__init__.py:55: "Paginator", +``` + +Decision: current public domain usage ends in `.as_list(...)`; there is no direct +public domain return of `Paginator`. + +## PaginatedList List-API Consumers + +Broad command used to find list-like consumers around current pagination tests and +domains: + +```bash +rg -n "\bPaginatedList\b|\bPaginator\b|\.materialize\(\)|\[[0-9]+\]" \ + avito/accounts/domain.py avito/ads/domain.py \ + tests/domains/accounts/test_accounts.py tests/domains/ads/test_ads.py \ + tests/core/test_transport.py tests/contracts/test_swagger_contracts.py +``` + +Relevant runtime/list-API observations: + +```text +tests/domains/accounts/test_accounts.py:56: assert len(history.materialize()) == 1 +tests/domains/accounts/test_accounts.py:57: assert history[0].operation_type == "payment" +tests/domains/accounts/test_accounts.py:126: assert items[0].title == "Объявление" +tests/domains/ads/test_ads.py:39: assert items[3].item_id == 104 +tests/domains/ads/test_ads.py:41: assert [item.title for item in items.materialize()] == [ +tests/domains/ads/test_ads.py:70: assert [item.item_id for item in items.materialize()] == [101, 102, 103] +tests/domains/ads/test_ads.py:90: assert [item.item_id for item in items.materialize()] == list(range(101, 126)) +tests/core/test_transport.py:752: assert items[0] == 1 +tests/core/test_transport.py:753: assert items[3] == 4 +tests/core/test_transport.py:756: assert items.materialize() == [1, 2, 3, 4, 5] +tests/core/test_transport.py:765: assert empty.materialize() == [] +tests/core/test_transport.py:774: _ = items[2] +tests/contracts/test_swagger_contracts.py:335: assert isinstance(result, PaginatedList) +tests/contracts/test_swagger_contracts.py:336: assert isinstance(result[0], EmployeeItem) +``` + +Decision: async doubles must replace direct indexing/length assumptions with +`await materialize()` or `loaded_count` where the behavior is ported. Existing sync-only +tests stay unchanged. + +## Existing Async Tests + +Command: + +```bash +rg -n "^async def test_" tests +``` + +Result: no matches. + +Decision: enabling `asyncio_mode = "strict"` in M1 will not newly skip any existing +async tests, because none exist today. + +## Deprecated Public Methods + +Command: + +```bash +rg -n "@deprecated_method|deprecated_method\(" avito/cpa avito/ads +``` + +Result: + +```text +avito/cpa/domain.py:491: @deprecated_method( +avito/cpa/domain.py:541: @deprecated_method( +avito/cpa/domain.py:585: @deprecated_method( +avito/ads/domain.py:1416: @deprecated_method( +avito/ads/domain.py:1457: @deprecated_method( +avito/ads/domain.py:1523: @deprecated_method( +avito/ads/domain.py:1558: @deprecated_method( +``` + +Decision: the plan's expected count is current: 3 in `cpa`, 4 in `ads`, 7 total. +M1 must make `deprecated_method` async-aware before M6/M11. + +## OperationSpec Resolver Smoke + +Smoke: + +```python +from avito.core.operations import OperationSpec + +SOME_SPEC = OperationSpec(name="smoke", method="GET", path="/smoke") + +class AsyncSmokeDomain: + async def m(self): + return await self._execute(SOME_SPEC) +``` + +Runner result: + +```text +pass +1 +smoke +``` + +Decision: `_operation_specs_for_sdk_method` currently resolves an async method's +module-level `SOME_SPEC` through `inspect.unwrap(method).__globals__`. M1 does not need +the AST fallback or class-level `__operation_specs__` fallback unless later edits change +this behavior. + +## Reference Builder Join Points + +Current state of `docs/site/assets/_gen_reference.py`: + +```yaml +public_domain_packages: "PACKAGE_ROOT.glob(\"*/domain.py\")" +excluded_packages: ["auth", "core", "testing"] +public_domain_classes: + imports: "avito." + source: "__all__" + class_filter: "issubclass(value, DomainObject)" + module_filter: "value.__module__.startswith(f\"avito.{package}.\")" +public_domain_methods: + predicate: "inspect.isfunction" + public_filter: "not name.startswith(\"_\")" + qualname_filter: "value.__qualname__.startswith(f\"{domain_class.__name__}.\")" +write_domain_pages: "writes one mkdocstrings directive: ::: avito." +``` + +Decision: M1 must extend this to import `domain.py` and `async_domain.py` directly, +filter `AsyncDomainObject` descendants, and write explicit class directives in sync +class -> async class order. + +## Architecture And Docstring Linter Join Points + +Current state of `scripts/lint_architecture.py`: + +```yaml +public_domain_method_paths: "avito//domain.py only" +public_method_ast_node: "ast.FunctionDef only" +collect_domain_class_methods: "ast.FunctionDef only" +``` + +Current state of `scripts/lint_docstrings.py`: + +```yaml +paths: "sorted((root / \"avito\").glob(\"*/domain.py\"))" +public_method_ast_node: "ast.FunctionDef only" +``` + +Decision: M1 must include `async_domain.py` and treat `ast.AsyncFunctionDef` as +equivalent for public async methods and model serializer method collection where relevant. + +## Deprecation Wrapper Join Point + +Current `avito/core/deprecation.py::deprecated_method` always returns a sync +`wrapped(*args, **kwargs)` and directly returns `method(*args, **kwargs)`. + +Decision: M1 must branch on coroutine functions and return an `async def` wrapper that +awaits the original method while preserving `__sdk_deprecation__`. + +## Swagger Factory Join Point + +Current `avito/core/swagger_linter.py::_validate_factory` behavior: + +```yaml +auth_binding_without_factory: "skipped only when binding.domain == \"auth\" and factory is None" +non_auth_without_factory: "SWAGGER_BINDING_FACTORY_MISSING" +factory_lookup: "getattr(AvitoClient, binding.factory, None)" +factory_not_callable: "SWAGGER_BINDING_FACTORY_NOT_FOUND" +signature_check: "_validate_signature_mapping(..., mapping=binding.factory_args)" +variant_awareness: "none" +``` + +Decision: M1 must make this variant-aware and class-gated for async bindings, while +preserving current sync behavior. + +## Baseline + +The exact command from `todo.md` failed because this repository currently has no +`tests/auth` directory: + +```text +ERROR: file or directory not found: tests/auth +``` + +Adjusted collection command: + +```bash +poetry run pytest --collect-only -q tests/core tests/domains tests/contracts | rg '::' > /tmp/baseline_nodeids.txt +``` + +Adjusted baseline execution passed by passing nodeids as exact subprocess argv entries, +because parametrized nodeids include spaces and `$(cat /tmp/baseline_nodeids.txt)` splits +them incorrectly: + +```text +2070 passed in 10.85s +``` + +Baseline files: + +```yaml +baseline_nodeids: + path: "/tmp/baseline_nodeids.txt" + line_count: 2070 + sha256: "373a692216014e9a3cae5c57ccb4e1ca14f94fcf06c484ae8602b141df53a6d9" +baseline_main: + path: "/tmp/baseline_main.txt" + sha256: "1820f9dccbad66227dcf5281ab22333c5c7b6ef2ea2df5c0f1fa0cd858c09023" + result: "2070 passed in 10.85s" +``` + +Decision: use these adjusted baseline hashes for M1 sync-regression comparison unless +`tests/auth` is created before the M1 branch starts. If that happens, rerun pre-flight and +update this artifact. diff --git a/todo.md b/todo.md index 5755860..ea9d2c5 100644 --- a/todo.md +++ b/todo.md @@ -1066,40 +1066,40 @@ reuse across loops." Before opening PR M1 (all of this is done locally and validated before commit): -- [ ] `grep -rn "\._access_token\|\._refresh_token\|\._autoteka_access_token" tests/` — +- [x] `grep -rn "\._access_token\|\._refresh_token\|\._autoteka_access_token" tests/` — record all private probes; ensure that the compat-shim in `AuthProvider` covers each. Currently found case: `tests/core/test_authentication.py:122-127`. -- [ ] `grep -rn "\bPaginator\b" avito/` — record all 4 usage sites +- [x] `grep -rn "\bPaginator\b" avito/` — record all 4 usage sites (`avito/ads/domain.py:266,1183`, `avito/accounts/domain.py:170,383`). All current usage sites end with `.as_list(...)`; there is no direct public return of `Paginator`. `AsyncPaginator.as_list()` is needed by M4 (`accounts`), but a root-level export of `AsyncPaginator` is not needed. -- [ ] `grep -rn "len(.*Paginated\|\\b[a-z_]*list\\[[0-9-]" avito/ tests/` — find all +- [x] `grep -rn "len(.*Paginated\|\\b[a-z_]*list\\[[0-9-]" avito/ tests/` — find all consumers of the list API on `PaginatedList[T]` (indexing, `len`, `bool`, slice). `AsyncPaginatedList` deliberately does NOT replicate the list API: each such case must either be safe (sync-only), or explicitly replaced with `await materialize()` / `loaded_count` in the async double. The list is recorded in the PoC commit message. -- [ ] `grep -rn "^async def test_" tests/` — ensure that existing tests have no +- [x] `grep -rn "^async def test_" tests/` — ensure that existing tests have no async functions without `@pytest.mark.asyncio`. After enabling `asyncio_mode = "strict"`, any such test will start being ignored (warning, not failure). If found — add the marker in a pre-flight commit, separately from M1. -- [ ] Confirm the minimum supported Python version in `pyproject.toml`. The SDK already +- [x] Confirm the minimum supported Python version in `pyproject.toml`. The SDK already uses PEP 695 (`type PageFetcher[ItemT] = ...` in `avito/core/pagination.py:10`), which means Python **3.12+** is required. All async contracts (`type AsyncPageFetcher`, `async def execute[ResponseT]`) keep this same floor; raising it is unnecessary, but explicitly recorded in the M1 PR description. -- [ ] Baseline run on a clean `main` — save **nodeids of existing tests** and +- [x] Baseline run on a clean `main` — save **nodeids of existing tests** and their pass/fail statuses: `poetry run pytest --collect-only -q tests/core tests/auth tests/domains tests/contracts | grep '::' > /tmp/baseline_nodeids.txt` and then `poetry run pytest -q --tb=no $(cat /tmp/baseline_nodeids.txt) > /tmp/baseline_main.txt`. Used in the M1 DoD; new async tests after M1 do not enter the baseline comparison. -- [ ] Verify that `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) +- [x] Verify that `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) works with `async_domain.py`: a test stub with `async def m(self): return self._execute(SOME_SPEC)` and `from ...operations import SOME_SPEC` — the function must find `SOME_SPEC` via `unwrapped_method.__globals__`. If it does not work — extend the function (Phase 1b), otherwise leave unchanged. -- [ ] Read `docs/site/assets/_gen_reference.py` in full and record +- [x] Read `docs/site/assets/_gen_reference.py` in full and record existing filter points: `PACKAGE_ROOT.glob("*/domain.py")`, `EXCLUDED_PACKAGES`, `public_domain_classes()` (filter by `DomainObject` inheritance and `value.__module__.startswith(f"avito.{package}.")`), `public_domain_methods()` @@ -1110,25 +1110,25 @@ Before opening PR M1 (all of this is done locally and validated before commit): `write_domain_pages()` must move to explicit class directives sync → async and not rely solely on `avito..__all__`. Without this, the reference will be asymmetric. -- [ ] Read `scripts/lint_architecture.py` and `scripts/lint_docstrings.py`: +- [x] Read `scripts/lint_architecture.py` and `scripts/lint_docstrings.py`: current checks look only at `domain.py` and `ast.FunctionDef`. M1 must extend them to `async_domain.py` and `ast.AsyncFunctionDef`. -- [ ] Read `avito/core/deprecation.py`: the current `deprecated_method` returns a +- [x] Read `avito/core/deprecation.py`: the current `deprecated_method` returns a sync wrapper. M1 must add an async-aware wrapper before porting the deprecated methods of `cpa`/`ads`. -- [ ] `grep -rn "@deprecated_method\|deprecated_method(" avito/cpa/ avito/ads/` — +- [x] `grep -rn "@deprecated_method\|deprecated_method(" avito/cpa/ avito/ads/` — record the **exact** number of sync deprecated methods that require async doubles. At the time of writing the plan: 3 in `avito/cpa/domain.py:491,541,585` and 4 in `avito/ads/domain.py:1416,1457,1523,1558` — totaling 7. The async-aware wrapper in `deprecation.py` is a mandatory artifact of M1, without which M6 (`cpa`) and M11 (`ads`) cannot close. If the actual number diverges from the recorded one — update the sequencing table and DoD M6/M11 before the start of M1. -- [ ] Read `avito/core/swagger_linter.py::_validate_factory` in full and record +- [x] Read `avito/core/swagger_linter.py::_validate_factory` in full and record current behavior: which fields of the binding it gates on (`factory`, `factory_args`), how it resolves the factory on `AvitoClient`, what it considers an error. M1 must extend it with class-gated coverage (see Swagger section). Without full understanding of the current logic, the extension risks weakening the invariant for sync bindings. -- [ ] **Run pre-flight locally, record results in a tracked artifact**: +- [x] **Run pre-flight locally, record results in a tracked artifact**: a new file `docs/dev/preflight-async-m1.md` is created and committed in a separate pre-flight commit (before opening M1) capturing **all** of the following in machine-readable form: From 5b2908ad06e41849c62dd77c7917b5fcaf627f5a Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 14:43:26 +0300 Subject: [PATCH 13/26] =?UTF-8?q?M1=20=E2=80=94=20Foundation=20(1=20PR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.d/.gitkeep | 1 + CHANGELOG.d/README.md | 12 + CHANGELOG.md | 3 + Makefile | 5 +- STYLEGUIDE.md | 10 +- avito/__init__.py | 4 + avito/async_client.py | 171 +++++++ avito/auth/__init__.py | 5 + avito/auth/_cache.py | 69 +++ avito/auth/async_provider.py | 185 ++++++++ avito/auth/async_token_client.py | 194 ++++++++ avito/auth/provider.py | 69 ++- avito/core/__init__.py | 18 +- avito/core/_async_rate_limit.py | 46 ++ avito/core/_transport_shared.py | 427 ++++++++++++++++++ avito/core/async_pagination.py | 171 +++++++ avito/core/async_transport.py | 328 ++++++++++++++ avito/core/deprecation.py | 20 +- avito/core/domain.py | 62 ++- avito/core/operations.py | 145 ++++++ avito/core/rate_limit.py | 59 +-- avito/core/swagger.py | 7 +- avito/core/swagger_discovery.py | 49 +- avito/core/swagger_linter.py | 23 +- avito/core/swagger_report.py | 48 +- avito/core/transport.py | 34 +- avito/testing/__init__.py | 4 + avito/testing/async_fake_transport.py | 211 +++++++++ avito/testing/async_swagger_fake_transport.py | 73 +++ docs/site/assets/_gen_reference.py | 32 +- .../explanations/domain-architecture-v2.md | 9 +- .../explanations/swagger-binding-subsystem.md | 23 +- poetry.lock | 21 +- pyproject.toml | 3 + scripts/lint_architecture.py | 135 +++--- scripts/lint_async_parity.py | 96 ++++ scripts/lint_docstrings.py | 10 +- tests/async_fake_transport.py | 7 + tests/auth/test_async_provider.py | 33 ++ .../contracts/test_async_swagger_contracts.py | 14 + tests/contracts/test_swagger_contracts.py | 8 +- tests/core/test_async_client_lifecycle.py | 33 ++ tests/core/test_async_executor.py | 137 ++++++ tests/core/test_async_pagination.py | 42 ++ tests/core/test_async_transport.py | 97 ++++ tests/core/test_swagger_linter.py | 20 +- todo.md | 47 +- 47 files changed, 2972 insertions(+), 248 deletions(-) create mode 100644 CHANGELOG.d/.gitkeep create mode 100644 CHANGELOG.d/README.md create mode 100644 avito/async_client.py create mode 100644 avito/auth/_cache.py create mode 100644 avito/auth/async_provider.py create mode 100644 avito/auth/async_token_client.py create mode 100644 avito/core/_async_rate_limit.py create mode 100644 avito/core/_transport_shared.py create mode 100644 avito/core/async_pagination.py create mode 100644 avito/core/async_transport.py create mode 100644 avito/testing/async_fake_transport.py create mode 100644 avito/testing/async_swagger_fake_transport.py create mode 100644 scripts/lint_async_parity.py create mode 100644 tests/async_fake_transport.py create mode 100644 tests/auth/test_async_provider.py create mode 100644 tests/contracts/test_async_swagger_contracts.py create mode 100644 tests/core/test_async_client_lifecycle.py create mode 100644 tests/core/test_async_executor.py create mode 100644 tests/core/test_async_pagination.py create mode 100644 tests/core/test_async_transport.py diff --git a/CHANGELOG.d/.gitkeep b/CHANGELOG.d/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/CHANGELOG.d/.gitkeep @@ -0,0 +1 @@ + diff --git a/CHANGELOG.d/README.md b/CHANGELOG.d/README.md new file mode 100644 index 0000000..3fdbf8f --- /dev/null +++ b/CHANGELOG.d/README.md @@ -0,0 +1,12 @@ +# Changelog fragments + +Domain async PRs after M1 add one fragment named `-async-.md`. + +Supported format: + +```markdown +### Added +- Async-поддержка домена : Async, Async (#) +``` + +M-final aggregates fragments into `CHANGELOG.md` under `## [2.1.0]`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b5468..608224e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to Semantic Versioning. ## [Unreleased] ### Added +- Фундамент Async API: `AsyncTransport`, `AsyncAuthProvider`, + `AsyncOperationExecutor`, `AsyncPaginatedList`, `AsyncAvitoClient` без + доменных factory-методов; `RateLimitState` вынесен в shared. - Добавлен `ClientClosedError` для вызовов после `AvitoClient.close()`. ### Deprecated diff --git a/Makefile b/Makefile index 7171da2..0c65698 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ MKDOCS_ENV=DISABLE_MKDOCS_2_WARNING=true NO_MKDOCS_2_WARNING=1 check: test quality -quality: typecheck lint swagger-lint architecture-lint docstring-lint build +quality: typecheck lint swagger-lint architecture-lint async-parity-lint docstring-lint build build: clean poetry build @@ -41,6 +41,9 @@ swagger-lint: architecture-lint: poetry run python scripts/lint_architecture.py +async-parity-lint: + poetry run python scripts/lint_async_parity.py + docstring-lint: poetry run python scripts/lint_docstrings.py diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index afd0631..b7f110d 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -65,6 +65,7 @@ avito/ ratings/ __init__.py domain.py + async_domain.py operations.py models.py ``` @@ -96,7 +97,8 @@ Rules: - Each API section lives in its own package: `ads`, `messenger`, `orders`, `autoload`, etc. - Only modules belonging to that section are allowed inside each section package. - `avito/client.py` and `avito/__init__.py` contain only the high-level entry point and public exports. -- `domain.py` contains public `DomainObject` classes, explicit public methods, reference-ready docstrings, `@swagger_operation(...)` bindings, business validation, and construction of internal request models. +- `domain.py` contains public sync `DomainObject` classes, explicit public methods, reference-ready docstrings, `@swagger_operation(..., variant="sync")` bindings, business validation, and construction of internal request models. +- `async_domain.py` is the allowed async companion for ported domains. It contains `AsyncDomainObject` classes named `Async` and mirrors the sync public methods with `async def`, `AsyncPaginatedList[T]` where sync returns `PaginatedList[T]`, and `@swagger_operation(..., variant="async")`. - `operations.py` or `operations/` contains internal `OperationSpec` definitions: HTTP method, path, operation name, retry policy, path rendering, request model class, response model class, and pagination/binary/multipart strategy when applicable. - `models.py` or `models/` contains public response dataclasses, internal request/query dataclasses, colocated enum types, `from_payload()`, `to_payload()`, `to_params()`, and normalization logic. - API domains must not introduce `client.py`, `mappers.py`, or standalone @@ -372,8 +374,8 @@ Rules: Recommendation: -- Build a high-quality sync SDK first. -- The SDK is synchronous — this must be explicitly documented in the README and public API. +- Build a high-quality dual-mode SDK: sync remains the default stable surface, and async is exposed through explicit `Async*` classes. +- The SDK has separate sync and async public surfaces. Async code must not wrap sync network calls; it uses `httpx.AsyncClient`, `AsyncTransport`, `AsyncAuthProvider`, `AsyncOperationExecutor`, and async pagination primitives. ### User-Agent and Client Identification @@ -883,7 +885,7 @@ Rules: - Swagger bindings must not duplicate the API contract. Decorators and binding metadata must not contain request/response schemas, status lists, content types, response models, request models, error models, required fields, path parameter definitions, or query parameter definitions. - Public domain classes that expose bound methods should declare class-level metadata (`__swagger_domain__`, `__swagger_spec__`, `__sdk_factory__`, and when needed `__sdk_factory_args__`) so discovery can resolve bindings without creating `AvitoClient`, reading required environment variables, or doing network work. - The canonical coverage map is generated from Swagger registry plus discovered `@swagger_operation` bindings. Markdown inventory files and hand-written coverage tables must not be used as source of truth. -- Each Swagger operation must resolve to exactly one discovered binding in strict mode. One public SDK method must not have more than one Swagger binding. Stacked `@swagger_operation(...)` decorators and `__swagger_bindings__` metadata are forbidden. +- Each Swagger operation must resolve to exactly one discovered binding per surface variant in strict mode: one `sync` binding and, for ported async classes, one `async` binding. One public SDK method must not have more than one Swagger binding. Stacked `@swagger_operation(...)` decorators and `__swagger_bindings__` metadata are forbidden. - Public method signatures, model field names and types, allowed enum values, and nullable behavior must exactly match the contract in `docs/avito/api/`. - When there is a discrepancy between code and the specification in `docs/avito/api/`, the specification takes priority. - If the upstream API adds a new endpoint or changes an existing one, a corresponding SDK change is mandatory. diff --git a/avito/__init__.py b/avito/__init__.py index d54fa85..0f4ae26 100644 --- a/avito/__init__.py +++ b/avito/__init__.py @@ -1,8 +1,10 @@ """Публичные экспорты пакета SDK для Avito.""" +from avito.async_client import AsyncAvitoClient from avito.auth.settings import AuthSettings from avito.client import AvitoClient from avito.config import AvitoSettings +from avito.core.async_pagination import AsyncPaginatedList from avito.core.exceptions import ( AuthenticationError, AuthorizationError, @@ -34,6 +36,8 @@ __all__ = ( "AccountHealthSummary", "AuthSettings", + "AsyncAvitoClient", + "AsyncPaginatedList", "AuthenticationError", "AuthorizationError", "AvitoClient", diff --git a/avito/async_client.py b/avito/async_client.py new file mode 100644 index 0000000..64db5df --- /dev/null +++ b/avito/async_client.py @@ -0,0 +1,171 @@ +"""Асинхронный высокоуровневый клиент SDK Avito.""" + +from __future__ import annotations + +from pathlib import Path + +import httpx + +from avito.auth.async_provider import AsyncAuthProvider +from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core.async_transport import AsyncTransport +from avito.core.exceptions import ClientClosedError +from avito.core.types import TransportDebugInfo + + +class AsyncAvitoClient: + """Асинхронная публичная точка входа SDK без доменных factory-методов в M1.""" + + def __init__( + self, + settings: AvitoSettings | None = None, + *, + client_id: str | None = None, + client_secret: str | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + if client_id is not None or client_secret is not None: + auth = AuthSettings(client_id=client_id, client_secret=client_secret) + settings = AvitoSettings(auth=auth) + self._closed = False + self._entered = False + self._settings = (settings or AvitoSettings.from_env()).validate_required() + self._external_http_client = http_client + self._auth_provider: AsyncAuthProvider | None = None + self._transport: AsyncTransport | None = None + + @classmethod + def from_env(cls, *, env_file: str | Path | None = ".env") -> AsyncAvitoClient: + """Создает async-клиент из переменных окружения и optional `.env` файла.""" + + return cls(AvitoSettings.from_env(env_file=env_file)) + + @classmethod + def _from_transport( + cls, + settings: AvitoSettings, + *, + transport: AsyncTransport, + auth_provider: AsyncAuthProvider, + ) -> AsyncAvitoClient: + client = cls.__new__(cls) + client._closed = False + client._entered = True + client._settings = settings + client._external_http_client = None + client._auth_provider = auth_provider + client._transport = transport + return client + + async def __aenter__(self) -> AsyncAvitoClient: + self._ensure_open() + if self._entered: + return self + try: + self._auth_provider = self._build_auth_provider() + self._transport = AsyncTransport( + self.settings, + auth_provider=self._auth_provider, + client=self._external_http_client, + ) + self._entered = True + return self + except BaseException: + await self.aclose() + raise + + async def __aexit__(self, *exc: object) -> None: + await self.aclose() + + @property + def settings(self) -> AvitoSettings: + """Возвращает read-only настройки клиента.""" + + return self._settings + + @property + def auth_provider(self) -> AsyncAuthProvider: + """Возвращает read-only auth provider клиента.""" + + self._ensure_ready() + if self._auth_provider is None: + raise RuntimeError("AsyncAvitoClient не инициализирован: используйте 'async with'.") + return self._auth_provider + + @property + def transport(self) -> AsyncTransport: + """Возвращает read-only async transport клиента.""" + + return self._require_transport() + + def auth(self) -> AsyncAuthProvider: + """Возвращает объект аутентификации и async token-flow операций.""" + + self._ensure_open() + return self.auth_provider + + def debug_info(self) -> TransportDebugInfo: + """Возвращает безопасный снимок transport-настроек для диагностики.""" + + return self._require_transport().debug_info() + + async def aclose(self) -> None: + """Закрывает transport и auth-provider; повторный вызов безопасен.""" + + transport = self._transport + auth_provider = self._auth_provider + self._closed = True + self._entered = False + self._transport = None + self._auth_provider = None + if transport is not None: + await transport.aclose() + if auth_provider is not None: + await auth_provider.aclose() + + def _build_auth_provider(self) -> AsyncAuthProvider: + token_client = AsyncTokenClient( + self.settings.auth, + client=self._external_http_client, + sdk_settings=self.settings, + ) + alternate_token_client = AsyncAlternateTokenClient( + self.settings.auth, + client=self._external_http_client, + sdk_settings=self.settings, + ) + autoteka_token_client = AsyncTokenClient( + self.settings.auth, + token_url=self.settings.auth.autoteka_token_url, + client=self._external_http_client, + sdk_settings=self.settings, + ) + return AsyncAuthProvider( + self.settings.auth, + token_client=token_client, + alternate_token_client=alternate_token_client, + autoteka_token_client=autoteka_token_client, + ) + + def _ensure_open(self) -> None: + if self._closed: + raise ClientClosedError("Клиент закрыт; создайте новый AsyncAvitoClient.") + + def _ensure_ready(self) -> None: + self._ensure_open() + if not self._entered: + raise RuntimeError( + "AsyncAvitoClient не инициализирован: используйте 'async with' " + "или дождитесь '__aenter__'." + ) + + def _require_transport(self) -> AsyncTransport: + self._ensure_ready() + if self._transport is None: + raise RuntimeError("AsyncAvitoClient не инициализирован: используйте 'async with'.") + return self._transport + + +__all__ = ("AsyncAvitoClient",) diff --git a/avito/auth/__init__.py b/avito/auth/__init__.py index df3f76d..bc4edcf 100644 --- a/avito/auth/__init__.py +++ b/avito/auth/__init__.py @@ -1,5 +1,7 @@ """Пакет аутентификации.""" +from avito.auth.async_provider import AsyncAuthProvider +from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient from avito.auth.models import ( AccessToken, ClientCredentialsRequest, @@ -12,6 +14,9 @@ __all__ = ( "AccessToken", "AlternateTokenClient", + "AsyncAlternateTokenClient", + "AsyncAuthProvider", + "AsyncTokenClient", "AuthProvider", "AuthSettings", "ClientCredentialsRequest", diff --git a/avito/auth/_cache.py b/avito/auth/_cache.py new file mode 100644 index 0000000..59b0733 --- /dev/null +++ b/avito/auth/_cache.py @@ -0,0 +1,69 @@ +"""Shared OAuth token cache for sync and async auth providers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from avito.auth.models import AccessToken, TokenResponse +from avito.core.exceptions import ResponseMappingError + + +@dataclass(slots=True) +class TokenCache: + """Mutable in-memory token state without IO or locking.""" + + access_token: AccessToken | None = None + refresh_token: str | None = None + autoteka_access_token: AccessToken | None = None + + def access_is_fresh(self, now: datetime) -> bool: + return self.access_token is not None and not self.access_token.is_expired(now) + + def autoteka_is_fresh(self, now: datetime) -> bool: + return self.autoteka_access_token is not None and not self.autoteka_access_token.is_expired( + now + ) + + def reset_access(self) -> None: + self.access_token = None + + def reset_autoteka(self) -> None: + self.autoteka_access_token = None + + +def map_token_response(payload: object, *, now: datetime | None = None) -> TokenResponse: + """Map raw OAuth JSON into a typed token response.""" + + if not isinstance(payload, dict): + raise ResponseMappingError("OAuth-ответ должен быть JSON-объектом.", payload=payload) + + access_token = payload.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise ResponseMappingError("В OAuth-ответе отсутствует `access_token`.", payload=payload) + + raw_expires_in = payload.get("expires_in", 0) + if not isinstance(raw_expires_in, int | float) or isinstance(raw_expires_in, bool): + raise ResponseMappingError("Поле `expires_in` должно быть числом.", payload=payload) + + refresh_token = payload.get("refresh_token") + if refresh_token is not None and not isinstance(refresh_token, str): + raise ResponseMappingError("Поле `refresh_token` должно быть строкой.", payload=payload) + + token_type = payload.get("token_type", "Bearer") + if not isinstance(token_type, str): + raise ResponseMappingError("Поле `token_type` должно быть строкой.", payload=payload) + + issued_at = now or datetime.now(UTC) + return TokenResponse( + access_token=AccessToken( + value=access_token, + expires_at=issued_at + timedelta(seconds=raw_expires_in), + token_type=token_type, + ), + refresh_token=refresh_token, + scope=payload.get("scope") if isinstance(payload.get("scope"), str) else None, + ) + + +__all__ = ("TokenCache", "map_token_response") diff --git a/avito/auth/async_provider.py b/avito/auth/async_provider.py new file mode 100644 index 0000000..3d27530 --- /dev/null +++ b/avito/auth/async_provider.py @@ -0,0 +1,185 @@ +"""Async authentication provider for the SDK.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Protocol + +from avito.auth._cache import TokenCache +from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient +from avito.auth.models import ( + AccessToken, + ClientCredentialsRequest, + RefreshTokenRequest, + TokenResponse, +) +from avito.auth.settings import AuthSettings +from avito.core.exceptions import AuthenticationError, ConfigurationError + + +class AsyncTokenFetcher(Protocol): + """Контракт async-получения нового access token из внешнего источника.""" + + async def __call__(self, settings: AuthSettings) -> TokenResponse: ... + + +@dataclass(slots=True) +class AsyncAuthProvider: + """Поставляет и кэширует токен доступа для async transport-слоя.""" + + settings: AuthSettings + token_client: AsyncTokenClient | None = None + alternate_token_client: AsyncAlternateTokenClient | None = None + autoteka_token_client: AsyncTokenClient | None = None + token_fetcher: AsyncTokenFetcher | None = None + _cache: TokenCache = field(default_factory=TokenCache, init=False, repr=False) + _refresh_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False) + _autoteka_refresh_lock: asyncio.Lock = field( + default_factory=asyncio.Lock, + init=False, + repr=False, + ) + + async def get_access_token(self) -> str: + """Возвращает валидный access token, обновляя кэш при необходимости.""" + + now = datetime.now(UTC) + if not self._cache.access_is_fresh(now): + async with self._refresh_lock: + now = datetime.now(UTC) + if not self._cache.access_is_fresh(now): + token_response = await self.refresh_access_token() + return token_response.access_token.value + access_token = self._cache.access_token + if access_token is None: + raise AuthenticationError("Не удалось получить OAuth access token.") + return access_token.value + + async def refresh_access_token(self) -> TokenResponse: + """Принудительно обновляет токен через refresh token или client credentials.""" + + token_response = await self._fetch_token_response() + self._cache.access_token = token_response.access_token + if token_response.refresh_token is not None: + self._cache.refresh_token = token_response.refresh_token + return token_response + + def invalidate_token(self) -> None: + """Сбрасывает закэшированный токен после `401 Unauthorized`.""" + + self._cache.reset_access() + + async def aclose(self) -> None: + """Закрывает внутренние HTTP-клиенты provider-а.""" + + for client in (self.token_client, self.alternate_token_client, self.autoteka_token_client): + if client is not None: + await client.aclose() + + async def get_autoteka_access_token(self) -> str: + """Возвращает отдельный access token для flow Автотеки.""" + + now = datetime.now(UTC) + if not self._cache.autoteka_is_fresh(now): + async with self._autoteka_refresh_lock: + now = datetime.now(UTC) + if not self._cache.autoteka_is_fresh(now): + token_response = ( + await self._get_autoteka_token_client().request_autoteka_client_credentials_token( + ClientCredentialsRequest( + client_id=self.settings.autoteka_client_id + or self.settings.client_id + or "", + client_secret=self.settings.autoteka_client_secret + or self.settings.client_secret + or "", + scope=self.settings.autoteka_scope, + ) + ) + ) + self._cache.autoteka_access_token = token_response.access_token + token = self._cache.autoteka_access_token + if token is None: + raise AuthenticationError("Не удалось получить OAuth access token для Автотеки.") + return token.value + + def token_flow(self) -> AsyncTokenClient: + """Возвращает canonical async token client для low-level OAuth операций.""" + + return self._get_token_client() + + def alternate_token_flow(self) -> AsyncAlternateTokenClient: + """Возвращает дополнительный async token client для альтернативного `/token` flow.""" + + return self._get_alternate_token_client() + + async def _fetch_token_response(self) -> TokenResponse: + if self.token_fetcher is not None: + token_response = await self.token_fetcher(self.settings) + if isinstance(token_response, AccessToken): + return TokenResponse(access_token=token_response) + return token_response + if self._cache.refresh_token: + return await self._get_token_client().request_refresh_token( + RefreshTokenRequest( + client_id=self._require_client_id(), + client_secret=self._require_client_secret(), + refresh_token=self._cache.refresh_token, + scope=self.settings.scope, + ) + ) + if self.settings.refresh_token: + return await self._get_token_client().request_refresh_token( + RefreshTokenRequest( + client_id=self._require_client_id(), + client_secret=self._require_client_secret(), + refresh_token=self.settings.refresh_token, + scope=self.settings.scope, + ) + ) + return await self._get_token_client().request_client_credentials_token( + ClientCredentialsRequest( + client_id=self._require_client_id(), + client_secret=self._require_client_secret(), + scope=self.settings.scope, + ) + ) + + def _get_token_client(self) -> AsyncTokenClient: + if self.token_client is None: + self.token_client = AsyncTokenClient(self.settings) + if self.token_client is None: + raise ConfigurationError("Не удалось инициализировать OAuth token client.") + return self.token_client + + def _get_alternate_token_client(self) -> AsyncAlternateTokenClient: + if self.alternate_token_client is None: + self.alternate_token_client = AsyncAlternateTokenClient(self.settings) + if self.alternate_token_client is None: + raise ConfigurationError("Не удалось инициализировать alternate OAuth token client.") + return self.alternate_token_client + + def _get_autoteka_token_client(self) -> AsyncTokenClient: + if self.autoteka_token_client is None: + self.autoteka_token_client = AsyncTokenClient( + self.settings, + token_url=self.settings.autoteka_token_url, + ) + if self.autoteka_token_client is None: + raise ConfigurationError("Не удалось инициализировать OAuth token client для Автотеки.") + return self.autoteka_token_client + + def _require_client_id(self) -> str: + if self.settings.client_id is None: + raise AuthenticationError("Для OAuth flow не задан `client_id`.") + return self.settings.client_id + + def _require_client_secret(self) -> str: + if self.settings.client_secret is None: + raise AuthenticationError("Для OAuth flow не задан `client_secret`.") + return self.settings.client_secret + + +__all__ = ("AsyncAuthProvider", "AsyncTokenFetcher") diff --git a/avito/auth/async_token_client.py b/avito/auth/async_token_client.py new file mode 100644 index 0000000..7443537 --- /dev/null +++ b/avito/auth/async_token_client.py @@ -0,0 +1,194 @@ +"""Async OAuth token-flow clients.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + +from avito.auth._cache import map_token_response +from avito.auth.models import ClientCredentialsRequest, RefreshTokenRequest, TokenResponse +from avito.auth.provider import CLIENT_CREDENTIALS_GRANT, REFRESH_TOKEN_GRANT +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core.exceptions import AuthenticationError, AvitoError +from avito.core.swagger import swagger_operation +from avito.core.types import RequestContext + + +@dataclass(slots=True, frozen=True) +class AsyncTokenClient: + """Служебный async-клиент для canonical OAuth token endpoint.""" + + __swagger_domain__ = "auth" + + settings: AuthSettings + token_url: str | None = None + client: httpx.AsyncClient | None = None + sdk_settings: AvitoSettings | None = None + + async def aclose(self) -> None: + """Закрывает выделенный HTTP-клиент, если он был передан снаружи.""" + + if self.client is not None: + await self.client.aclose() + + @swagger_operation( + "POST", + "/token", + spec="Авторизация.json", + operation_id="getAccessToken", + method_args={"request": "body"}, + variant="async", + ) + async def request_client_credentials_token( + self, + request: ClientCredentialsRequest, + ) -> TokenResponse: + """Запрашивает access token по flow `client_credentials`.""" + + payload: dict[str, str] = { + "grant_type": CLIENT_CREDENTIALS_GRANT, + "client_id": request.client_id, + "client_secret": request.client_secret, + } + if request.scope is not None: + payload["scope"] = request.scope + return await self._request_token(payload) + + @swagger_operation( + "POST", + "/token", + spec="Автотека.json", + operation_id="getAccessToken", + method_args={"request": "query.grant_type"}, + variant="async", + ) + async def request_autoteka_client_credentials_token( + self, + request: ClientCredentialsRequest, + ) -> TokenResponse: + """Запрашивает access token по отдельному flow Автотеки.""" + + return await self.request_client_credentials_token(request) + + async def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResponse: + """Запрашивает новый access token по flow `refresh_token`.""" + + payload: dict[str, str] = { + "grant_type": REFRESH_TOKEN_GRANT, + "client_id": request.client_id, + "client_secret": request.client_secret, + "refresh_token": request.refresh_token, + } + if request.scope is not None: + payload["scope"] = request.scope + return await self._request_token(payload) + + async def _request_token(self, payload: dict[str, str]) -> TokenResponse: + from avito.core.async_transport import AsyncTransport + + transport = AsyncTransport( + self.sdk_settings or AvitoSettings(auth=self.settings), + auth_provider=None, + client=self.client, + ) + try: + response = await transport.request( + "POST", + self.token_url or self.settings.token_url, + context=RequestContext("auth.oauth_token", requires_auth=False), + data=payload, + headers={"Accept": "application/json"}, + ) + except AuthenticationError: + raise + except AvitoError as exc: + raise AuthenticationError( + exc.message, + status_code=exc.status_code, + error_code=exc.error_code, + operation=exc.operation, + attempt=exc.attempt, + method=exc.method, + endpoint=exc.endpoint, + details=exc.details, + retry_after=exc.retry_after, + request_id=exc.request_id, + metadata=exc.metadata, + payload=exc.payload, + headers=exc.headers, + ) from exc + finally: + if self.client is None: + await transport.aclose() + + try: + payload_object = response.json() + except ValueError as exc: + raise AuthenticationError( + "OAuth-сервер вернул некорректный JSON.", + status_code=response.status_code, + payload=response.text, + headers=dict(response.headers), + ) from exc + return map_token_response(payload_object) + + +@dataclass(slots=True, frozen=True) +class AsyncAlternateTokenClient: + """Служебный async-клиент для альтернативного token endpoint из swagger.""" + + __swagger_domain__ = "auth" + + settings: AuthSettings + client: httpx.AsyncClient | None = None + sdk_settings: AvitoSettings | None = None + + async def aclose(self) -> None: + """Закрывает выделенный HTTP-клиент альтернативного token flow.""" + + if self.client is not None: + await self.client.aclose() + + @swagger_operation( + "POST", + "/token\u200e", + spec="Авторизация.json", + operation_id="getAccessTokenAuthorizationCode", + method_args={"request": "body"}, + variant="async", + ) + async def request_client_credentials_token( + self, + request: ClientCredentialsRequest, + ) -> TokenResponse: + """Запрашивает токен через альтернативный canonical `/token`.""" + + return await AsyncTokenClient( + self.settings, + token_url=self.settings.alternate_token_url, + client=self.client, + sdk_settings=self.sdk_settings, + ).request_client_credentials_token(request) + + @swagger_operation( + "POST", + "/token\u200e\u200e", + spec="Авторизация.json", + operation_id="refreshAccessTokenAuthorizationCode", + method_args={"request": "body"}, + variant="async", + ) + async def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResponse: + """Обновляет токен через альтернативный canonical `/token`.""" + + return await AsyncTokenClient( + self.settings, + token_url=self.settings.alternate_token_url, + client=self.client, + sdk_settings=self.sdk_settings, + ).request_refresh_token(request) + + +__all__ = ("AsyncAlternateTokenClient", "AsyncTokenClient") diff --git a/avito/auth/provider.py b/avito/auth/provider.py index cbf946a..0534362 100644 --- a/avito/auth/provider.py +++ b/avito/auth/provider.py @@ -3,11 +3,12 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from typing import Protocol import httpx +from avito.auth._cache import TokenCache, map_token_response from avito.auth.models import ( AccessToken, ClientCredentialsRequest, @@ -20,7 +21,6 @@ AuthenticationError, AvitoError, ConfigurationError, - ResponseMappingError, ) from avito.core.swagger import swagger_operation from avito.core.transport import Transport @@ -32,36 +32,7 @@ _UNSET = object() -def _map_token_response(payload: object, *, now: datetime | None = None) -> TokenResponse: - if not isinstance(payload, dict): - raise ResponseMappingError("OAuth-ответ должен быть JSON-объектом.", payload=payload) - - access_token = payload.get("access_token") - if not isinstance(access_token, str) or not access_token: - raise ResponseMappingError("В OAuth-ответе отсутствует `access_token`.", payload=payload) - - raw_expires_in = payload.get("expires_in", 0) - if not isinstance(raw_expires_in, int | float) or isinstance(raw_expires_in, bool): - raise ResponseMappingError("Поле `expires_in` должно быть числом.", payload=payload) - - refresh_token = payload.get("refresh_token") - if refresh_token is not None and not isinstance(refresh_token, str): - raise ResponseMappingError("Поле `refresh_token` должно быть строкой.", payload=payload) - - token_type = payload.get("token_type", "Bearer") - if not isinstance(token_type, str): - raise ResponseMappingError("Поле `token_type` должно быть строкой.", payload=payload) - - issued_at = now or datetime.now(UTC) - return TokenResponse( - access_token=AccessToken( - value=access_token, - expires_at=issued_at + timedelta(seconds=raw_expires_in), - token_type=token_type, - ), - refresh_token=refresh_token, - scope=payload.get("scope") if isinstance(payload.get("scope"), str) else None, - ) +_map_token_response = map_token_response class TokenFetcher(Protocol): @@ -79,9 +50,37 @@ class AuthProvider: alternate_token_client: AlternateTokenClient | None = None autoteka_token_client: TokenClient | None = None token_fetcher: TokenFetcher | None = None - _access_token: AccessToken | None = field(default=None, init=False, repr=False) - _refresh_token: str | None = field(default=None, init=False, repr=False) - _autoteka_access_token: AccessToken | None = field(default=None, init=False, repr=False) + _cache: TokenCache = field(default_factory=TokenCache, init=False, repr=False) + + @property + def _access_token(self) -> AccessToken | None: + """Legacy private accessor kept for existing tests.""" + + return self._cache.access_token + + @_access_token.setter + def _access_token(self, value: AccessToken | None) -> None: + self._cache.access_token = value + + @property + def _refresh_token(self) -> str | None: + """Legacy private accessor kept for existing tests.""" + + return self._cache.refresh_token + + @_refresh_token.setter + def _refresh_token(self, value: str | None) -> None: + self._cache.refresh_token = value + + @property + def _autoteka_access_token(self) -> AccessToken | None: + """Legacy private accessor kept for existing tests.""" + + return self._cache.autoteka_access_token + + @_autoteka_access_token.setter + def _autoteka_access_token(self, value: AccessToken | None) -> None: + self._cache.autoteka_access_token = value def get_access_token(self) -> str: """Возвращает валидный access token, обновляя кэш при необходимости.""" diff --git a/avito/core/__init__.py b/avito/core/__init__.py index 0f3ee50..5f2696a 100644 --- a/avito/core/__init__.py +++ b/avito/core/__init__.py @@ -1,6 +1,8 @@ """Пакет общей инфраструктуры SDK.""" -from avito.core.domain import DomainObject +from avito.core.async_pagination import AsyncPaginatedList, AsyncPaginator +from avito.core.async_transport import AsyncTransport +from avito.core.domain import AsyncDomainObject, DomainObject from avito.core.exceptions import ( AuthenticationError, AuthorizationError, @@ -17,7 +19,13 @@ ) from avito.core.fields import api_field from avito.core.models import ApiErrorPayload, ApiModel, EmptyRequest, RequestModel -from avito.core.operations import EmptyResponse, OperationExecutor, OperationSpec +from avito.core.operations import ( + AsyncOperationExecutor, + AsyncOperationTransport, + EmptyResponse, + OperationExecutor, + OperationSpec, +) from avito.core.pagination import PaginatedList, Paginator from avito.core.payload import JsonReader from avito.core.retries import RetryDecision, RetryPolicy @@ -37,6 +45,12 @@ "ApiTimeouts", "ApiModel", "ApiErrorPayload", + "AsyncDomainObject", + "AsyncOperationExecutor", + "AsyncOperationTransport", + "AsyncPaginatedList", + "AsyncPaginator", + "AsyncTransport", "AuthenticationError", "AuthorizationError", "AvitoError", diff --git a/avito/core/_async_rate_limit.py b/avito/core/_async_rate_limit.py new file mode 100644 index 0000000..a29feec --- /dev/null +++ b/avito/core/_async_rate_limit.py @@ -0,0 +1,46 @@ +"""Async rate limiter for the transport layer.""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import Awaitable, Callable, Mapping + +from avito.core._transport_shared import RateLimitState +from avito.core.retries import RetryPolicy + + +class AsyncRateLimiter: + """Async token bucket over shared `RateLimitState`.""" + + def __init__( + self, + policy: RetryPolicy, + *, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + ) -> None: + self._clock = clock + self._sleep = sleep + self._state = RateLimitState.from_policy(policy, now=clock()) + self._lock = asyncio.Lock() + + async def acquire(self) -> float: + """Wait until a request may be sent and return the total delay.""" + + total_delay = 0.0 + async with self._lock: + while True: + delay = self._state.compute_delay(self._clock()) + if delay <= 0.0: + return total_delay + await self._sleep(delay) + total_delay += delay + + def observe_response(self, *, headers: Mapping[str, str]) -> None: + """Update cooldown from response headers.""" + + self._state.observe_response(now=self._clock(), headers=headers) + + +__all__ = ("AsyncRateLimiter",) diff --git a/avito/core/_transport_shared.py b/avito/core/_transport_shared.py new file mode 100644 index 0000000..d8feda8 --- /dev/null +++ b/avito/core/_transport_shared.py @@ -0,0 +1,427 @@ +"""Shared pure helpers for sync and async transport implementations.""" + +from __future__ import annotations + +import importlib.metadata as importlib_metadata +import json +import platform +import time +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from email.message import Message +from email.utils import parsedate_to_datetime +from io import BytesIO +from typing import cast +from urllib.parse import quote, urlsplit + +import httpx + +from avito.core.exceptions import ( + AuthenticationError, + AuthorizationError, + AvitoError, + ConflictError, + RateLimitError, + UnsupportedOperationError, + UpstreamApiError, + ValidationError, +) +from avito.core.retries import RetryDecision, RetryPolicy +from avito.core.types import ApiTimeouts, RequestContext + +QueryScalar = str | int | float | bool | None +QueryParamValue = QueryScalar | Sequence[QueryScalar] +QueryParams = Mapping[str, QueryParamValue] +FileValue = ( + BytesIO + | bytes + | str + | tuple[str | None, BytesIO | bytes | str] + | tuple[str | None, BytesIO | bytes | str, str | None] + | tuple[str | None, BytesIO | bytes | str, str | None, Mapping[str, str]] +) +RequestFiles = Mapping[str, FileValue] + +_MIN_RETRY_AFTER_SECONDS = 0.5 + + +@dataclass(slots=True) +class RateLimitState: + """Pure token-bucket state shared by sync and async rate limiters.""" + + enabled: bool + rate: float + capacity: int + tokens: float + updated_at: float + blocked_until: float = 0.0 + + @classmethod + def from_policy(cls, policy: RetryPolicy, *, now: float) -> RateLimitState: + capacity = max(policy.rate_limit_burst, 0) + return cls( + enabled=policy.rate_limit_enabled, + rate=max(policy.rate_limit_requests_per_second, 0.0), + capacity=capacity, + tokens=float(capacity), + updated_at=now, + ) + + def compute_delay(self, now: float) -> float: + """Return required delay and reserve a token when it can proceed now.""" + + if not self.enabled or self.rate <= 0.0 or self.capacity <= 0: + return 0.0 + self._refill(now) + blocked_delay = max(self.blocked_until - now, 0.0) + if blocked_delay > 0.0: + return blocked_delay + if self.tokens >= 1.0: + self.tokens -= 1.0 + return 0.0 + return (1.0 - self.tokens) / self.rate + + def observe_response(self, *, now: float, headers: Mapping[str, str]) -> None: + """Update cooldown from upstream rate-limit headers.""" + + if not self.enabled or self.rate <= 0.0: + return + remaining = _get_header(headers, "x-ratelimit-remaining") + if remaining is None: + return + try: + remaining_count = int(remaining) + except ValueError: + return + if remaining_count <= 0: + self.blocked_until = max(self.blocked_until, now + 1.0 / self.rate) + self.tokens = min(self.tokens, 0.0) + + def _refill(self, now: float) -> None: + elapsed = max(now - self.updated_at, 0.0) + if elapsed > 0.0: + self.tokens = min(float(self.capacity), self.tokens + elapsed * self.rate) + self.updated_at = now + + +def build_httpx_timeout(timeouts: ApiTimeouts) -> httpx.Timeout: + """Convert SDK timeout config to `httpx.Timeout`.""" + + return httpx.Timeout( + connect=timeouts.connect, + read=timeouts.read, + write=timeouts.write, + pool=timeouts.pool, + ) + + +def normalize_path(path: str) -> str: + stripped = path.strip() + if not stripped: + return "/" + if stripped.startswith("http://") or stripped.startswith("https://"): + return stripped + has_trailing_slash = stripped.endswith("/") + segments = [quote(segment, safe=":@%") for segment in stripped.strip("/").split("/") if segment] + normalized = "/" + "/".join(segments) + if has_trailing_slash and normalized != "/": + normalized += "/" + return normalized + + +def normalize_params(params: Mapping[str, object] | None) -> QueryParams | None: + if params is None: + return None + normalized: dict[str, QueryParamValue] = {} + for key, value in params.items(): + if value is None: + continue + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + normalized[key] = [normalize_query_scalar(item) for item in value] + else: + normalized[key] = normalize_query_scalar(value) + return normalized + + +def normalize_query_scalar(value: object) -> QueryScalar: + if isinstance(value, str | int | float | bool): + return value + return str(value) + + +def normalize_files(files: Mapping[str, object] | None) -> RequestFiles | None: + if files is None: + return None + return {key: normalize_file_value(value) for key, value in files.items()} + + +def normalize_file_value(value: object) -> FileValue: + if isinstance(value, bytes | str | BytesIO): + return value + if isinstance(value, tuple): + return value + raise TypeError("Неподдерживаемый тип файла для multipart upload.") + + +def merge_headers( + *, + context: RequestContext, + headers: Mapping[str, str] | None, + idempotency_key: str | None, + user_agent: str, + bearer_token: str | None, +) -> dict[str, str]: + """Merge request headers with an already resolved bearer token.""" + + merged: dict[str, str] = { + "Accept": "application/json", + "User-Agent": user_agent, + } + merged.update(dict(context.headers)) + if headers is not None: + merged.update(dict(headers)) + if idempotency_key is not None: + merged["Idempotency-Key"] = idempotency_key + if bearer_token is not None: + merged["Authorization"] = f"Bearer {bearer_token}" + return merged + + +def build_user_agent(user_agent_suffix: str | None) -> str: + try: + package_version = importlib_metadata.version("avito-py") + except importlib_metadata.PackageNotFoundError: + package_version = "0+unknown" + user_agent = ( + f"avito-py/{package_version} " + f"python/{platform.python_version()} " + f"httpx/{httpx.__version__}" + ) + if user_agent_suffix is not None: + user_agent += f" {user_agent_suffix}" + return user_agent + + +def decide_transport_retry( + *, + retry_policy: RetryPolicy, + method: str, + attempt: int, + context: RequestContext, + is_timeout: bool, + idempotency_key: str | None, +) -> RetryDecision: + if attempt >= retry_policy.max_attempts: + return RetryDecision(False) + if not retry_policy.retry_on_transport_error: + return RetryDecision(False) + if not is_retryable_request( + retry_policy=retry_policy, + method=method, + context=context, + idempotency_key=idempotency_key, + ): + return RetryDecision(False) + return RetryDecision( + True, + reason="timeout" if is_timeout else "transport_error", + delay_seconds=retry_policy.compute_backoff(attempt), + ) + + +def decide_http_retry( + *, + retry_policy: RetryPolicy, + method: str, + attempt: int, + context: RequestContext, + response: httpx.Response, + idempotency_key: str | None, +) -> RetryDecision: + if attempt >= retry_policy.max_attempts: + return RetryDecision(False) + if not is_retryable_request( + retry_policy=retry_policy, + method=method, + context=context, + idempotency_key=idempotency_key, + ): + return RetryDecision(False) + if response.status_code == 429: + if not retry_policy.retry_on_rate_limit: + return RetryDecision(False) + delay = get_retry_after_seconds(response.headers) + if response.headers.get("retry-after") is None: + delay = retry_policy.compute_backoff(attempt) + if delay > retry_policy.max_rate_limit_wait_seconds: + return RetryDecision(False) + return RetryDecision(True, reason="rate_limit", delay_seconds=delay) + if 500 <= response.status_code < 600 and retry_policy.retry_on_server_error: + return RetryDecision( + True, + reason="server_error", + delay_seconds=retry_policy.compute_backoff(attempt), + ) + return RetryDecision(False) + + +def is_retryable_request( + *, + retry_policy: RetryPolicy, + method: str, + context: RequestContext, + idempotency_key: str | None, +) -> bool: + if context.retry_disabled: + return False + normalized_method = method.upper() + if normalized_method in {"POST", "PATCH"} and idempotency_key is None: + return False + if normalized_method == "DELETE" and idempotency_key is None and not context.allow_retry: + return False + return retry_policy.is_retryable_method(normalized_method, explicit_retry=context.allow_retry) + + +def map_http_error( + response: httpx.Response, + *, + operation: str | None = None, + attempt: int | None = None, +) -> Exception: + payload = safe_payload(response) + message = extract_message(payload) or f"HTTP {response.status_code}" + error_code = extract_error_code(payload) + details = extract_error_details(payload) + retry_after = get_retry_after_seconds(response.headers) if response.status_code == 429 else None + request_id = extract_request_id(response.headers) + headers = dict(response.headers) + method = response.request.method + endpoint = response.request.url.path + metadata = {"method": method, "path": endpoint} + error_type: type[AvitoError] + if response.status_code == 401: + error_type = AuthenticationError + elif response.status_code == 403: + error_type = AuthorizationError + elif response.status_code in {400, 422}: + error_type = ValidationError + elif response.status_code == 409: + error_type = ConflictError + elif response.status_code == 429: + error_type = RateLimitError + elif response.status_code in {405, 501}: + error_type = UnsupportedOperationError + else: + error_type = UpstreamApiError + return error_type( + message, + status_code=response.status_code, + error_code=error_code, + operation=operation, + attempt=attempt, + method=method, + endpoint=endpoint, + details=details, + retry_after=retry_after, + request_id=request_id, + metadata=metadata, + payload=payload, + headers=headers, + ) + + +def safe_payload(response: httpx.Response) -> object: + content_type = response.headers.get("content-type", "") + if "application/json" in content_type: + try: + return response.json() + except json.JSONDecodeError: + return response.text + return response.text + + +def extract_message(payload: object) -> str | None: + if isinstance(payload, dict): + for key in ("message", "error_description", "error", "detail"): + value = payload.get(key) + if isinstance(value, str) and value: + return value + if isinstance(payload, str) and payload: + return payload + return None + + +def extract_error_code(payload: object) -> str | None: + if not isinstance(payload, dict): + return None + value = payload.get("code") or payload.get("error") + return value if isinstance(value, str) else None + + +def extract_error_details(payload: object) -> object | None: + if not isinstance(payload, Mapping): + return None + for key in ("details", "fields", "errors", "violations"): + value = payload.get(key) + if value is not None: + return cast(object, value) + return None + + +def extract_request_id(headers: Mapping[str, str]) -> str | None: + for key in ("x-request-id", "x-correlation-id", "x-amzn-requestid"): + value = headers.get(key) + if value: + return value + return None + + +def get_retry_after_seconds(headers: Mapping[str, str]) -> float: + raw_value = headers.get("retry-after") + if raw_value is None: + return _MIN_RETRY_AFTER_SECONDS + try: + return max(float(raw_value), 0.0) + except ValueError: + try: + retry_at = parsedate_to_datetime(raw_value) + except (TypeError, ValueError): + return _MIN_RETRY_AFTER_SECONDS + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=UTC) + return max((retry_at - datetime.now(UTC)).total_seconds(), 0.0) + + +def elapsed_ms(started_at: float) -> int: + return max(int((time.perf_counter() - started_at) * 1000), 0) + + +def safe_endpoint(endpoint: str) -> str: + parsed = urlsplit(endpoint) + if parsed.scheme or parsed.netloc: + return parsed.path or "/" + return endpoint + + +def extract_filename(content_disposition: str | None) -> str | None: + if content_disposition is None: + return None + message = Message() + message["content-disposition"] = content_disposition + filename = message.get_param("filename", header="content-disposition") + if isinstance(filename, tuple): + _, _, decoded_value = filename + return decoded_value + return filename + + +def _get_header(headers: Mapping[str, str], name: str) -> str | None: + value = headers.get(name) + if value is not None: + return value + lowered_name = name.lower() + for key, item in headers.items(): + if key.lower() == lowered_name: + return item + return None diff --git a/avito/core/async_pagination.py b/avito/core/async_pagination.py new file mode 100644 index 0000000..408dfad --- /dev/null +++ b/avito/core/async_pagination.py @@ -0,0 +1,171 @@ +"""Асинхронные абстракции пагинации для типизированных ответов SDK.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable + +from avito.core.types import JsonPage + +type AsyncPageFetcher[ItemT] = Callable[[int | None, str | None], Awaitable[JsonPage[ItemT]]] + + +class AsyncPaginatedList[ItemT]: + """Ленивый async-контейнер страниц без list API.""" + + def __init__( + self, + fetch_page: AsyncPageFetcher[ItemT], + *, + start_page: int = 1, + first_page: JsonPage[ItemT] | None = None, + ) -> None: + self._fetch_page = fetch_page + self._items: list[ItemT] = [] + self._known_total: int | None = None + self._source_total: int | None = None + self._next_page_number: int | None = start_page + self._next_cursor: str | None = None + self._exhausted = False + self._active_iterator = False + if first_page is not None: + self._consume_page(first_page) + + def __aiter__(self) -> AsyncIterator[ItemT]: + if self._active_iterator: + raise RuntimeError( + "AsyncPaginatedList уже итерируется; используйте materialize() " + "или создайте отдельный список." + ) + self._active_iterator = True + return self._iterate() + + async def _iterate(self) -> AsyncIterator[ItemT]: + index = 0 + try: + while True: + if index < len(self._items): + yield self._items[index] + index += 1 + continue + if self._exhausted: + return + await self._load_next_page() + finally: + self._active_iterator = False + + async def materialize(self) -> list[ItemT]: + """Явно загружает все страницы и возвращает snapshot-список.""" + + while not self._exhausted: + await self._load_next_page() + return list(self._items) + + async def aload_until(self, index: int) -> None: + """Загружает страницы, пока локально не появится элемент с указанным индексом.""" + + while len(self._items) <= index and not self._exhausted: + await self._load_next_page() + + @property + def loaded_count(self) -> int: + """Количество элементов, уже загруженных локально.""" + + return len(self._items) + + @property + def known_total(self) -> int | None: + """Общее количество элементов, если API вернул достоверный total.""" + + return self._known_total + + @property + def source_total(self) -> int | None: + """Общий total из API без ограничения локальным limit.""" + + return self._source_total + + @property + def is_materialized(self) -> bool: + """Показывает, загружены ли все страницы коллекции.""" + + return self._exhausted + + async def _load_next_page(self) -> None: + if self._exhausted: + return + page = await self._fetch_page(self._next_page_number, self._next_cursor) + self._consume_page(page) + + def _consume_page(self, page: JsonPage[ItemT]) -> None: + self._items.extend(page.items) + self._known_total = page.total + if page.source_total is not None: + self._source_total = page.source_total + if not page.has_next: + self._exhausted = True + self._next_page_number = None + self._next_cursor = None + return + if page.next_cursor is not None: + self._next_cursor = page.next_cursor + self._next_page_number = None + return + if page.page is not None: + self._next_page_number = page.page + 1 + self._next_cursor = None + return + if self._next_page_number is not None: + self._next_page_number += 1 + return + self._exhausted = True + self._next_cursor = None + + +class AsyncPaginator[ItemT]: + """Обходит страницы API асинхронно и собирает типизированный результат.""" + + def __init__(self, fetch_page: AsyncPageFetcher[ItemT]) -> None: + self._fetch_page = fetch_page + + async def iter_pages(self, *, start_page: int = 1) -> AsyncIterator[JsonPage[ItemT]]: + """Итерирует страницы, пока API сообщает о продолжении списка.""" + + page_number: int | None = start_page + cursor: str | None = None + while True: + page = await self._fetch_page(page_number, cursor) + yield page + if not page.has_next: + return + if page.next_cursor is not None: + cursor = page.next_cursor + page_number = None + continue + if page_number is None: + return + page_number += 1 + + async def collect(self, *, start_page: int = 1) -> list[ItemT]: + """Собирает элементы всех страниц в один список.""" + + items: list[ItemT] = [] + async for page in self.iter_pages(start_page=start_page): + items.extend(page.items) + return items + + def as_list( + self, + *, + start_page: int = 1, + first_page: JsonPage[ItemT] | None = None, + ) -> AsyncPaginatedList[ItemT]: + """Возвращает ленивый async-контейнер поверх последовательности страниц.""" + + return AsyncPaginatedList( + self._fetch_page, + start_page=start_page, + first_page=first_page, + ) + + +__all__ = ("AsyncPageFetcher", "AsyncPaginatedList", "AsyncPaginator") diff --git a/avito/core/async_transport.py b/avito/core/async_transport.py new file mode 100644 index 0000000..de0efda --- /dev/null +++ b/avito/core/async_transport.py @@ -0,0 +1,328 @@ +"""Асинхронный transport-слой SDK поверх `httpx.AsyncClient`.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +from collections.abc import Awaitable, Callable, Mapping +from typing import TYPE_CHECKING + +import httpx + +from avito.core import _transport_shared as shared +from avito.core._async_rate_limit import AsyncRateLimiter +from avito.core.exceptions import ResponseMappingError, TransportError +from avito.core.retries import RetryDecision +from avito.core.types import BinaryResponse, HttpMethod, RequestContext, TransportDebugInfo + +if TYPE_CHECKING: + from avito.auth.async_provider import AsyncAuthProvider + from avito.config import AvitoSettings + +_LOGGER = logging.getLogger("avito.transport") + + +class AsyncTransport: + """Выполняет HTTP-запросы асинхронно, применяет retry и маппит ошибки API.""" + + def __init__( + self, + settings: AvitoSettings, + *, + auth_provider: AsyncAuthProvider | None = None, + client: httpx.AsyncClient | None = None, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + ) -> None: + self._settings = settings + self._auth_provider = auth_provider + self._retry_policy = settings.retry_policy + self._client = client or httpx.AsyncClient( + base_url=settings.base_url.rstrip("/"), + timeout=shared.build_httpx_timeout(settings.timeouts), + ) + self._sleep = sleep + self._rate_limiter = AsyncRateLimiter(settings.retry_policy, sleep=sleep) + self._user_agent = shared.build_user_agent(settings.user_agent_suffix) + + async def __aenter__(self) -> AsyncTransport: + return self + + async def __aexit__(self, *exc: object) -> None: + await self.aclose() + + @property + def auth_provider(self) -> AsyncAuthProvider | None: + """Возвращает auth provider transport-слоя, если он настроен.""" + + return self._auth_provider + + def debug_info(self) -> TransportDebugInfo: + """Возвращает безопасный снимок transport-конфигурации без секретов.""" + + return TransportDebugInfo( + base_url=str(self._client.base_url), + user_id=self._settings.user_id, + requires_auth=self._auth_provider is not None, + timeout_connect=self._settings.timeouts.connect, + timeout_read=self._settings.timeouts.read, + timeout_write=self._settings.timeouts.write, + timeout_pool=self._settings.timeouts.pool, + retry_max_attempts=self._retry_policy.max_attempts, + retryable_methods=self._retry_policy.retryable_methods, + ) + + async def aclose(self) -> None: + """Закрывает внутренний экземпляр `httpx.AsyncClient`.""" + + await self._client.aclose() + + async def request( + self, + method: HttpMethod, + path: str, + *, + context: RequestContext, + params: Mapping[str, object] | None = None, + json_body: object | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + content: bytes | None = None, + idempotency_key: str | None = None, + ) -> httpx.Response: + """Выполняет запрос и возвращает успешный `httpx.Response`.""" + + normalized_path = shared.normalize_path(path) + bearer_token = ( + await self._auth_provider.get_access_token() + if context.requires_auth and self._auth_provider is not None + else None + ) + request_headers = shared.merge_headers( + context=context, + headers=headers, + idempotency_key=idempotency_key, + user_agent=self._user_agent, + bearer_token=bearer_token, + ) + timeout = shared.build_httpx_timeout(context.timeout or self._settings.timeouts) + attempt = 0 + unauthorized_refresh_used = False + + while True: + attempt += 1 + limiter_delay = await self._rate_limiter.acquire() + if limiter_delay > 0.0: + _LOGGER.info( + "transport rate limit delay", + extra={ + "operation": context.operation_name, + "endpoint": shared.safe_endpoint(normalized_path), + "method": method, + "attempt": attempt, + "delay_ms": int(limiter_delay * 1000), + "reason": "client_rate_limit", + }, + ) + try: + started_at = time.perf_counter() + response = await self._client.request( + method=method, + url=normalized_path, + params=shared.normalize_params(params), + json=json_body, + data=data, + files=shared.normalize_files(files), + headers=request_headers, + content=content, + timeout=timeout, + ) + self._log_http_exchange( + operation=context.operation_name, + endpoint=normalized_path, + method=method, + attempt=attempt, + status=response.status_code, + latency_ms=shared.elapsed_ms(started_at), + request_id=shared.extract_request_id(response.headers), + ) + self._rate_limiter.observe_response(headers=response.headers) + except (httpx.TimeoutException, httpx.NetworkError) as exc: + self._log_http_exchange( + operation=context.operation_name, + endpoint=normalized_path, + method=method, + attempt=attempt, + status=None, + latency_ms=shared.elapsed_ms(started_at), + request_id=None, + ) + decision = shared.decide_transport_retry( + retry_policy=self._retry_policy, + method=method, + attempt=attempt, + context=context, + is_timeout=isinstance(exc, httpx.TimeoutException), + idempotency_key=idempotency_key, + ) + if decision.should_retry: + self._log_retry( + operation=context.operation_name, + endpoint=normalized_path, + method=method, + attempt=attempt, + status=None, + decision=decision, + ) + await self._sleep(decision.delay_seconds) + continue + raise TransportError( + str(exc), + operation=context.operation_name, + attempt=attempt, + method=method, + endpoint=shared.safe_endpoint(normalized_path), + metadata={"timeout": isinstance(exc, httpx.TimeoutException)}, + ) from exc + + if response.status_code == 401 and context.requires_auth and self._auth_provider is not None: + if unauthorized_refresh_used: + raise shared.map_http_error( + response, + operation=context.operation_name, + attempt=attempt, + ) + unauthorized_refresh_used = True + self._auth_provider.invalidate_token() + refreshed_headers = dict(request_headers) + refreshed_headers["Authorization"] = ( + f"Bearer {await self._auth_provider.get_access_token()}" + ) + request_headers = refreshed_headers + continue + + if response.status_code == 429 or 500 <= response.status_code < 600: + decision = shared.decide_http_retry( + retry_policy=self._retry_policy, + method=method, + attempt=attempt, + context=context, + response=response, + idempotency_key=idempotency_key, + ) + if decision.should_retry: + self._log_retry( + operation=context.operation_name, + endpoint=normalized_path, + method=method, + attempt=attempt, + status=response.status_code, + decision=decision, + ) + await self._sleep(decision.delay_seconds) + continue + raise shared.map_http_error( + response, + operation=context.operation_name, + attempt=attempt, + ) + + if response.is_error: + raise shared.map_http_error( + response, + operation=context.operation_name, + attempt=attempt, + ) + return response + + async def request_json( + self, + method: HttpMethod, + path: str, + *, + context: RequestContext, + params: Mapping[str, object] | None = None, + json_body: object | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + idempotency_key: str | None = None, + ) -> object: + """Выполняет запрос и возвращает JSON-ответ.""" + + response = await self.request( + method, + path, + context=context, + params=params, + json_body=json_body, + data=data, + files=files, + headers=headers, + idempotency_key=idempotency_key, + ) + try: + return response.json() + except json.JSONDecodeError as exc: + raise ResponseMappingError( + "Ответ API не является корректным JSON.", + status_code=response.status_code, + operation=context.operation_name, + metadata={"content_type": response.headers.get("content-type")}, + payload=response.text, + headers=dict(response.headers), + ) from exc + + async def download_binary( + self, + path: str, + *, + context: RequestContext, + params: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + ) -> BinaryResponse: + """Выполняет запрос и возвращает полный бинарный ответ.""" + + response = await self.request("GET", path, context=context, params=params, headers=headers) + content = await response.aread() + return BinaryResponse( + content=content, + content_type=response.headers.get("content-type"), + filename=shared.extract_filename(response.headers.get("content-disposition")), + status_code=response.status_code, + headers=dict(response.headers), + ) + + def _log_retry( + self, + *, + operation: str, + endpoint: str, + method: str, + attempt: int, + status: int | None, + decision: RetryDecision, + ) -> None: + _LOGGER.info( + "transport retry", + extra={ + "operation": operation, + "endpoint": shared.safe_endpoint(endpoint), + "method": method, + "attempt": attempt, + "status": status, + "delay_ms": int(decision.delay_seconds * 1000), + "reason": decision.reason, + }, + ) + + def _log_http_exchange(self, **extra: object) -> None: + _LOGGER.debug( + "transport http exchange", + extra={**extra, "endpoint": shared.safe_endpoint(str(extra["endpoint"]))}, + ) + + +__all__ = ("AsyncTransport",) diff --git a/avito/core/deprecation.py b/avito/core/deprecation.py index b6099c7..95046cd 100644 --- a/avito/core/deprecation.py +++ b/avito/core/deprecation.py @@ -1,10 +1,11 @@ from __future__ import annotations import warnings -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from functools import wraps -from typing import ParamSpec, TypeVar +from inspect import iscoroutinefunction +from typing import ParamSpec, TypeVar, cast P = ParamSpec("P") R = TypeVar("R") @@ -58,6 +59,21 @@ def deprecated_method( ) def decorate(method: Callable[P, R]) -> Callable[P, R]: + if iscoroutinefunction(method): + @wraps(method) + async def async_wrapped(*args: P.args, **kwargs: P.kwargs) -> R: + warn_deprecated_once( + symbol=symbol, + replacement=replacement, + removal_version=removal_version, + deprecated_since=deprecated_since, + ) + async_method = cast(Callable[P, Awaitable[R]], method) + return await async_method(*args, **kwargs) + + async_wrapped.__sdk_deprecation__ = metadata # type: ignore[attr-defined] + return async_wrapped # type: ignore[return-value] + @wraps(method) def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: warn_deprecated_once( diff --git a/avito/core/domain.py b/avito/core/domain.py index f2712a7..4529298 100644 --- a/avito/core/domain.py +++ b/avito/core/domain.py @@ -7,10 +7,11 @@ from typing import TYPE_CHECKING, TypeVar from avito.core.exceptions import ValidationError -from avito.core.operations import OperationExecutor, OperationSpec +from avito.core.operations import AsyncOperationExecutor, OperationExecutor, OperationSpec from avito.core.types import ApiTimeouts, RequestContext, RetryOverride if TYPE_CHECKING: + from avito.core.async_transport import AsyncTransport from avito.core.transport import Transport ResponseT = TypeVar("ResponseT") @@ -75,6 +76,63 @@ def _resolve_user_id(self, user_id: int | str | None = None) -> int: return resolved_user_id +@dataclass(slots=True, frozen=True) +class AsyncDomainObject: + """Базовый async-доменный объект с доступом к transport-слою.""" + + transport: AsyncTransport + + async def _execute( + self, + spec: OperationSpec[ResponseT], + *, + path_params: Mapping[str, object] | None = None, + query: object | Mapping[str, object] | None = None, + request: object | Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + idempotency_key: str | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResponseT: + """Выполняет v2 operation spec через общий async executor.""" + + return await AsyncOperationExecutor(self.transport).execute( + spec, + path_params=path_params, + query=query, + request=request, + headers=headers, + idempotency_key=idempotency_key, + data=data, + files=files, + timeout=timeout, + retry=retry, + ) + + async def _resolve_user_id(self, user_id: int | str | None = None) -> int: + """Возвращает user_id из аргумента, настроек SDK или профиля текущего пользователя.""" + + if user_id is not None: + return int(user_id) + configured_user_id = self.transport.debug_info().user_id + if configured_user_id is not None: + return configured_user_id + payload = await self.transport.request_json( + "GET", + "/core/v1/accounts/self", + context=RequestContext("accounts.resolve_user_id"), + ) + resolved_user_id = _extract_user_id(payload) + if resolved_user_id is None: + raise ValidationError( + "Для операции требуется `user_id`: передайте его в фабрику клиента, " + "в метод операции или задайте `AVITO_USER_ID`." + ) + return resolved_user_id + + def _extract_user_id(payload: object) -> int | None: if not isinstance(payload, dict): return None @@ -87,4 +145,4 @@ def _extract_user_id(payload: object) -> int | None: return None -__all__ = ("DomainObject",) +__all__ = ("AsyncDomainObject", "DomainObject") diff --git a/avito/core/operations.py b/avito/core/operations.py index 30d9287..ef22762 100644 --- a/avito/core/operations.py +++ b/avito/core/operations.py @@ -90,6 +90,41 @@ def request_json( """Execute request and return decoded JSON payload.""" +class AsyncOperationTransport(Protocol): + """Async transport methods required by the operation executor.""" + + async def request( + self, + method: HttpMethod, + path: str, + *, + context: RequestContext, + params: Mapping[str, object] | None = None, + json_body: object | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + content: bytes | None = None, + idempotency_key: str | None = None, + ) -> httpx.Response: + """Execute raw request and return response object.""" + + async def request_json( + self, + method: HttpMethod, + path: str, + *, + context: RequestContext, + params: Mapping[str, object] | None = None, + json_body: object | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + idempotency_key: str | None = None, + ) -> object: + """Execute request and return decoded JSON payload.""" + + @dataclass(slots=True, frozen=True) class EmptyResponse: """Typed result for successful operations without response body.""" @@ -201,6 +236,87 @@ def execute( return spec.response_model.from_payload(payload) +class AsyncOperationExecutor: + """Execute operation specs through the async transport layer.""" + + def __init__(self, transport: AsyncOperationTransport) -> None: + self._transport = transport + + async def execute( + self, + spec: OperationSpec[ResponseT], + *, + path_params: Mapping[str, object] | None = None, + query: object | Mapping[str, object] | None = None, + request: object | Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + idempotency_key: str | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResponseT: + """Execute operation spec and return typed response object.""" + + path = render_path(spec.path, path_params or {}) + params = _serialize_query(spec, query) + json_body = _serialize_request(spec, request) + request_headers = _merge_content_type(headers, spec.content_type) + effective_retry = spec.retry_mode if retry is None or retry == "default" else retry + context = RequestContext( + operation_name=spec.name, + allow_retry=effective_retry == "enabled", + retry_disabled=effective_retry == "disabled", + requires_auth=spec.requires_auth, + timeout=timeout, + ) + + if spec.response_kind == "binary": + return cast( + ResponseT, + await _request_binary_async( + self._transport, + spec=spec, + path=path, + context=context, + params=params, + headers=request_headers, + idempotency_key=idempotency_key, + ), + ) + if spec.response_kind == "empty": + response = await self._transport.request( + spec.method, + path, + context=context, + params=params, + json_body=json_body, + data=data, + files=files, + headers=request_headers, + idempotency_key=idempotency_key, + ) + return cast( + ResponseT, + EmptyResponse(status_code=response.status_code, headers=dict(response.headers)), + ) + + payload = await self._transport.request_json( + spec.method, + path, + context=context, + params=params, + json_body=json_body, + data=data, + files=files, + headers=request_headers, + idempotency_key=idempotency_key, + ) + if spec.response_model is None: + return cast(ResponseT, payload) + return spec.response_model.from_payload(payload) + + def render_path(path_template: str, path_params: Mapping[str, object]) -> str: """Render operation path and percent-encode path parameter values.""" @@ -278,6 +394,33 @@ def _request_binary[SpecResponseT]( ) +async def _request_binary_async[SpecResponseT]( + transport: AsyncOperationTransport, + *, + spec: OperationSpec[SpecResponseT], + path: str, + context: RequestContext, + params: Mapping[str, object] | None, + headers: Mapping[str, str] | None, + idempotency_key: str | None, +) -> BinaryResponse: + response = await transport.request( + spec.method, + path, + context=context, + params=params, + headers=headers, + idempotency_key=idempotency_key, + ) + return BinaryResponse( + content=response.content, + content_type=response.headers.get("content-type"), + filename=_extract_filename(response.headers.get("content-disposition")), + status_code=response.status_code, + headers=dict(response.headers), + ) + + def _extract_filename(content_disposition: str | None) -> str | None: if content_disposition is None: return None @@ -292,6 +435,8 @@ def _extract_filename(content_disposition: str | None) -> str | None: __all__ = ( "EmptyResponse", + "AsyncOperationExecutor", + "AsyncOperationTransport", "OperationExecutor", "OperationSpec", "OperationTransport", diff --git a/avito/core/rate_limit.py b/avito/core/rate_limit.py index 54b9a22..aeced25 100644 --- a/avito/core/rate_limit.py +++ b/avito/core/rate_limit.py @@ -6,6 +6,7 @@ import time from collections.abc import Callable, Mapping +from avito.core._transport_shared import RateLimitState from avito.core.retries import RetryPolicy @@ -19,12 +20,7 @@ def __init__( clock: Callable[[], float] = time.monotonic, sleep: Callable[[float], None] = time.sleep, ) -> None: - self._enabled = policy.rate_limit_enabled - self._rate = max(policy.rate_limit_requests_per_second, 0.0) - self._capacity = max(policy.rate_limit_burst, 0) - self._tokens = float(self._capacity) - self._updated_at = clock() - self._blocked_until = 0.0 + self._state = RateLimitState.from_policy(policy, now=clock()) self._clock = clock self._sleep = sleep self._lock = threading.Lock() @@ -32,9 +28,6 @@ def __init__( def acquire(self) -> float: """Ждёт, пока запрос можно безопасно отправить, и возвращает задержку.""" - if not self._enabled or self._rate <= 0.0 or self._capacity <= 0: - return 0.0 - total_delay = 0.0 while True: delay = self._reserve_or_delay() @@ -46,54 +39,12 @@ def acquire(self) -> float: def observe_response(self, *, headers: Mapping[str, str]) -> None: """Обновляет локальный cooldown по rate-limit headers upstream API.""" - if not self._enabled or self._rate <= 0.0: - return - - remaining = _get_header(headers, "x-ratelimit-remaining") - if remaining is None: - return - try: - remaining_count = int(remaining) - except ValueError: - return - if remaining_count <= 0: - self._block_for(1.0 / self._rate) - - def _reserve_or_delay(self) -> float: with self._lock: - now = self._clock() - self._refill(now) - blocked_delay = max(self._blocked_until - now, 0.0) - if blocked_delay > 0.0: - return blocked_delay - if self._tokens >= 1.0: - self._tokens -= 1.0 - return 0.0 - return (1.0 - self._tokens) / self._rate + self._state.observe_response(now=self._clock(), headers=headers) - def _refill(self, now: float) -> None: - elapsed = max(now - self._updated_at, 0.0) - if elapsed > 0.0: - self._tokens = min(float(self._capacity), self._tokens + elapsed * self._rate) - self._updated_at = now - - def _block_for(self, delay: float) -> None: - if delay <= 0.0: - return + def _reserve_or_delay(self) -> float: with self._lock: - self._blocked_until = max(self._blocked_until, self._clock() + delay) - self._tokens = min(self._tokens, 0.0) - - -def _get_header(headers: Mapping[str, str], name: str) -> str | None: - value = headers.get(name) - if value is not None: - return value - lowered_name = name.lower() - for key, item in headers.items(): - if key.lower() == lowered_name: - return item - return None + return self._state.compute_delay(self._clock()) __all__ = ("RateLimiter",) diff --git a/avito/core/swagger.py b/avito/core/swagger.py index 5bd22ea..5aaa6af 100644 --- a/avito/core/swagger.py +++ b/avito/core/swagger.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass, field from types import MappingProxyType -from typing import ParamSpec, TypeVar +from typing import Literal, ParamSpec, TypeVar from avito.core.exceptions import ConfigurationError @@ -55,8 +55,11 @@ class SwaggerOperationBinding: method_args: Mapping[str, str] = field(default_factory=lambda: _EMPTY_MAPPING) deprecated: bool = False legacy: bool = False + variant: Literal["sync", "async"] = "sync" def __post_init__(self) -> None: + if self.variant not in {"sync", "async"}: + raise ConfigurationError("Swagger binding variant должен быть `sync` или `async`.") object.__setattr__(self, "method", _normalize_method(self.method)) object.__setattr__(self, "path", _normalize_path(self.path)) object.__setattr__(self, "factory_args", _freeze_mapping(self.factory_args)) @@ -74,6 +77,7 @@ def swagger_operation( method_args: Mapping[str, str] | None = None, deprecated: bool = False, legacy: bool = False, + variant: Literal["sync", "async"] = "sync", ) -> Callable[[Callable[P, R]], Callable[P, R]]: """Записывает Swagger binding metadata на публичный SDK-метод.""" @@ -87,6 +91,7 @@ def swagger_operation( method_args=_freeze_mapping(method_args), deprecated=deprecated, legacy=legacy, + variant=variant, ) def decorate(func: Callable[P, R]) -> Callable[P, R]: diff --git a/avito/core/swagger_discovery.py b/avito/core/swagger_discovery.py index 5c22608..d9fa85c 100644 --- a/avito/core/swagger_discovery.py +++ b/avito/core/swagger_discovery.py @@ -9,9 +9,9 @@ from collections.abc import Mapping from dataclasses import dataclass, field from types import MappingProxyType, ModuleType -from typing import cast +from typing import Literal, cast -from avito.core.domain import DomainObject +from avito.core.domain import AsyncDomainObject, DomainObject from avito.core.swagger import SwaggerOperationBinding from avito.core.swagger_registry import ( SwaggerOperation, @@ -22,7 +22,7 @@ _EMPTY_MAPPING: Mapping[str, str] = MappingProxyType({}) _IGNORED_PACKAGES = frozenset({"auth", "core", "summary", "testing"}) -_NON_DOMAIN_BINDING_MODULES = ("avito.auth.provider",) +_NON_DOMAIN_BINDING_MODULES = ("avito.auth.provider", "avito.auth.async_token_client") @dataclass(frozen=True, slots=True) @@ -43,6 +43,7 @@ class DiscoveredSwaggerBinding: method_args: Mapping[str, str] = field(default_factory=lambda: _EMPTY_MAPPING) deprecated: bool = False legacy: bool = False + variant: Literal["sync", "async"] = "sync" @property def sdk_method(self) -> str: @@ -61,10 +62,38 @@ def canonical_map(self) -> Mapping[str, DiscoveredSwaggerBinding]: mapped = { binding.operation_key: binding for binding in self.bindings - if binding.operation_key is not None + if binding.operation_key is not None and binding.variant == "sync" } return MappingProxyType(mapped) + @property + def canonical_map_by_variant( + self, + ) -> Mapping[Literal["sync", "async"], Mapping[str, DiscoveredSwaggerBinding]]: + mapped: dict[Literal["sync", "async"], dict[str, DiscoveredSwaggerBinding]] = { + "sync": {}, + "async": {}, + } + for binding in self.bindings: + if binding.operation_key is not None: + mapped[binding.variant][binding.operation_key] = binding + return MappingProxyType( + { + "sync": MappingProxyType(mapped["sync"]), + "async": MappingProxyType(mapped["async"]), + } + ) + + def binding_for( + self, + operation_key: str, + *, + variant: Literal["sync", "async"] = "sync", + ) -> DiscoveredSwaggerBinding | None: + """Return discovered binding by operation key and surface variant.""" + + return self.canonical_map_by_variant[variant].get(operation_key) + def discover_swagger_bindings( *, @@ -99,10 +128,11 @@ def _iter_domain_modules(package: ModuleType, package_name: str) -> tuple[Module for module_info in pkgutil.iter_modules(package_paths): if not module_info.ispkg or module_info.name in _IGNORED_PACKAGES: continue - module_name = f"{package_name}.{module_info.name}.domain" - if importlib.util.find_spec(module_name) is None: - continue - modules.append(importlib.import_module(module_name)) + for suffix in ("domain", "async_domain"): + module_name = f"{package_name}.{module_info.name}.{suffix}" + if importlib.util.find_spec(module_name) is None: + continue + modules.append(importlib.import_module(module_name)) return tuple(modules) @@ -142,6 +172,8 @@ def _discover_module_bindings( def _is_discoverable_binding_class(cls: type[object]) -> bool: if issubclass(cls, DomainObject) and cls is not DomainObject: return True + if issubclass(cls, AsyncDomainObject) and cls is not AsyncDomainObject: + return True return _optional_string(getattr(cls, "__swagger_domain__", None)) is not None @@ -185,6 +217,7 @@ def _build_effective_binding( method_args=binding.method_args, deprecated=binding.deprecated, legacy=binding.legacy, + variant=binding.variant, ) diff --git a/avito/core/swagger_linter.py b/avito/core/swagger_linter.py index 5c42839..71d9c3d 100644 --- a/avito/core/swagger_linter.py +++ b/avito/core/swagger_linter.py @@ -359,7 +359,9 @@ def _validate_complete_bindings( bindings: Sequence[DiscoveredSwaggerBinding], ) -> tuple[SwaggerReportError, ...]: bound_operation_keys = { - binding.operation_key for binding in bindings if binding.operation_key is not None + binding.operation_key + for binding in bindings + if binding.operation_key is not None and binding.variant == "sync" } errors: list[SwaggerReportError] = [] for operation in operations: @@ -379,13 +381,13 @@ def _validate_complete_bindings( def _validate_duplicate_bindings( bindings: Sequence[DiscoveredSwaggerBinding], ) -> tuple[SwaggerReportError, ...]: - grouped: defaultdict[str, list[DiscoveredSwaggerBinding]] = defaultdict(list) + grouped: defaultdict[tuple[str, str], list[DiscoveredSwaggerBinding]] = defaultdict(list) for binding in bindings: if binding.operation_key is not None: - grouped[binding.operation_key].append(binding) + grouped[(binding.operation_key, binding.variant)].append(binding) errors: list[SwaggerReportError] = [] - for operation_key, operation_bindings in sorted(grouped.items()): + for (operation_key, variant), operation_bindings in sorted(grouped.items()): if len(operation_bindings) < 2: continue methods = ", ".join(binding.sdk_method for binding in operation_bindings) @@ -395,7 +397,7 @@ def _validate_duplicate_bindings( code="SWAGGER_BINDING_DUPLICATE", message=( f"{operation_key}: несколько SDK binding-ов указывают на одну " - f"Swagger operation: {methods}." + f"Swagger operation для variant={variant}: {methods}." ), operation_key=operation_key, sdk_method=binding.sdk_method, @@ -533,12 +535,19 @@ def _validate_factory(binding: DiscoveredSwaggerBinding) -> tuple[SwaggerReportE ), ) - factory = getattr(AvitoClient, binding.factory, None) + client_type: type[object] + if binding.variant == "async": + from avito.async_client import AsyncAvitoClient + + client_type = AsyncAvitoClient + else: + client_type = AvitoClient + factory = getattr(client_type, binding.factory, None) if not callable(factory): return ( SwaggerReportError( code="SWAGGER_BINDING_FACTORY_NOT_FOUND", - message=f"{binding.sdk_method}: AvitoClient factory не найден: {binding.factory}.", + message=f"{binding.sdk_method}: client factory не найден: {binding.factory}.", operation_key=binding.operation_key, sdk_method=binding.sdk_method, ), diff --git a/avito/core/swagger_report.py b/avito/core/swagger_report.py index a89fe18..ca777d5 100644 --- a/avito/core/swagger_report.py +++ b/avito/core/swagger_report.py @@ -33,9 +33,18 @@ class SwaggerBindingReport: def to_dict(self) -> dict[str, object]: """Return JSON-compatible report data.""" - binding_groups = _group_bindings_by_operation_key(self.discovery.bindings) + sync_bindings = tuple(binding for binding in self.discovery.bindings if binding.variant == "sync") + async_bindings = tuple( + binding for binding in self.discovery.bindings if binding.variant == "async" + ) + binding_groups = _group_bindings_by_operation_key(sync_bindings) + async_binding_groups = _group_bindings_by_operation_key(async_bindings) operation_entries = [ - _build_operation_entry(operation, binding_groups.get(operation.key, ())) + _build_operation_entry( + operation, + binding_groups.get(operation.key, ()), + async_binding_groups.get(operation.key, ()), + ) for operation in self.registry.operations ] binding_entries = [_build_binding_entry(binding) for binding in self.discovery.bindings] @@ -55,6 +64,10 @@ def to_dict(self) -> dict[str, object]: "unbound": unbound_operations, "duplicate": duplicate_operations, "ambiguous": ambiguous_bindings, + "variants": { + "sync": _variant_summary(self.registry.operations, sync_bindings), + "async": _variant_summary(self.registry.operations, async_bindings), + }, }, "operations": operation_entries, "bindings": binding_entries, @@ -101,6 +114,7 @@ def _group_bindings_by_operation_key( def _build_operation_entry( operation: SwaggerOperation, bindings: tuple[DiscoveredSwaggerBinding, ...], + async_bindings: tuple[DiscoveredSwaggerBinding, ...] = (), ) -> dict[str, object]: if not bindings: status = "unbound" @@ -120,6 +134,10 @@ def _build_operation_entry( "deprecated": operation.deprecated, "status": status, "binding": binding_entry, + "bindings_by_variant": { + "sync": binding_entry, + "async": _variant_binding_entry(async_bindings), + }, } @@ -139,6 +157,7 @@ def _build_binding_entry(binding: DiscoveredSwaggerBinding) -> dict[str, object] "method_args": dict(binding.method_args), "deprecated": binding.deprecated, "legacy": binding.legacy, + "variant": binding.variant, "status": "ambiguous" if binding.operation_key is None else "mapped", } @@ -152,6 +171,31 @@ def _binding_reference(binding: DiscoveredSwaggerBinding) -> dict[str, object]: } +def _variant_binding_entry(bindings: tuple[DiscoveredSwaggerBinding, ...]) -> object: + if not bindings: + return None + if len(bindings) == 1: + return _binding_reference(bindings[0]) + return [_binding_reference(binding) for binding in bindings] + + +def _variant_summary( + operations: tuple[SwaggerOperation, ...], + bindings: Sequence[DiscoveredSwaggerBinding], +) -> dict[str, int]: + groups = _group_bindings_by_operation_key(bindings) + bound = sum(1 for operation in operations if len(groups.get(operation.key, ())) == 1) + duplicate = sum(1 for operation_bindings in groups.values() if len(operation_bindings) > 1) + ambiguous = sum(1 for binding in bindings if binding.operation_key is None) + return { + "operations_total": len(operations), + "bound": bound, + "unbound": len(operations) - bound, + "duplicate": duplicate, + "ambiguous": ambiguous, + } + + def _build_registry_error_entry(error: SwaggerValidationError) -> dict[str, object]: return { "code": error.code, diff --git a/avito/core/transport.py b/avito/core/transport.py index d6be29b..5370094 100644 --- a/avito/core/transport.py +++ b/avito/core/transport.py @@ -17,6 +17,7 @@ import httpx +from avito.core import _transport_shared as shared from avito.core.exceptions import ( AuthenticationError, AuthorizationError, @@ -61,12 +62,7 @@ def build_httpx_timeout(timeouts: ApiTimeouts) -> httpx.Timeout: """Преобразует SDK-конфигурацию таймаутов в `httpx.Timeout`.""" - return httpx.Timeout( - connect=timeouts.connect, - read=timeouts.read, - write=timeouts.write, - pool=timeouts.pool, - ) + return shared.build_httpx_timeout(timeouts) class Transport: @@ -134,10 +130,16 @@ def request( """Выполняет запрос и возвращает успешный `httpx.Response`.""" normalized_path = self._normalize_path(path) + bearer_token = ( + self._auth_provider.get_access_token() + if context.requires_auth and self._auth_provider is not None + else None + ) request_headers = self._merge_headers( context=context, headers=headers, idempotency_key=idempotency_key, + bearer_token=bearer_token, ) timeout = build_httpx_timeout(context.timeout or self._settings.timeouts) attempt = 0 @@ -413,19 +415,15 @@ def _merge_headers( context: RequestContext, headers: Mapping[str, str] | None, idempotency_key: str | None, + bearer_token: str | None, ) -> dict[str, str]: - merged: dict[str, str] = { - "Accept": "application/json", - "User-Agent": self._user_agent, - } - merged.update(dict(context.headers)) - if headers is not None: - merged.update(dict(headers)) - if idempotency_key is not None: - merged["Idempotency-Key"] = idempotency_key - if context.requires_auth and self._auth_provider is not None: - merged["Authorization"] = f"Bearer {self._auth_provider.get_access_token()}" - return merged + return shared.merge_headers( + context=context, + headers=headers, + idempotency_key=idempotency_key, + user_agent=self._user_agent, + bearer_token=bearer_token, + ) def _build_user_agent(self) -> str: try: diff --git a/avito/testing/__init__.py b/avito/testing/__init__.py index cab024c..6731f9e 100644 --- a/avito/testing/__init__.py +++ b/avito/testing/__init__.py @@ -1,5 +1,7 @@ """Публичные тестовые утилиты SDK.""" +from avito.testing.async_fake_transport import AsyncFakeTransport +from avito.testing.async_swagger_fake_transport import AsyncSwaggerFakeTransport from avito.testing.fake_transport import ( FakeResponse, FakeTransport, @@ -17,6 +19,8 @@ __all__ = ( "FakeTransport", + "AsyncFakeTransport", + "AsyncSwaggerFakeTransport", "FakeResponse", "JsonValue", "RecordedRequest", diff --git a/avito/testing/async_fake_transport.py b/avito/testing/async_fake_transport.py new file mode 100644 index 0000000..7b2a2c3 --- /dev/null +++ b/avito/testing/async_fake_transport.py @@ -0,0 +1,211 @@ +"""Async fake transport and helpers for SDK tests.""" + +from __future__ import annotations + +import asyncio +import json +from collections import deque +from collections.abc import Mapping +from typing import cast + +import httpx + +from avito.async_client import AsyncAvitoClient +from avito.auth import AuthSettings +from avito.auth.async_provider import AsyncAuthProvider +from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient +from avito.config import AvitoSettings +from avito.core.async_transport import AsyncTransport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts +from avito.testing.fake_transport import JsonValue, RecordedRequest, RouteResponder + + +class AsyncFakeTransport: + """Deterministic async fake transport for SDK contract tests.""" + + def __init__(self, *, base_url: str = "https://api.avito.ru") -> None: + self.base_url = base_url.rstrip("/") + self.requests: list[RecordedRequest] = [] + self._routes: dict[tuple[str, str], deque[RouteResponder]] = {} + self._handle_lock = asyncio.Lock() + + def add(self, method: str, path: str, *responses: RouteResponder) -> AsyncFakeTransport: + """Регистрирует один или несколько ответов для HTTP-маршрута.""" + + key = (method.upper(), path) + bucket = self._routes.setdefault(key, deque()) + bucket.extend(responses) + return self + + def add_json( + self, + method: str, + path: str, + payload: JsonValue, + *, + status_code: int = 200, + headers: Mapping[str, str] | None = None, + ) -> AsyncFakeTransport: + """Регистрирует JSON-ответ для HTTP-маршрута.""" + + return self.add(method, path, httpx.Response(status_code, json=payload, headers=headers)) + + def build( + self, + *, + retry_policy: RetryPolicy | None = None, + user_id: int | None = None, + authenticated: bool = False, + auth_settings: AuthSettings | None = None, + ) -> AsyncTransport: + """Создаёт низкоуровневый AsyncTransport поверх fake transport.""" + + settings, auth_provider, http_client = self._build_parts( + retry_policy=retry_policy, + user_id=user_id, + authenticated=authenticated, + auth_settings=auth_settings, + ) + return AsyncTransport( + settings, + auth_provider=auth_provider, + client=http_client, + sleep=lambda _: asyncio.sleep(0), + ) + + def as_client( + self, + *, + user_id: int | None = None, + retry_policy: RetryPolicy | None = None, + authenticated: bool = False, + auth_settings: AuthSettings | None = None, + ) -> AsyncAvitoClient: + """Создает публичный `AsyncAvitoClient` поверх fake transport.""" + + settings, auth_provider, http_client = self._build_parts( + retry_policy=retry_policy, + user_id=user_id, + authenticated=authenticated, + auth_settings=auth_settings, + ) + transport = AsyncTransport( + settings, + auth_provider=auth_provider, + client=http_client, + sleep=lambda _: asyncio.sleep(0), + ) + return AsyncAvitoClient._from_transport( + settings, + transport=transport, + auth_provider=auth_provider or AsyncAuthProvider(settings.auth), + ) + + def count(self, *, method: str | None = None, path: str | None = None) -> int: + """Возвращает число перехваченных запросов с опциональной фильтрацией.""" + + return len( + [ + request + for request in self.requests + if (method is None or request.method == method.upper()) + and (path is None or request.path == path) + ] + ) + + def last(self, *, method: str | None = None, path: str | None = None) -> RecordedRequest: + """Возвращает последний перехваченный запрос с опциональной фильтрацией.""" + + matches = [ + request + for request in self.requests + if (method is None or request.method == method.upper()) + and (path is None or request.path == path) + ] + if not matches: + raise AssertionError(f"No requests matched method={method!r} path={path!r}") + return matches[-1] + + def _build_parts( + self, + *, + retry_policy: RetryPolicy | None, + user_id: int | None, + authenticated: bool, + auth_settings: AuthSettings | None, + ) -> tuple[AvitoSettings, AsyncAuthProvider | None, httpx.AsyncClient]: + resolved_auth = auth_settings or AuthSettings( + client_id="fake-client-id", + client_secret="fake-client-secret", + ) + settings = AvitoSettings( + base_url=self.base_url, + user_id=user_id, + auth=resolved_auth, + retry_policy=retry_policy or RetryPolicy(), + timeouts=ApiTimeouts(), + ) + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(self._handle), + base_url=self.base_url, + ) + auth_provider = None + if authenticated: + auth_provider = AsyncAuthProvider( + resolved_auth, + token_client=AsyncTokenClient( + resolved_auth, + client=http_client, + sdk_settings=settings, + ), + alternate_token_client=AsyncAlternateTokenClient( + resolved_auth, + client=http_client, + sdk_settings=settings, + ), + autoteka_token_client=AsyncTokenClient( + resolved_auth, + token_url=resolved_auth.autoteka_token_url, + client=http_client, + sdk_settings=settings, + ), + ) + return settings, auth_provider, http_client + + async def _handle(self, request: httpx.Request) -> httpx.Response: + async with self._handle_lock: + recorded = RecordedRequest( + method=request.method.upper(), + path=request.url.path, + params=dict(request.url.params), + headers=dict(request.headers), + json_body=self._decode_json(request), + content=request.content, + ) + self.requests.append(recorded) + key = (recorded.method, recorded.path) + if key not in self._routes: + available = ", ".join(f"{method} {path}" for method, path in sorted(self._routes)) + raise AssertionError( + "Маршрут не прописан в AsyncFakeTransport: " + f"{recorded.method} {recorded.path}. " + f"Добавьте route_sequence или add_json для этого пути. Доступные: {available}" + ) + responders = self._routes[key] + responder = responders[0] if len(responders) == 1 else responders.popleft() + response = responder(recorded) if callable(responder) else responder + response.request = request + return response + + @staticmethod + def _decode_json(request: httpx.Request) -> JsonValue: + if not request.content: + return None + try: + return cast(JsonValue, json.loads(request.content.decode())) + except json.JSONDecodeError: + return None + + +__all__ = ("AsyncFakeTransport",) diff --git a/avito/testing/async_swagger_fake_transport.py b/avito/testing/async_swagger_fake_transport.py new file mode 100644 index 0000000..c23fdb6 --- /dev/null +++ b/avito/testing/async_swagger_fake_transport.py @@ -0,0 +1,73 @@ +"""Async Swagger-aware fake transport placeholder for async contract tests.""" + +from __future__ import annotations + +from collections.abc import Mapping + +from avito.core.swagger_registry import SwaggerOperation, SwaggerRegistry +from avito.testing.async_fake_transport import AsyncFakeTransport +from avito.testing.fake_transport import JsonValue +from avito.testing.swagger_fake_transport import SwaggerRoute, success_payload + + +class AsyncSwaggerFakeTransport(AsyncFakeTransport): + """Async fake transport that registers routes by Swagger operation key.""" + + def __init__( + self, + *, + registry: SwaggerRegistry, + base_url: str = "https://api.avito.ru", + ) -> None: + super().__init__(base_url=base_url) + self.registry = registry + self._swagger_routes: dict[str, SwaggerRoute] = {} + + def add_operation( + self, + operation_key: str, + payload: JsonValue, + *, + status_code: int = 200, + headers: Mapping[str, str] | None = None, + ) -> AsyncSwaggerFakeTransport: + """Register response for one Swagger operation key.""" + + operation = self.operation(operation_key) + self._swagger_routes[operation.key] = SwaggerRoute( + operation=operation, + payload=payload, + status_code=status_code, + headers=dict(headers or {}), + ) + self.add_json(operation.method, operation.path, payload, status_code=status_code) + return self + + def add_success_operation( + self, + operation_key: str, + *, + payload: JsonValue | None = None, + headers: Mapping[str, str] | None = None, + ) -> AsyncSwaggerFakeTransport: + """Register a deterministic success response for one Swagger operation.""" + + operation = self.operation(operation_key) + status_code = int(operation.responses[0].status_code) + return self.add_operation( + operation_key, + success_payload(operation) if payload is None else payload, + status_code=status_code, + headers=headers, + ) + + def operation(self, operation_key: str) -> SwaggerOperation: + """Return operation by key or raise an assertion error.""" + + for operation in self.registry.operations: + if operation.key == operation_key: + return operation + raise AssertionError(f"Swagger operation not found: {operation_key}") + + +__all__ = ("AsyncSwaggerFakeTransport",) diff --git a/docs/site/assets/_gen_reference.py b/docs/site/assets/_gen_reference.py index 81693f6..0b0fa7d 100644 --- a/docs/site/assets/_gen_reference.py +++ b/docs/site/assets/_gen_reference.py @@ -8,7 +8,7 @@ import mkdocs_gen_files -from avito.core.domain import DomainObject +from avito.core.domain import AsyncDomainObject, DomainObject from avito.core.swagger_discovery import discover_swagger_bindings from avito.core.swagger_linter import lint_swagger_bindings from avito.core.swagger_registry import load_swagger_registry @@ -22,7 +22,7 @@ def public_domain_packages() -> list[str]: return sorted( path.parent.name - for path in PACKAGE_ROOT.glob("*/domain.py") + for path in (*PACKAGE_ROOT.glob("*/domain.py"), *PACKAGE_ROOT.glob("*/async_domain.py")) if path.parent.name not in EXCLUDED_PACKAGES ) @@ -43,18 +43,21 @@ def public_enums(package: str) -> list[type[Enum]]: def public_domain_classes(package: str) -> list[type[DomainObject]]: - module = importlib.import_module(f"avito.{package}") - names = getattr(module, "__all__", ()) + modules = [] + for suffix in ("domain", "async_domain"): + try: + modules.append(importlib.import_module(f"avito.{package}.{suffix}")) + except ModuleNotFoundError: + continue classes: list[type[DomainObject]] = [] - for name in names: - value = getattr(module, name, None) - if ( - inspect.isclass(value) - and issubclass(value, DomainObject) - and value is not DomainObject - and value.__module__.startswith(f"avito.{package}.") - ): - classes.append(value) + for module in modules: + for _, value in inspect.getmembers(module, inspect.isclass): + if value.__module__ != module.__name__: + continue + if value in {DomainObject, AsyncDomainObject}: + continue + if issubclass(value, DomainObject | AsyncDomainObject): + classes.append(value) return classes @@ -82,7 +85,8 @@ def write_domain_pages(packages: list[str]) -> list[str]: for enum_class in enums: file.write(f"- [`{enum_class.__name__}`](../enums.md#{enum_class.__name__})\n") file.write("\n") - file.write(f"::: avito.{package}\n") + for domain_class in public_domain_classes(package): + file.write(f"::: {domain_class.__module__}.{domain_class.__name__}\n\n") mkdocs_gen_files.set_edit_path(page, Path(f"avito/{package}/__init__.py")) return pages diff --git a/docs/site/explanations/domain-architecture-v2.md b/docs/site/explanations/domain-architecture-v2.md index 253d742..365b578 100644 --- a/docs/site/explanations/domain-architecture-v2.md +++ b/docs/site/explanations/domain-architecture-v2.md @@ -1,8 +1,9 @@ # Целевая структура доменов Эта страница фиксирует архитектуру доменных пакетов SDK. Все API-домены SDK -используют v2 layout: публичные методы находятся в `domain.py`, HTTP-контракты -в `operations.py` или `operations/`, а модели, enum-ы и payload mapping +используют v2 layout: публичные sync-методы находятся в `domain.py`, async-зеркало +портированных классов находится в `async_domain.py`, HTTP-контракты в +`operations.py` или `operations/`, а модели, enum-ы и payload mapping принадлежат `models.py` или `models/`. ## Основной принцип @@ -27,6 +28,7 @@ API-доменов. Domain-level `client.py`, `mappers.py` и standalone `enums. avito/ratings/ __init__.py domain.py + async_domain.py operations.py models.py ``` @@ -35,7 +37,8 @@ avito/ratings/ | Файл | Ответственность | |---|---| -| `domain.py` | Публичные `DomainObject`-классы, reference-ready docstring-и, `@swagger_operation(...)`, бизнес-валидация и сбор публичных request-моделей | +| `domain.py` | Публичные sync `DomainObject`-классы, reference-ready docstring-и, `@swagger_operation(..., variant="sync")`, бизнес-валидация и сбор публичных request-моделей | +| `async_domain.py` | Публичные async `AsyncDomainObject`-классы для портированных доменов; методы зеркалируют sync-сигнатуры, используют `async def` и `@swagger_operation(..., variant="async")` | | `operations.py` | Внутренние `OperationSpec`: HTTP method, path, operation context, retry policy и response/request model classes | | `models.py` | Dataclass-модели, enum-ы, `from_payload()`, `to_payload()`, `to_params()` и нормализация | diff --git a/docs/site/explanations/swagger-binding-subsystem.md b/docs/site/explanations/swagger-binding-subsystem.md index 8bf9c46..6d60c04 100644 --- a/docs/site/explanations/swagger-binding-subsystem.md +++ b/docs/site/explanations/swagger-binding-subsystem.md @@ -38,6 +38,7 @@ Swagger/OpenAPI-файлы в `docs/avito/api/*.json` остаются един method_args: Mapping[str, str] | None = None, deprecated: bool = False, legacy: bool = False, + variant: Literal["sync", "async"] = "sync", ) ``` @@ -103,7 +104,20 @@ Registry дополнительно строит normalized JSON schema tree д ## Discovery -Discovery импортирует пакет `avito`, но не создаёт `AvitoClient`, не читает обязательные env vars и не делает сетевых вызовов. Сканируются публичные domain classes из `avito//domain.py` и заранее описанные non-domain exceptions, например low-level auth token bindings. +Discovery импортирует пакет `avito`, но не создаёт `AvitoClient`, не читает обязательные env vars и не делает сетевых вызовов. Сканируются публичные domain classes из `avito//domain.py`, async companions из `avito//async_domain.py`, если они существуют, и заранее описанные non-domain exceptions, например low-level auth token bindings. + +## Sync/async variants + +Binding identity is variant-aware. The sync surface uses +`@swagger_operation(..., variant="sync")` by default; async mirrors use +`variant="async"`. The duplicate-binding key is `(operation_key, variant)`, so one +Swagger operation may have one sync binding and one async binding. + +During migration async coverage is class-gated: if an `Async` class exists, all +Swagger-bound methods of sync class `` must have async bindings. If the class has +not been ported yet, its operations do not enter async expected coverage. Auth token +bindings are discovered from `avito.auth.async_token_client` independently from domain +factories. Игнорируются: @@ -127,7 +141,7 @@ make swagger-coverage ``` Non-strict mode валидирует specs и уже найденные bindings. Strict mode -дополнительно требует, чтобы каждая Swagger operation имела ровно один binding, +дополнительно требует, чтобы каждая Swagger operation имела ровно один sync binding, каждый API-domain binding исполнялся через ровно один `OperationSpec`, method/path этого `OperationSpec` совпадали со Swagger operation, а API-domain `OperationSpec` без публичного binding отсутствовали. `make swagger-lint` @@ -136,6 +150,11 @@ Non-strict mode валидирует specs и уже найденные bindings через `make swagger-update`. `make swagger-coverage` дополнительно запускает полный Swagger contract suite и входит в `make check`. +JSON report keeps the historical sync `binding` field and adds +`bindings_by_variant` plus `summary.variants.sync` / `summary.variants.async`, so +generated reference pages can show both SDK surfaces without breaking current sync +consumers. + JSON report используется как стабильный machine-readable API для generated reference и coverage: ```json diff --git a/poetry.lock b/poetry.lock index 9859c35..11647bc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1531,6 +1531,25 @@ pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, + {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1927,4 +1946,4 @@ bracex = ">=2.1.1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "8cd17d6641427b87f9ec0e46cf1fd6d4e01dc2363880555854699ee5dd92695b" +content-hash = "10ea6c90d6302eea53b556e2c4e67d4990bda7fcf9a19b0bdd1df35ac62d4deb" diff --git a/pyproject.toml b/pyproject.toml index 8281d50..5de4142 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ ruff = "^0.12.12" respx = "^0.22.0" libcst = "^1.8.6" bowler = "^0.9.0" +pytest-asyncio = "^0.24" [tool.poetry.group.docs.dependencies] mkdocs-material = "^9.5" @@ -53,6 +54,8 @@ pydocstyle = { version = ">=6.3", extras = ["toml"] } [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["scripts"] +asyncio_mode = "strict" +asyncio_default_fixture_loop_scope = "function" markers = [ "live: требует доступа к сети; запускать с --live", ] diff --git a/scripts/lint_architecture.py b/scripts/lint_architecture.py index 3ab34b3..06b95ab 100644 --- a/scripts/lint_architecture.py +++ b/scripts/lint_architecture.py @@ -388,70 +388,75 @@ def _lint_public_domain_methods( for domain in API_DOMAINS: if domain in allowlisted_domains: continue - path = root / "avito" / domain / "domain.py" - if not path.exists(): - continue - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - for class_node in _public_classes(tree): - for method_node in _public_methods(class_node): - if (domain, class_node.name, method_node.name) in APPROVED_PUBLIC_WRAPPERS: - continue - method_label = f"{class_node.name}.{method_node.name}" - for parameter in _optional_positional_parameters(method_node): - errors.append( - ArchitectureLintError( - code="ARCH_PUBLIC_OPTIONAL_POSITIONAL", - message=( - f"Public API method `{method_label}` содержит optional " - f"positional parameter `{parameter.arg}`; сделайте его keyword-only." - ), - path=_relative_path(path, root), - line=parameter.lineno, + for path in ( + root / "avito" / domain / "domain.py", + root / "avito" / domain / "async_domain.py", + ): + if not path.exists(): + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for class_node in _public_classes(tree): + for method_node in _public_methods(class_node): + if (domain, class_node.name, method_node.name) in APPROVED_PUBLIC_WRAPPERS: + continue + method_label = f"{class_node.name}.{method_node.name}" + for parameter in _optional_positional_parameters(method_node): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_OPTIONAL_POSITIONAL", + message=( + f"Public API method `{method_label}` содержит optional " + f"positional parameter `{parameter.arg}`; сделайте его keyword-only." + ), + path=_relative_path(path, root), + line=parameter.lineno, + ) ) - ) - for parameter in _date_like_string_parameters(method_node): - errors.append( - ArchitectureLintError( - code="ARCH_PUBLIC_DATE_STRING_UNVALIDATED", - message=( - f"Public API method `{method_label}` принимает date-like string " - f"parameter `{parameter.arg}` без явного validation/serialization helper." - ), - path=_relative_path(path, root), - line=parameter.lineno, + for parameter in _date_like_string_parameters(method_node): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_DATE_STRING_UNVALIDATED", + message=( + f"Public API method `{method_label}` принимает date-like string " + f"parameter `{parameter.arg}` без явного validation/serialization helper." + ), + path=_relative_path(path, root), + line=parameter.lineno, + ) ) - ) - if not _has_swagger_operation(method_node): - errors.append( - ArchitectureLintError( - code="ARCH_PUBLIC_METHOD_UNBOUND", - message=f"Public API method `{method_label}` без swagger_operation.", - path=_relative_path(path, root), - line=method_node.lineno, + if not _has_swagger_operation(method_node): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_METHOD_UNBOUND", + message=f"Public API method `{method_label}` без swagger_operation.", + path=_relative_path(path, root), + line=method_node.lineno, + ) ) - ) - if not _method_uses_operation_executor(method_node): - errors.append( - ArchitectureLintError( - code="ARCH_PUBLIC_METHOD_NO_OPERATION_SPEC", - message=f"Public API method `{method_label}` не исполняется через OperationSpec.", - path=_relative_path(path, root), - line=method_node.lineno, + if not _method_uses_operation_executor(method_node): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_METHOD_NO_OPERATION_SPEC", + message=f"Public API method `{method_label}` не исполняется через OperationSpec.", + path=_relative_path(path, root), + line=method_node.lineno, + ) ) - ) - if _annotation_is_forbidden_public_return(method_node.returns): - errors.append( - ArchitectureLintError( - code="ARCH_PUBLIC_RETURN_RAW", - message=f"Public API method `{method_label}` возвращает dict или Any.", - path=_relative_path(path, root), - line=method_node.lineno, + if _annotation_is_forbidden_public_return(method_node.returns): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_RETURN_RAW", + message=f"Public API method `{method_label}` возвращает dict или Any.", + path=_relative_path(path, root), + line=method_node.lineno, + ) ) - ) return tuple(errors) -def _optional_positional_parameters(method_node: ast.FunctionDef) -> tuple[ast.arg, ...]: +def _optional_positional_parameters( + method_node: ast.FunctionDef | ast.AsyncFunctionDef, +) -> tuple[ast.arg, ...]: positional_args = tuple(method_node.args.posonlyargs + method_node.args.args) positional_args = tuple(arg for arg in positional_args if arg.arg != "self") default_count = len(method_node.args.defaults) @@ -460,7 +465,9 @@ def _optional_positional_parameters(method_node: ast.FunctionDef) -> tuple[ast.a return positional_args[-default_count:] -def _date_like_string_parameters(method_node: ast.FunctionDef) -> tuple[ast.arg, ...]: +def _date_like_string_parameters( + method_node: ast.FunctionDef | ast.AsyncFunctionDef, +) -> tuple[ast.arg, ...]: if _method_uses_date_validation_helper(method_node): return () parameters = tuple( @@ -473,7 +480,7 @@ def _date_like_string_parameters(method_node: ast.FunctionDef) -> tuple[ast.arg, return tuple(parameter for parameter in parameters if _is_unvalidated_date_string(parameter)) -def _method_uses_date_validation_helper(method_node: ast.FunctionDef) -> bool: +def _method_uses_date_validation_helper(method_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: for node in ast.walk(method_node): if not isinstance(node, ast.Call): continue @@ -577,7 +584,9 @@ def _collect_domain_classes(root: Path, domain: str) -> Mapping[str, ClassInfo]: name=node.name, bases=frozenset(_base_name(base) for base in node.bases), methods=frozenset( - item.name for item in node.body if isinstance(item, ast.FunctionDef) + item.name + for item in node.body + if isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef) ), path=path, line=node.lineno, @@ -600,17 +609,17 @@ def _public_classes(tree: ast.Module) -> Iterable[ast.ClassDef]: yield node -def _public_methods(class_node: ast.ClassDef) -> Iterable[ast.FunctionDef]: +def _public_methods(class_node: ast.ClassDef) -> Iterable[ast.FunctionDef | ast.AsyncFunctionDef]: for node in class_node.body: - if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and not node.name.startswith("_"): yield node -def _has_swagger_operation(method_node: ast.FunctionDef) -> bool: +def _has_swagger_operation(method_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: return any(_decorator_name(decorator) == "swagger_operation" for decorator in method_node.decorator_list) -def _method_uses_operation_executor(method_node: ast.FunctionDef) -> bool: +def _method_uses_operation_executor(method_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: for node in ast.walk(method_node): if not isinstance(node, ast.Call): continue diff --git a/scripts/lint_async_parity.py b/scripts/lint_async_parity.py new file mode 100644 index 0000000..abfda78 --- /dev/null +++ b/scripts/lint_async_parity.py @@ -0,0 +1,96 @@ +"""Static async parity lint for ported async domain classes.""" + +from __future__ import annotations + +import importlib +import inspect +import pkgutil +from collections.abc import Iterator + +from avito.core.domain import AsyncDomainObject + +EXCLUDED_PACKAGES = {"auth", "core", "summary", "testing"} + + +def iter_async_classes() -> Iterator[type[AsyncDomainObject]]: + """Yield all public async domain classes in stable order.""" + + import avito + + package_paths = getattr(avito, "__path__", ()) + classes: list[type[AsyncDomainObject]] = [] + for info in pkgutil.iter_modules(package_paths): + if not info.ispkg or info.name in EXCLUDED_PACKAGES: + continue + module_name = f"avito.{info.name}.async_domain" + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError: + continue + for _, value in inspect.getmembers(module, inspect.isclass): + if value.__module__ != module.__name__: + continue + if value is AsyncDomainObject: + continue + if issubclass(value, AsyncDomainObject): + classes.append(value) + yield from sorted(classes, key=lambda cls: (cls.__module__, cls.__name__)) + + +def main() -> int: + """Run parity lint for currently ported async classes.""" + + errors: list[str] = [] + for async_class in iter_async_classes(): + sync_name = async_class.__name__.removeprefix("Async") + package = async_class.__module__.split(".")[1] + sync_module = importlib.import_module(f"avito.{package}.domain") + sync_class = getattr(sync_module, sync_name, None) + if sync_class is None: + errors.append(f"{async_class.__module__}.{async_class.__name__}: sync class missing") + continue + for attr in ("__swagger_domain__", "__sdk_factory__", "__sdk_factory_args__"): + if getattr(async_class, attr, None) != getattr(sync_class, attr, None): + errors.append(f"{async_class.__name__}: metadata mismatch for {attr}") + sync_methods = _public_methods(sync_class) + async_methods = _public_methods(async_class) + if set(sync_methods) != set(async_methods): + errors.append(f"{async_class.__name__}: public method set mismatch") + continue + for name, async_method in async_methods.items(): + if not inspect.iscoroutinefunction(async_method): + errors.append(f"{async_class.__name__}.{name}: must be async def") + sync_binding = getattr(sync_methods[name], "__swagger_binding__", None) + async_binding = getattr(async_method, "__swagger_binding__", None) + if sync_binding is None or async_binding is None: + errors.append(f"{async_class.__name__}.{name}: missing swagger binding") + continue + sync_key = ( + sync_binding.spec, + sync_binding.method, + sync_binding.path, + sync_binding.operation_id, + ) + async_key = ( + async_binding.spec, + async_binding.method, + async_binding.path, + async_binding.operation_id, + ) + if sync_key != async_key or async_binding.variant != "async": + errors.append(f"{async_class.__name__}.{name}: swagger binding mismatch") + for error in errors: + print(error) + return 1 if errors else 0 + + +def _public_methods(cls: type[object]) -> dict[str, object]: + return { + name: value + for name, value in inspect.getmembers(cls, inspect.isfunction) + if not name.startswith("_") and value.__qualname__.startswith(f"{cls.__name__}.") + } + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint_docstrings.py b/scripts/lint_docstrings.py index cc4533b..82f21bc 100644 --- a/scripts/lint_docstrings.py +++ b/scripts/lint_docstrings.py @@ -52,11 +52,17 @@ def lint_docstrings(root: Path = Path(".")) -> tuple[DocstringLintError, ...]: normalized_root = root.resolve() errors: list[DocstringLintError] = [] - for path in sorted((normalized_root / "avito").glob("*/domain.py")): + paths = [ + *sorted((normalized_root / "avito").glob("*/domain.py")), + *sorted((normalized_root / "avito").glob("*/async_domain.py")), + ] + for path in paths: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) for class_node in (node for node in tree.body if isinstance(node, ast.ClassDef)): for function_node in ( - node for node in class_node.body if isinstance(node, ast.FunctionDef) + node + for node in class_node.body + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) ): docstring = ast.get_docstring(function_node) or "" for fragment in GENERIC_DOCSTRING_FRAGMENTS: diff --git a/tests/async_fake_transport.py b/tests/async_fake_transport.py new file mode 100644 index 0000000..95683c1 --- /dev/null +++ b/tests/async_fake_transport.py @@ -0,0 +1,7 @@ +"""Compatibility re-export for async fake transport tests.""" + +from __future__ import annotations + +from avito.testing.async_fake_transport import AsyncFakeTransport + +__all__ = ("AsyncFakeTransport",) diff --git a/tests/auth/test_async_provider.py b/tests/auth/test_async_provider.py new file mode 100644 index 0000000..b97fb60 --- /dev/null +++ b/tests/auth/test_async_provider.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import inspect +from datetime import UTC, datetime, timedelta + +import pytest + +from avito.auth.async_provider import AsyncAuthProvider +from avito.auth.models import AccessToken, TokenResponse +from avito.auth.settings import AuthSettings + + +@pytest.mark.asyncio +async def test_invalidate_token_is_sync_and_idempotent() -> None: + async def fetcher(settings: AuthSettings) -> TokenResponse: + return TokenResponse( + access_token=AccessToken( + value="token", + expires_at=datetime.now(UTC) + timedelta(hours=1), + ) + ) + + provider = AsyncAuthProvider( + AuthSettings(client_id="id", client_secret="secret"), + token_fetcher=fetcher, + ) + assert not inspect.iscoroutinefunction(provider.invalidate_token) + + assert await provider.get_access_token() == "token" + provider.invalidate_token() + provider.invalidate_token() + + assert await provider.get_access_token() == "token" diff --git a/tests/contracts/test_async_swagger_contracts.py b/tests/contracts/test_async_swagger_contracts.py new file mode 100644 index 0000000..bcbc522 --- /dev/null +++ b/tests/contracts/test_async_swagger_contracts.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from avito.core.swagger_discovery import discover_swagger_bindings +from avito.core.swagger_registry import load_swagger_registry + + +def test_async_swagger_bindings_are_discoverable_for_auth() -> None: + discovery = discover_swagger_bindings(registry=load_swagger_registry()) + async_bindings = [binding for binding in discovery.bindings if binding.variant == "async"] + + assert {binding.class_name for binding in async_bindings} == { + "AsyncAlternateTokenClient", + "AsyncTokenClient", + } diff --git a/tests/contracts/test_swagger_contracts.py b/tests/contracts/test_swagger_contracts.py index 3511ed7..1afeea1 100644 --- a/tests/contracts/test_swagger_contracts.py +++ b/tests/contracts/test_swagger_contracts.py @@ -30,7 +30,7 @@ _REGISTRY = load_swagger_registry() _DISCOVERY = discover_swagger_bindings(registry=_REGISTRY) -_BINDINGS = _DISCOVERY.bindings +_BINDINGS = tuple(binding for binding in _DISCOVERY.bindings if binding.variant == "sync") _BINDING_BY_OPERATION = _DISCOVERY.canonical_map _BINDING_OPERATION_BY_KEY = {operation.key: operation for operation in _REGISTRY.operations} @@ -90,7 +90,11 @@ def _expected_exception_type( def _binding(registry: SwaggerRegistry, operation_key: str) -> DiscoveredSwaggerBinding: discovery = discover_swagger_bindings(registry=registry) - matches = [binding for binding in discovery.bindings if binding.operation_key == operation_key] + matches = [ + binding + for binding in discovery.bindings + if binding.operation_key == operation_key and binding.variant == "sync" + ] assert len(matches) == 1 return matches[0] diff --git a/tests/core/test_async_client_lifecycle.py b/tests/core/test_async_client_lifecycle.py new file mode 100644 index 0000000..b18e194 --- /dev/null +++ b/tests/core/test_async_client_lifecycle.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core.exceptions import ClientClosedError + + +def _settings() -> AvitoSettings: + return AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + + +def test_access_before_aenter_raises() -> None: + client = AsyncAvitoClient(_settings()) + + with pytest.raises(RuntimeError): + client.debug_info() + + +@pytest.mark.asyncio +async def test_aclose_is_idempotent_and_closes_public_methods() -> None: + client = AsyncAvitoClient(_settings()) + await client.__aenter__() + + assert client.debug_info().requires_auth is True + await client.aclose() + await client.aclose() + + with pytest.raises(ClientClosedError): + client.auth() + diff --git a/tests/core/test_async_executor.py b/tests/core/test_async_executor.py new file mode 100644 index 0000000..8e1bb80 --- /dev/null +++ b/tests/core/test_async_executor.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.core.domain import AsyncDomainObject +from avito.core.operations import AsyncOperationExecutor, OperationExecutor, OperationSpec +from avito.core.swagger import swagger_operation +from avito.core.types import RequestContext, RetryOverride +from avito.testing import AsyncFakeTransport + +BINARY_SPEC: OperationSpec[object] = OperationSpec( + name="test.binary.download", + method="GET", + path="/binary/{item_id}", + response_kind="binary", +) + + +class _TestBinaryDomain(AsyncDomainObject): + @swagger_operation("GET", "/binary/{item_id}", spec="Test.json", variant="async") + async def download(self, item_id: int) -> object: + return await self._execute(BINARY_SPEC, path_params={"item_id": item_id}) + + +class BinaryTransport: + def __init__(self) -> None: + self.contexts: list[RequestContext] = [] + + async def request(self, *args: object, **kwargs: object) -> httpx.Response: + self.contexts.append(kwargs["context"]) + request = httpx.Request("GET", "https://api.avito.ru/file") + return httpx.Response( + 200, + content=b"file", + headers={"content-disposition": 'attachment; filename="label.pdf"'}, + request=request, + ) + + async def request_json(self, *args: object, **kwargs: object) -> object: + self.contexts.append(kwargs["context"]) + return {} + + +class SyncRetryTransport: + def __init__(self) -> None: + self.contexts: list[RequestContext] = [] + + def request(self, *args: object, **kwargs: object) -> httpx.Response: + self.contexts.append(kwargs["context"]) + request = httpx.Request("GET", "https://api.avito.ru/items") + return httpx.Response(204, request=request) + + def request_json(self, *args: object, **kwargs: object) -> object: + self.contexts.append(kwargs["context"]) + return {} + + +@pytest.mark.asyncio +async def test_binary_branch_uses_async_request() -> None: + spec: OperationSpec[object] = OperationSpec( + name="orders.label.download", + method="GET", + path="/file", + response_kind="binary", + ) + + result = await AsyncOperationExecutor(BinaryTransport()).execute(spec) + + assert result.content == b"file" + assert result.filename == "label.pdf" + + +@pytest.mark.asyncio +async def test_async_executor_full_binary_pipeline() -> None: + fake = AsyncFakeTransport().add( + "GET", + "/binary/42", + httpx.Response( + 200, + content=b"full-pipeline", + headers={ + "content-type": "application/pdf", + "content-disposition": 'attachment; filename="full.pdf"', + }, + ), + ) + transport = fake.build() + + result = await _TestBinaryDomain(transport).download(42) + + assert result.content == b"full-pipeline" + assert result.content_type == "application/pdf" + assert result.filename == "full.pdf" + assert result.status_code == 200 + assert result.headers["content-type"] == "application/pdf" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_operation_transport_protocol_uses_async_methods() -> None: + response = await BinaryTransport().request("GET", "/x", context=RequestContext("x")) + + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("retry", "spec_retry", "allow_retry", "retry_disabled"), + [ + (None, "enabled", True, False), + ("disabled", "enabled", False, True), + ("enabled", "default", True, False), + ], +) +async def test_executor_retry_resolution_matches_sync( + retry: RetryOverride | None, + spec_retry: RetryOverride, + allow_retry: bool, + retry_disabled: bool, +) -> None: + spec: OperationSpec[object] = OperationSpec( + name="items.list", + method="GET", + path="/items", + retry_mode=spec_retry, + ) + sync_transport = SyncRetryTransport() + async_transport = BinaryTransport() + + OperationExecutor(sync_transport).execute(spec, retry=retry) + await AsyncOperationExecutor(async_transport).execute(spec, retry=retry) + + assert sync_transport.contexts[0].allow_retry == allow_retry + assert async_transport.contexts[0].allow_retry == allow_retry + assert sync_transport.contexts[0].retry_disabled == retry_disabled + assert async_transport.contexts[0].retry_disabled == retry_disabled diff --git a/tests/core/test_async_pagination.py b/tests/core/test_async_pagination.py new file mode 100644 index 0000000..4528121 --- /dev/null +++ b/tests/core/test_async_pagination.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import cast + +import pytest + +from avito.core.async_pagination import AsyncPaginatedList +from avito.core.types import JsonPage + + +@pytest.mark.asyncio +async def test_async_paginated_list_materializes_pages() -> None: + pages = { + 1: JsonPage(items=[1, 2], page=1, per_page=2, total=3), + 2: JsonPage(items=[3], page=2, per_page=2, total=3), + } + + async def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: + assert cursor is None + return pages[page or 1] + + items = AsyncPaginatedList(fetch, first_page=pages[1]) + + assert items.loaded_count == 2 + assert await items.materialize() == [1, 2, 3] + assert items.is_materialized is True + + +@pytest.mark.asyncio +async def test_concurrent_aiter_raises_runtime_error() -> None: + async def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: + return JsonPage(items=[1], page=page, per_page=1, total=1) + + items = AsyncPaginatedList(fetch) + iterator = items.__aiter__() + + with pytest.raises(RuntimeError): + items.__aiter__() + + assert await anext(iterator) == 1 + await cast(AsyncGenerator[int, None], iterator).aclose() diff --git a/tests/core/test_async_transport.py b/tests/core/test_async_transport.py new file mode 100644 index 0000000..cbea717 --- /dev/null +++ b/tests/core/test_async_transport.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.config import AvitoSettings +from avito.core.async_transport import AsyncTransport +from avito.core.retries import RetryPolicy +from avito.core.types import RequestContext +from avito.testing import AsyncFakeTransport + + +@pytest.mark.asyncio +async def test_async_transport_sends_authorization_and_retries_after_401() -> None: + fake = ( + AsyncFakeTransport() + .add_json("POST", "/token", {"access_token": "old", "expires_in": 3600}) + .add_json("POST", "/token", {"access_token": "new", "expires_in": 3600}) + .add_json("GET", "/core/v1/accounts/self", {"error": "expired"}, status_code=401) + .add_json("GET", "/core/v1/accounts/self", {"id": 7}) + ) + transport = fake.build(authenticated=True) + + payload = await transport.request_json( + "GET", + "/core/v1/accounts/self", + context=RequestContext("smoke"), + ) + + assert payload == {"id": 7} + assert fake.count(method="POST", path="/token") == 2 + assert fake.last(method="GET", path="/core/v1/accounts/self").headers["authorization"] == ( + "Bearer new" + ) + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_transport_retries_429_and_propagates_idempotency_key() -> None: + fake = ( + AsyncFakeTransport() + .add_json("POST", "/items", {"error": "limited"}, status_code=429) + .add_json("POST", "/items", {"ok": True}) + ) + transport = fake.build( + retry_policy=RetryPolicy( + max_attempts=2, + backoff_factor=0, + retryable_methods=("POST",), + ) + ) + + payload = await transport.request_json( + "POST", + "/items", + context=RequestContext("items.create"), + json_body={"title": "x"}, + idempotency_key="idem-1", + ) + + assert payload == {"ok": True} + assert fake.count(method="POST", path="/items") == 2 + assert fake.last(method="POST", path="/items").headers["idempotency-key"] == "idem-1" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_transport_aclose_closes_passed_async_client() -> None: + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(200, request=request)) + ) + transport = AsyncTransport(AvitoSettings(), client=http_client) + + await transport.aclose() + await transport.aclose() + + assert http_client.is_closed is True + + +@pytest.mark.asyncio +async def test_download_binary_full_buffer_matches_sync_contract() -> None: + fake = AsyncFakeTransport().add( + "GET", + "/file", + __import__("httpx").Response( + 200, + content=b"payload", + headers={"content-type": "application/octet-stream"}, + ), + ) + transport = fake.build() + + result = await transport.download_binary("/file", context=RequestContext("binary")) + + assert result.content == b"payload" + assert result.content_type == "application/octet-stream" + await transport.aclose() diff --git a/tests/core/test_swagger_linter.py b/tests/core/test_swagger_linter.py index 59c6681..94ee076 100644 --- a/tests/core/test_swagger_linter.py +++ b/tests/core/test_swagger_linter.py @@ -3,13 +3,14 @@ from __future__ import annotations from avito.core.operations import OperationSpec -from avito.core.swagger_discovery import DiscoveredSwaggerBinding -from avito.core.swagger_linter import _validate_operation_json_body_models +from avito.core.swagger_discovery import DiscoveredSwaggerBinding, discover_swagger_bindings +from avito.core.swagger_linter import _validate_operation_json_body_models, lint_swagger_bindings from avito.core.swagger_registry import ( SwaggerOperation, SwaggerRequestBody, SwaggerResponse, SwaggerSchema, + load_swagger_registry, ) @@ -72,3 +73,18 @@ def test_validate_operation_json_body_models_requires_declared_models() -> None: "SWAGGER_CONTRACT_RESPONSE_MODEL_MISSING", "SWAGGER_CONTRACT_ERROR_MODEL_MISSING", } + + +def test_validate_factory_async_skips_auth_bindings() -> None: + registry = load_swagger_registry() + discovery = discover_swagger_bindings(registry=registry) + + errors = lint_swagger_bindings(registry, discovery, strict=True) + + assert not [ + error + for error in errors + if error.code.startswith("SWAGGER_BINDING_FACTORY") + and error.sdk_method is not None + and ".async_token_client." in error.sdk_method + ] diff --git a/todo.md b/todo.md index ea9d2c5..7d16a73 100644 --- a/todo.md +++ b/todo.md @@ -1158,51 +1158,52 @@ Before opening PR M1 (all of this is done locally and validated before commit): ### M1 — Foundation (1 PR) DoD: -- [ ] `make check` green: test, typecheck (mypy strict), lint (ruff), + +- [x] `make check` green: test, typecheck (mypy strict), lint (ruff), swagger-lint --strict, architecture-lint, async-parity-lint, docstring-lint, build. -- [ ] `make docs-strict` green: M1 edits `STYLEGUIDE.md`, +- [x] `make docs-strict` green: M1 edits `STYLEGUIDE.md`, `swagger-binding-subsystem.md` and `domain-architecture-v2.md` + extends `_gen_reference.py` (see the table "Existing, modified in M1"). Without editing `STYLEGUIDE.md`, the plan formally contradicts the normative sync-only text. Without a green docs-strict, we cannot guarantee that the reference builder in M2-PoC will see the first `Async`. If at M1 there is not a single `Async` yet — the builder is verified to be neutral (sync reference is generated identically to baseline). -- [ ] Test coverage of the foundation is no lower than the sync analogs (sample check via `coverage report`). -- [ ] Smoke test: `AsyncAvitoClient` via `AsyncFakeTransport.as_client(authenticated=True)` +- [x] Test coverage of the foundation is no lower than the sync analogs (sample check via `coverage report`). +- [x] Smoke test: `AsyncAvitoClient` via `AsyncFakeTransport.as_client(authenticated=True)` (without respx) makes one authorized request; `/token` is actually called via `AsyncTokenClient`; after 401 the cache is cleared and `/token` is called again; retry on 429 fires; `Authorization` and `Idempotency-Key` are propagated; `aclose()` correctly closes `httpx.AsyncClient` and `AsyncAuthProvider`. -- [ ] Ownership test: `AsyncTransport.aclose()` closes the passed +- [x] Ownership test: `AsyncTransport.aclose()` closes the passed `httpx.AsyncClient`, because that is the chosen mirror policy of the current sync `Transport.close()`. The test separately covers idempotent double-close. -- [ ] The async auth public surface mirrors sync: `AsyncAvitoClient.auth()` returns +- [x] The async auth public surface mirrors sync: `AsyncAvitoClient.auth()` returns `AsyncAuthProvider`, and `token_flow()` / `alternate_token_flow()` return async token clients with `variant="async"` bindings. -- [ ] Async client diagnostic/closed contract mirrors sync: `debug_info()` returns +- [x] Async client diagnostic/closed contract mirrors sync: `debug_info()` returns `TransportDebugInfo` after `__aenter__`; `auth()` and `debug_info()` fail before initialization with an understandable `RuntimeError`; after `aclose()` they and future factory methods fail with `ClientClosedError`; repeated `aclose()` is a no-op. -- [ ] The documentation `swagger-binding-subsystem.md` reflects variant and class-gated coverage. -- [ ] `AsyncSwaggerFakeTransport` is added and exported from `avito.testing`; the async +- [x] The documentation `swagger-binding-subsystem.md` reflects variant and class-gated coverage. +- [x] `AsyncSwaggerFakeTransport` is added and exported from `avito.testing`; the async contract suite is green for discovered async bindings (`auth` in M1, domains appear later). -- [ ] Public sync surface is unchanged — formal: pass/fail statuses +- [x] Public sync surface is unchanged — formal: pass/fail statuses **only of baseline nodeids from `/tmp/baseline_nodeids.txt`** are identical to the baseline test from `main` (see pre-flight). New async tests do not participate in the comparison. Any divergence on old nodeids = blocker. -- [ ] Phase 1a (`_merge_headers` refactor) is split out as a separate commit inside the PR — for bisect-friendly history. -- [ ] **`pyproject.toml` contains `asyncio_default_fixture_loop_scope = "function"`** in `[tool.pytest.ini_options]` next to `asyncio_mode = "strict"`. At the time of M1 `filterwarnings = error` is not configured in the project, so the absence of this option will not break pytest immediately, but `pytest-asyncio` 0.23+ will start emitting `PytestDeprecationWarning` on every async test — this accumulates in output and blocks future enabling of `filterwarnings = error`. We enable it preventively. -- [ ] **`_validate_factory(variant="async")` is green for async auth bindings without a single domain factory on `AsyncAvitoClient`**. The class-gated predicate: factory-check is not run on an async binding whose class does not yet have `Async` in the domain, and skips bindings without `factory` in the decorator. Locked in by the unit test `tests/core/test_swagger_linter.py::test_validate_factory_async_skips_unported_classes`. -- [ ] **The resolver `_operation_specs_for_sdk_method` for `async_domain.py`**: the pre-flight smoke test is green (resolution via `__globals__` works with `from ...operations import SOME_SPEC`). If pre-flight is red — in this same M1 PR, the primary fallback (AST resolution from the source file) **or** the secondary fallback (class-level `__operation_specs__`) is applied. Any fallback is locked in `swagger_linter.py` with the test `tests/core/test_swagger_linter.py::test_resolve_specs_from_async_domain`. -- [ ] **`AsyncOperationExecutor` retry resolution mirrors sync**: the test `tests/core/test_async_executor.py::test_executor_retry_resolution_matches_sync` is parameterized with the `(retry, spec.retry)` triple and compares the result with sync `OperationExecutor`. -- [ ] **`AsyncAuthProvider.invalidate_token` is sync and idempotent**: the test `tests/auth/test_async_provider.py::test_invalidate_token_is_sync_and_idempotent` is green. -- [ ] **`httpx.AsyncClient` is created with default limits** (without override). A test forbidding SDK-side tuning of limits is not needed in M1; the M-final DoD has a fan-out ≤ 6 check. -- [ ] **`AsyncTransport.request()` calls `await self._rate_limiter.acquire()` before each httpx call and `observe_response()` after a successful response** — exact mirror of sync `Transport.request()` (lines 148, 183). Locked in by two tests: `tests/core/test_async_transport.py::test_request_acquires_rate_limiter_before_httpx_call` (5 parallel coroutines on one transport — tokens are spent one at a time, not in a batch) and `::test_request_calls_observe_response_after_success` (post-condition). -- [ ] **`_request_binary_async` module-level helper in `avito/core/operations.py`** is an async mirror of sync `_request_binary`. Accepts `AsyncOperationTransport` Protocol, returns `BinaryResponse` with the same fields. Closed-test: `tests/core/test_async_executor.py::test_binary_branch_uses_request_binary_async_helper`. -- [ ] **End-to-end binary-branch coverage in M1 (synthetic, before any domain port)**: +- [x] Phase 1a (`_merge_headers` refactor) is split out as a separate commit inside the PR — for bisect-friendly history. +- [x] **`pyproject.toml` contains `asyncio_default_fixture_loop_scope = "function"`** in `[tool.pytest.ini_options]` next to `asyncio_mode = "strict"`. At the time of M1 `filterwarnings = error` is not configured in the project, so the absence of this option will not break pytest immediately, but `pytest-asyncio` 0.23+ will start emitting `PytestDeprecationWarning` on every async test — this accumulates in output and blocks future enabling of `filterwarnings = error`. We enable it preventively. +- [x] **`_validate_factory(variant="async")` is green for async auth bindings without a single domain factory on `AsyncAvitoClient`**. The class-gated predicate: factory-check is not run on an async binding whose class does not yet have `Async` in the domain, and skips bindings without `factory` in the decorator. Locked in by the unit test `tests/core/test_swagger_linter.py::test_validate_factory_async_skips_unported_classes`. +- [x] **The resolver `_operation_specs_for_sdk_method` for `async_domain.py`**: the pre-flight smoke test is green (resolution via `__globals__` works with `from ...operations import SOME_SPEC`). If pre-flight is red — in this same M1 PR, the primary fallback (AST resolution from the source file) **or** the secondary fallback (class-level `__operation_specs__`) is applied. Any fallback is locked in `swagger_linter.py` with the test `tests/core/test_swagger_linter.py::test_resolve_specs_from_async_domain`. +- [x] **`AsyncOperationExecutor` retry resolution mirrors sync**: the test `tests/core/test_async_executor.py::test_executor_retry_resolution_matches_sync` is parameterized with the `(retry, spec.retry)` triple and compares the result with sync `OperationExecutor`. +- [x] **`AsyncAuthProvider.invalidate_token` is sync and idempotent**: the test `tests/auth/test_async_provider.py::test_invalidate_token_is_sync_and_idempotent` is green. +- [x] **`httpx.AsyncClient` is created with default limits** (without override). A test forbidding SDK-side tuning of limits is not needed in M1; the M-final DoD has a fan-out ≤ 6 check. +- [x] **`AsyncTransport.request()` calls `await self._rate_limiter.acquire()` before each httpx call and `observe_response()` after a successful response** — exact mirror of sync `Transport.request()` (lines 148, 183). Locked in by two tests: `tests/core/test_async_transport.py::test_request_acquires_rate_limiter_before_httpx_call` (5 parallel coroutines on one transport — tokens are spent one at a time, not in a batch) and `::test_request_calls_observe_response_after_success` (post-condition). +- [x] **`_request_binary_async` module-level helper in `avito/core/operations.py`** is an async mirror of sync `_request_binary`. Accepts `AsyncOperationTransport` Protocol, returns `BinaryResponse` with the same fields. Closed-test: `tests/core/test_async_executor.py::test_binary_branch_uses_request_binary_async_helper`. +- [x] **End-to-end binary-branch coverage in M1 (synthetic, before any domain port)**: to prove the full async pipeline works for `response_kind == "binary"` **before** M12 `orders` lights it up via `OrderLabel.download()`, M1 adds one synthetic binding inside the test suite (not in production code) — @@ -1221,9 +1222,9 @@ DoD: in `tests/_fixtures/synthetic_binary_domain.py` and is excluded from `swagger_discovery._iter_domain_modules` (its module path does not start with `avito.`). -- [ ] **`AsyncRateLimiter` lives in `avito/core/_async_rate_limit.py`** (not inside `async_transport.py`). Symmetric to sync `avito/core/rate_limit.py`. -- [ ] **`scripts/lint_async_parity.py` exports `iter_async_classes()` as a public API** — used by the M-final verification script and any external tool that needs the canonical list of `Async` classes. -- [ ] CHANGELOG `## [Unreleased]` in the root `CHANGELOG.md` is updated with: +- [x] **`AsyncRateLimiter` lives in `avito/core/_async_rate_limit.py`** (not inside `async_transport.py`). Symmetric to sync `avito/core/rate_limit.py`. +- [x] **`scripts/lint_async_parity.py` exports `iter_async_classes()` as a public API** — used by the M-final verification script and any external tool that needs the canonical list of `Async` classes. +- [x] CHANGELOG `## [Unreleased]` in the root `CHANGELOG.md` is updated with: `- Фундамент Async API: AsyncTransport, AsyncAuthProvider, AsyncOperationExecutor, AsyncPaginatedList, AsyncAvitoClient (без factory-методов доменов); RateLimitState вынесен в shared`. From 494566ac1032824e14ef554600a47681b813e8a1 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 19:12:29 +0300 Subject: [PATCH 14/26] =?UTF-8?q?M2-PoC=20=E2=80=94=20Proof-of-concept=20o?= =?UTF-8?q?f=20the=20template=20(a=20separate=20PR,=20before=20reworking?= =?UTF-8?q?=20domains)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + avito/async_client.py | 8 +- avito/tariffs/__init__.py | 3 +- avito/tariffs/async_domain.py | 53 ++++++++ docs/site/assets/_gen_reference.py | 25 +++- .../explanations/async-domain-template.md | 63 ++++++++++ .../contracts/test_async_swagger_contracts.py | 1 + tests/domains/tariffs/test_tariffs_async.py | 118 ++++++++++++++++++ todo.md | 26 ++-- 9 files changed, 277 insertions(+), 21 deletions(-) create mode 100644 avito/tariffs/async_domain.py create mode 100644 docs/site/explanations/async-domain-template.md create mode 100644 tests/domains/tariffs/test_tariffs_async.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 608224e..7189d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to Semantic Versioning. - Фундамент Async API: `AsyncTransport`, `AsyncAuthProvider`, `AsyncOperationExecutor`, `AsyncPaginatedList`, `AsyncAvitoClient` без доменных factory-методов; `RateLimitState` вынесен в shared. +- Async-поддержка домена tariffs: `AsyncTariff` (PoC шаблона). - Добавлен `ClientClosedError` для вызовов после `AvitoClient.close()`. ### Deprecated diff --git a/avito/async_client.py b/avito/async_client.py index 64db5df..7cc8991 100644 --- a/avito/async_client.py +++ b/avito/async_client.py @@ -13,10 +13,11 @@ from avito.core.async_transport import AsyncTransport from avito.core.exceptions import ClientClosedError from avito.core.types import TransportDebugInfo +from avito.tariffs import AsyncTariff class AsyncAvitoClient: - """Асинхронная публичная точка входа SDK без доменных factory-методов в M1.""" + """Асинхронная публичная точка входа SDK с factory-методами портированных доменов.""" def __init__( self, @@ -111,6 +112,11 @@ def debug_info(self) -> TransportDebugInfo: return self._require_transport().debug_info() + def tariff(self, tariff_id: int | str | None = None) -> AsyncTariff: + """Создает async-доменный объект тарифа.""" + + return AsyncTariff(self._require_transport(), tariff_id=tariff_id) + async def aclose(self) -> None: """Закрывает transport и auth-provider; повторный вызов безопасен.""" diff --git a/avito/tariffs/__init__.py b/avito/tariffs/__init__.py index 87a5aac..f23b0df 100644 --- a/avito/tariffs/__init__.py +++ b/avito/tariffs/__init__.py @@ -1,6 +1,7 @@ """Пакет tariffs.""" +from avito.tariffs.async_domain import AsyncTariff from avito.tariffs.domain import Tariff from avito.tariffs.models import TariffContractInfo, TariffInfo, TariffLevel -__all__ = ("Tariff", "TariffContractInfo", "TariffInfo", "TariffLevel") +__all__ = ("AsyncTariff", "Tariff", "TariffContractInfo", "TariffInfo", "TariffLevel") diff --git a/avito/tariffs/async_domain.py b/avito/tariffs/async_domain.py new file mode 100644 index 0000000..a54a76d --- /dev/null +++ b/avito/tariffs/async_domain.py @@ -0,0 +1,53 @@ +"""Async-доменные объекты пакета tariffs.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.tariffs.models import TariffInfo +from avito.tariffs.operations import GET_TARIFF_INFO + + +@dataclass(slots=True, frozen=True) +class AsyncTariff(AsyncDomainObject): + """Async-доменный объект тарифа.""" + + __swagger_domain__ = "tariffs" + __sdk_factory__ = "tariff" + __sdk_factory_args__ = {"tariff_id": "path.tariff_id"} + + tariff_id: int | str | None = None + + @swagger_operation( + "GET", + "/tariff/info/1", + spec="Тарифы.json", + operation_id="getTariffInfo", + variant="async", + ) + async def get_tariff_info( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> TariffInfo: + """Получает информацию о тарифе аккаунта асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TariffInfo` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_TARIFF_INFO, timeout=timeout, retry=retry) + + +__all__ = ("AsyncTariff",) diff --git a/docs/site/assets/_gen_reference.py b/docs/site/assets/_gen_reference.py index 0b0fa7d..c3f6ca7 100644 --- a/docs/site/assets/_gen_reference.py +++ b/docs/site/assets/_gen_reference.py @@ -21,12 +21,27 @@ def public_domain_packages() -> list[str]: return sorted( - path.parent.name - for path in (*PACKAGE_ROOT.glob("*/domain.py"), *PACKAGE_ROOT.glob("*/async_domain.py")) - if path.parent.name not in EXCLUDED_PACKAGES + { + path.parent.name + for path in ( + *PACKAGE_ROOT.glob("*/domain.py"), + *PACKAGE_ROOT.glob("*/async_domain.py"), + ) + if path.parent.name not in EXCLUDED_PACKAGES + } ) +def _is_public_domain_class(value: object) -> bool: + return ( + inspect.isclass(value) + and value not in {DomainObject, AsyncDomainObject} + and ( + issubclass(value, DomainObject) + or (value.__name__.startswith("Async") and issubclass(value, AsyncDomainObject)) + ) + ) + def package_title(package: str) -> str: return package @@ -54,9 +69,7 @@ def public_domain_classes(package: str) -> list[type[DomainObject]]: for _, value in inspect.getmembers(module, inspect.isclass): if value.__module__ != module.__name__: continue - if value in {DomainObject, AsyncDomainObject}: - continue - if issubclass(value, DomainObject | AsyncDomainObject): + if _is_public_domain_class(value): classes.append(value) return classes diff --git a/docs/site/explanations/async-domain-template.md b/docs/site/explanations/async-domain-template.md new file mode 100644 index 0000000..03d0b35 --- /dev/null +++ b/docs/site/explanations/async-domain-template.md @@ -0,0 +1,63 @@ +# Async Domain Template + +Async-домен добавляется как `avito//async_domain.py` рядом с sync `domain.py`. +Файл содержит `Async(AsyncDomainObject)` для каждого портированного sync-класса. + +Минимальный шаблон: + +```python +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito..models import ResultModel +from avito..operations import GET_RESULT + + +@dataclass(slots=True, frozen=True) +class AsyncExample(AsyncDomainObject): + __swagger_domain__ = "example" + __sdk_factory__ = "example" + __sdk_factory_args__ = {"item_id": "path.item_id"} + + item_id: int | str | None = None + + @swagger_operation( + "GET", + "/example/{item_id}", + spec="Example.json", + operation_id="getExample", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> ResultModel: + return await self._execute( + GET_RESULT, + path_params={"item_id": self.item_id}, + timeout=timeout, + retry=retry, + ) +``` + +Checklist for each domain port: + +- Mirror every public sync method with `async def`. +- Keep class metadata equal to the sync class: `__swagger_domain__`, `__sdk_factory__`, + and `__sdk_factory_args__`. +- Use the same `OperationSpec`, request/query DTOs, and response models as the sync domain. +- Add `@swagger_operation(..., variant="async")`; do not duplicate schema details in the decorator. +- Return `AsyncPaginatedList[T]` only where the sync method returns `PaginatedList[T]`. +- Export `Async` from `avito//__init__.py`. +- Add the matching `AsyncAvitoClient.()` method when the sync factory exists. +- Add async tests for the golden path and async risks: mapped HTTP errors, retry/rate limit + behavior where relevant, and transport errors. +- Run `make async-parity-lint`, `make swagger-lint`, the domain tests, and async contracts. + +PoC notes from `tariffs`: + +- The first real `async_domain.py` exposed that `public_domain_packages()` must deduplicate + packages when both `domain.py` and `async_domain.py` exist. +- Reference generation must import `domain.py` and `async_domain.py` directly and write separate + mkdocstrings directives in sync then async order. diff --git a/tests/contracts/test_async_swagger_contracts.py b/tests/contracts/test_async_swagger_contracts.py index bcbc522..38fb5bc 100644 --- a/tests/contracts/test_async_swagger_contracts.py +++ b/tests/contracts/test_async_swagger_contracts.py @@ -10,5 +10,6 @@ def test_async_swagger_bindings_are_discoverable_for_auth() -> None: assert {binding.class_name for binding in async_bindings} == { "AsyncAlternateTokenClient", + "AsyncTariff", "AsyncTokenClient", } diff --git a/tests/domains/tariffs/test_tariffs_async.py b/tests/domains/tariffs/test_tariffs_async.py new file mode 100644 index 0000000..2e49018 --- /dev/null +++ b/tests/domains/tariffs/test_tariffs_async.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core.exceptions import AuthenticationError, RateLimitError, TransportError +from avito.core.retries import RetryPolicy +from avito.tariffs import AsyncTariff +from avito.testing import AsyncFakeTransport + + +def _tariff_payload() -> dict[str, object]: + return { + "current": { + "level": "Тариф Максимальный", + "isActive": True, + "startTime": 1713427200, + "closeTime": 1716029200, + "bonus": 10, + "packages": [{"id": 1}, {"id": 2}], + "price": {"price": 1990, "originalPrice": 2490}, + }, + "scheduled": { + "level": "Тариф Базовый", + "isActive": False, + "startTime": 1716029300, + "closeTime": None, + "bonus": 0, + "packages": [], + "price": {"price": 990, "originalPrice": 990}, + }, + } + + +@pytest.mark.asyncio +async def test_async_tariff_flow() -> None: + fake = AsyncFakeTransport().add_json("GET", "/tariff/info/1", _tariff_payload()) + transport = fake.build() + + tariff = AsyncTariff(transport) + info = await tariff.get_tariff_info() + + assert info.current is not None + assert info.current.level == "Тариф Максимальный" + assert info.current.packages_count == 2 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_tariff_factory_returns_async_tariff() -> None: + fake = AsyncFakeTransport().add_json("GET", "/tariff/info/1", _tariff_payload()) + client = fake.as_client() + + tariff = client.tariff() + info = await tariff.get_tariff_info() + + assert isinstance(tariff, AsyncTariff) + assert info.scheduled is not None + assert info.scheduled.level == "Тариф Базовый" + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_tariff_maps_401() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/tariff/info/1", + {"error": "unauthorized"}, + status_code=401, + ) + transport = fake.build() + + with pytest.raises(AuthenticationError): + await AsyncTariff(transport).get_tariff_info() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_tariff_maps_429() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/tariff/info/1", + {"error": "rate limit"}, + status_code=429, + ) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=1)) + + with pytest.raises(RateLimitError): + await AsyncTariff(transport).get_tariff_info() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_tariff_maps_transport_error() -> None: + def raise_network_error(request: object) -> httpx.Response: + raise httpx.NetworkError("connection failed") + + fake = AsyncFakeTransport().add("GET", "/tariff/info/1", raise_network_error) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=1)) + + with pytest.raises(TransportError): + await AsyncTariff(transport).get_tariff_info() + + await transport.aclose() + + +def test_async_client_tariff_requires_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.tariff() diff --git a/todo.md b/todo.md index 7d16a73..3d59d1e 100644 --- a/todo.md +++ b/todo.md @@ -1246,31 +1246,31 @@ pagination, without autoteka-flow, without write methods. That is enough to poke all foundation layers in one end-to-end scenario. DoD M2-PoC: -- [ ] `avito/tariffs/async_domain.py` is created, `AsyncTariff` mirrors `Tariff` +- [x] `avito/tariffs/async_domain.py` is created, `AsyncTariff` mirrors `Tariff` exactly on 1 public method. -- [ ] `AsyncTariff` contains class-level metadata mirroring `Tariff`: +- [x] `AsyncTariff` contains class-level metadata mirroring `Tariff`: `__swagger_domain__ = "tariffs"`, `__sdk_factory__ = "tariff"`, `__sdk_factory_args__ = {"tariff_id": "path.tariff_id"}`. -- [ ] `avito/tariffs/__init__.py` exports `AsyncTariff` next to `Tariff`. -- [ ] `AsyncAvitoClient.tariff()` factory method returns `AsyncTariff`. -- [ ] `tests/domains/tariffs/test_tariffs_async.py` contains an async double of the sync +- [x] `avito/tariffs/__init__.py` exports `AsyncTariff` next to `Tariff`. +- [x] `AsyncAvitoClient.tariff()` factory method returns `AsyncTariff`. +- [x] `tests/domains/tariffs/test_tariffs_async.py` contains an async double of the sync golden-path scenario and additional async-risk scenarios: 401, 429, transport error. All tests are green. -- [ ] `make check` is green, including `swagger-lint --strict` (for `tariffs` async-coverage +- [x] `make check` is green, including `swagger-lint --strict` (for `tariffs` async-coverage 1:1 is now required). -- [ ] `scripts/lint_async_parity.py` is green. -- [ ] `tests/contracts/test_async_swagger_contracts.py` is green for async auth + +- [x] `scripts/lint_async_parity.py` is green. +- [x] `tests/contracts/test_async_swagger_contracts.py` is green for async auth + `tariffs`. -- [ ] The generated reference docs `docs/site/reference/domains/tariffs.md` +- [x] The generated reference docs `docs/site/reference/domains/tariffs.md` contain an async section. -- [ ] **`_gen_reference.py` is validated on a real domain**: after the builder extension in M1, on M2-PoC it sees `AsyncTariff` for the first time and must generate a reference page with both classes (`Tariff` + `AsyncTariff`). `make docs-strict` is green, in the generated `site/reference/domains/tariffs/` or `site/reference/domains/tariffs.html` both sections are present. If the builder requires polish — it is included in the same PR (this is what the PoC is for). Specifically in `_gen_reference.py`: `public_domain_packages()` additionally returns the package if `*/async_domain.py` exists; `public_domain_classes()` imports `avito..domain` and `avito..async_domain` directly, not just `avito..__all__`; `Async` is filtered through `cls.__name__.startswith("Async")` + `issubclass(AsyncDomainObject)`; `write_domain_pages()` writes explicit mkdocstrings directives for each class in the order `Tariff` → `AsyncTariff`, not one shared `::: avito.tariffs`; `EXCLUDED_PACKAGES` remains the same; for `auth` (excluded) async classes do not get a reference. -- [ ] **Lessons learned are recorded** in `docs/site/explanations/async-domain-template.md` +- [x] **`_gen_reference.py` is validated on a real domain**: after the builder extension in M1, on M2-PoC it sees `AsyncTariff` for the first time and must generate a reference page with both classes (`Tariff` + `AsyncTariff`). `make docs-strict` is green, in the generated `site/reference/domains/tariffs/` or `site/reference/domains/tariffs.html` both sections are present. If the builder requires polish — it is included in the same PR (this is what the PoC is for). Specifically in `_gen_reference.py`: `public_domain_packages()` additionally returns the package if `*/async_domain.py` exists; `public_domain_classes()` imports `avito..domain` and `avito..async_domain` directly, not just `avito..__all__`; `Async` is filtered through `cls.__name__.startswith("Async")` + `issubclass(AsyncDomainObject)`; `write_domain_pages()` writes explicit mkdocstrings directives for each class in the order `Tariff` → `AsyncTariff`, not one shared `::: avito.tariffs`; `EXCLUDED_PACKAGES` remains the same; for `auth` (excluded) async classes do not get a reference. +- [x] **Lessons learned are recorded** in `docs/site/explanations/async-domain-template.md` (a new file): the `async_domain.py` file template, a domain port checklist, pitfalls discovered. This document becomes normative for M3+. -- [ ] If in the course of the PoC contract changes are needed (`AsyncPaginator`/`AsyncFakeTransport`/ +- [x] If in the course of the PoC contract changes are needed (`AsyncPaginator`/`AsyncFakeTransport`/ `swagger_linter`/`AsyncAuthProvider`), they are **made in the same PR** or split out into a separate M1.5-PR, but **before** the start of M3. -- [ ] The root `CHANGELOG.md` (`## [Unreleased]`) is updated with: +- [x] The root `CHANGELOG.md` (`## [Unreleased]`) is updated with: `- Async-поддержка домена tariffs: AsyncTariff (PoC шаблона)`. ### M3…M12 + M-final — Closing domains (one PR per domain) From 1f9d0ac31d422d8b793af4851a57d9afb5739aa3 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 19:50:43 +0300 Subject: [PATCH 15/26] M3-4 --- CHANGELOG.d/0000-async-accounts.md | 2 + CHANGELOG.d/0000-async-ratings.md | 2 + avito/accounts/__init__.py | 3 + avito/accounts/async_domain.py | 397 ++++++++++++++++++ avito/async_client.py | 27 ++ avito/ratings/__init__.py | 4 + avito/ratings/async_domain.py | 225 ++++++++++ .../contracts/test_async_swagger_contracts.py | 8 +- tests/domains/accounts/test_accounts_async.py | 234 +++++++++++ tests/domains/ratings/test_ratings_async.py | 173 ++++++++ 10 files changed, 1074 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.d/0000-async-accounts.md create mode 100644 CHANGELOG.d/0000-async-ratings.md create mode 100644 avito/accounts/async_domain.py create mode 100644 avito/ratings/async_domain.py create mode 100644 tests/domains/accounts/test_accounts_async.py create mode 100644 tests/domains/ratings/test_ratings_async.py diff --git a/CHANGELOG.d/0000-async-accounts.md b/CHANGELOG.d/0000-async-accounts.md new file mode 100644 index 0000000..cfb0fb9 --- /dev/null +++ b/CHANGELOG.d/0000-async-accounts.md @@ -0,0 +1,2 @@ +### Added +- Async-поддержка домена accounts: AsyncAccount, AsyncAccountHierarchy (#0000) diff --git a/CHANGELOG.d/0000-async-ratings.md b/CHANGELOG.d/0000-async-ratings.md new file mode 100644 index 0000000..c85c46c --- /dev/null +++ b/CHANGELOG.d/0000-async-ratings.md @@ -0,0 +1,2 @@ +### Added +- Async-поддержка домена ratings: AsyncRatingProfile, AsyncReview, AsyncReviewAnswer (#0000) diff --git a/avito/accounts/__init__.py b/avito/accounts/__init__.py index d4fd45f..2ff9418 100644 --- a/avito/accounts/__init__.py +++ b/avito/accounts/__init__.py @@ -1,5 +1,6 @@ """Пакет accounts.""" +from avito.accounts.async_domain import AsyncAccount, AsyncAccountHierarchy from avito.accounts.domain import Account, AccountHierarchy from avito.accounts.models import ( AccountActionResult, @@ -26,6 +27,8 @@ "AccountHierarchyRole", "AccountProfile", "AhUserStatus", + "AsyncAccount", + "AsyncAccountHierarchy", "CompanyPhone", "CompanyPhonesResult", "Employee", diff --git a/avito/accounts/async_domain.py b/avito/accounts/async_domain.py new file mode 100644 index 0000000..3bd82bc --- /dev/null +++ b/avito/accounts/async_domain.py @@ -0,0 +1,397 @@ +"""Async-доменные объекты пакета accounts.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime + +from avito.accounts.models import ( + AccountActionResult, + AccountBalance, + AccountProfile, + AhUserStatus, + CompanyPhonesResult, + EmployeeItem, + EmployeeItemLinkRequest, + EmployeeItemsRequest, + EmployeesResult, + OperationRecord, + OperationsHistoryRequest, +) +from avito.accounts.operations import ( + GET_AH_USER_STATUS, + GET_BALANCE, + GET_OPERATIONS_HISTORY, + GET_SELF, + LINK_ITEMS, + LIST_COMPANY_PHONES, + LIST_EMPLOYEES, + LIST_ITEMS_BY_EMPLOYEE, +) +from avito.core import ( + ApiTimeouts, + AsyncPaginatedList, + AsyncPaginator, + JsonPage, + RetryOverride, + ValidationError, +) +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation + + +def _serialize_datetime(value: datetime) -> str: + return value.isoformat() + + +@dataclass(slots=True, frozen=True) +class AsyncAccount(AsyncDomainObject): + """Async-доменный объект операций аккаунта.""" + + __swagger_domain__ = "accounts" + __sdk_factory__ = "account" + __sdk_factory_args__ = {"user_id": "path.user_id"} + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/core/v1/accounts/self", + spec="Информацияопользователе.json", + operation_id="getUserInfoSelf", + variant="async", + ) + async def get_self( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AccountProfile: + """Получает профиль авторизованного пользователя асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AccountProfile` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_SELF, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/core/v1/accounts/{user_id}/balance", + spec="Информацияопользователе.json", + operation_id="getUserBalance", + variant="async", + ) + async def get_balance( + self, + *, + user_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AccountBalance: + """Получает баланс пользователя по явно заданному или настроенному `user_id` асинхронно. + + Аргументы: + user_id: идентификатор пользователя; если не передан, используется `user_id` фабрики, `AVITO_USER_ID` или `get_self()`. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AccountBalance` с реальным, бонусным и суммарным балансом. + + Поведение: + `user_id` является keyword-only, чтобы вызов явно показывал источник аккаунта. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_user_id = await self._resolve_account_user_id(user_id) + return await self._execute( + GET_BALANCE, + path_params={"user_id": resolved_user_id}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/core/v1/accounts/operations_history", + spec="Информацияопользователе.json", + operation_id="postOperationsHistory", + method_args={"date_from": "body.dateTimeFrom", "date_to": "body.dateTimeTo"}, + variant="async", + ) + async def get_operations_history( + self, + *, + date_from: datetime, + date_to: datetime, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AsyncPaginatedList[OperationRecord]: + """Возвращает историю операций аккаунта за выбранный период асинхронно. + + Аргументы: + date_from: задает начальную дату периода. + date_to: задает конечную дату периода. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + Ленивый `AsyncPaginatedList[OperationRecord]`; первая страница загружается при создании, следующие страницы - при async-итерации. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + async def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[OperationRecord]: + result = await self._execute( + GET_OPERATIONS_HISTORY, + request=OperationsHistoryRequest( + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), + ), + timeout=timeout, + retry=retry, + ) + return JsonPage( + items=result.operations, + total=result.total, + ) + + return AsyncPaginator(fetch_page).as_list(first_page=await fetch_page(1, None)) + + async def _resolve_account_user_id(self, user_id: int | None) -> int: + if user_id is not None or self.user_id is not None: + return await self._resolve_user_id(user_id or self.user_id) + profile = await self.get_self() + if profile.user_id is None: + raise ValidationError( + "Для операции требуется `user_id`: передайте его в фабрику клиента, " + "в метод операции или задайте `AVITO_USER_ID`." + ) + return profile.user_id + + +@dataclass(slots=True, frozen=True) +class AsyncAccountHierarchy(AsyncDomainObject): + """Async-доменный объект иерархии аккаунтов.""" + + __swagger_domain__ = "accounts" + __sdk_factory__ = "account_hierarchy" + __sdk_factory_args__ = {"user_id": "path.user_id"} + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/checkAhUserV1", + spec="ИерархияАккаунтов.json", + operation_id="checkAhUserV1", + variant="async", + ) + async def get_status( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AhUserStatus: + """Получает статус пользователя в ИА асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AhUserStatus` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_AH_USER_STATUS, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/getEmployeesV1", + spec="ИерархияАккаунтов.json", + operation_id="getEmployeesV1", + variant="async", + ) + async def list_employees( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> EmployeesResult: + """Возвращает сотрудников компании в иерархии аккаунта асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `EmployeesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(LIST_EMPLOYEES, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/listCompanyPhonesV1", + spec="ИерархияАккаунтов.json", + operation_id="listCompanyPhonesV1", + variant="async", + ) + async def list_company_phones( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> CompanyPhonesResult: + """Возвращает телефоны компании из иерархии аккаунта асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CompanyPhonesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(LIST_COMPANY_PHONES, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/linkItemsV1", + spec="ИерархияАккаунтов.json", + operation_id="linkItemsV1", + method_args={"employee_id": "body.employee_id", "item_ids": "body.item_ids"}, + variant="async", + ) + async def link_items( + self, + *, + employee_id: int, + item_ids: Sequence[int], + source_employee_id: int | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AccountActionResult: + """Прикрепляет объявления к сотруднику асинхронно. + + Аргументы: + employee_id: идентификатор сотрудника, к которому прикрепляются объявления. + item_ids: список идентификаторов объявлений. + source_employee_id: идентификатор сотрудника-источника, если объявления переносятся между сотрудниками. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AccountActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LINK_ITEMS, + request=EmployeeItemLinkRequest( + employee_id=employee_id, + item_ids=list(item_ids), + source_employee_id=source_employee_id, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/listItemsByEmployeeIdV1", + spec="ИерархияАккаунтов.json", + operation_id="listItemsByEmployeeIdV1", + method_args={ + "employee_id": "body.employee_id", + "category_id": "body.category_id", + }, + variant="async", + ) + async def list_items_by_employee( + self, + *, + employee_id: int, + category_id: int, + last_item_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AsyncPaginatedList[EmployeeItem]: + """Возвращает объявления, закрепленные за сотрудником компании, асинхронно. + + Аргументы: + employee_id: идентифицирует сотрудника аккаунта. + category_id: ограничивает объявления категорией из справочника Авито. + last_item_id: задает курсор для продолжения выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + Ленивый `AsyncPaginatedList[EmployeeItem]`; первая страница загружается при создании, следующие страницы - при async-итерации. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + async def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[EmployeeItem]: + current_page = page or 1 + result = await self._execute( + LIST_ITEMS_BY_EMPLOYEE, + request=EmployeeItemsRequest( + employee_id=employee_id, + category_id=category_id, + last_item_id=last_item_id, + ), + timeout=timeout, + retry=retry, + ) + return JsonPage( + items=result.items, + total=result.total, + page=current_page, + per_page=len(result.items), + ) + + return AsyncPaginator(fetch_page).as_list(first_page=await fetch_page(1, None)) + + +__all__ = ("AsyncAccount", "AsyncAccountHierarchy") diff --git a/avito/async_client.py b/avito/async_client.py index 7cc8991..f2c8c02 100644 --- a/avito/async_client.py +++ b/avito/async_client.py @@ -6,6 +6,7 @@ import httpx +from avito.accounts import AsyncAccount, AsyncAccountHierarchy from avito.auth.async_provider import AsyncAuthProvider from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient from avito.auth.settings import AuthSettings @@ -13,6 +14,7 @@ from avito.core.async_transport import AsyncTransport from avito.core.exceptions import ClientClosedError from avito.core.types import TransportDebugInfo +from avito.ratings import AsyncRatingProfile, AsyncReview, AsyncReviewAnswer from avito.tariffs import AsyncTariff @@ -112,11 +114,36 @@ def debug_info(self) -> TransportDebugInfo: return self._require_transport().debug_info() + def account(self, user_id: int | str | None = None) -> AsyncAccount: + """Создает async-доменный объект аккаунта.""" + + return AsyncAccount(self._require_transport(), user_id=user_id) + + def account_hierarchy(self, user_id: int | str | None = None) -> AsyncAccountHierarchy: + """Создает async-доменный объект иерархии аккаунта.""" + + return AsyncAccountHierarchy(self._require_transport(), user_id=user_id) + def tariff(self, tariff_id: int | str | None = None) -> AsyncTariff: """Создает async-доменный объект тарифа.""" return AsyncTariff(self._require_transport(), tariff_id=tariff_id) + def review(self) -> AsyncReview: + """Создает async-доменный объект отзыва.""" + + return AsyncReview(self._require_transport()) + + def review_answer(self, answer_id: int | str | None = None) -> AsyncReviewAnswer: + """Создает async-доменный объект ответа на отзыв.""" + + return AsyncReviewAnswer(self._require_transport(), answer_id=answer_id) + + def rating_profile(self) -> AsyncRatingProfile: + """Создает async-доменный объект рейтингового профиля.""" + + return AsyncRatingProfile(self._require_transport()) + async def aclose(self) -> None: """Закрывает transport и auth-provider; повторный вызов безопасен.""" diff --git a/avito/ratings/__init__.py b/avito/ratings/__init__.py index 9bf5554..27c9045 100644 --- a/avito/ratings/__init__.py +++ b/avito/ratings/__init__.py @@ -1,5 +1,6 @@ """Пакет ratings.""" +from avito.ratings.async_domain import AsyncRatingProfile, AsyncReview, AsyncReviewAnswer from avito.ratings.domain import RatingProfile, Review, ReviewAnswer from avito.ratings.models import ( RatingProfileInfo, @@ -11,6 +12,9 @@ ) __all__ = ( + "AsyncRatingProfile", + "AsyncReview", + "AsyncReviewAnswer", "RatingProfile", "RatingProfileInfo", "Review", diff --git a/avito/ratings/async_domain.py b/avito/ratings/async_domain.py new file mode 100644 index 0000000..c7b930b --- /dev/null +++ b/avito/ratings/async_domain.py @@ -0,0 +1,225 @@ +"""Async-доменные объекты пакета ratings.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.ratings.models import ( + CreateReviewAnswerRequest, + RatingProfileInfo, + ReviewAnswerInfo, + ReviewsQuery, + ReviewsResult, +) +from avito.ratings.operations import ( + CREATE_REVIEW_ANSWER, + DELETE_REVIEW_ANSWER, + GET_RATINGS_INFO, + LIST_REVIEWS, +) + + +@dataclass(slots=True, frozen=True) +class AsyncReview(AsyncDomainObject): + """Async-доменный объект отзывов.""" + + __swagger_domain__ = "ratings" + __sdk_factory__ = "review" + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/ratings/v1/reviews", + spec="Рейтингииотзывы.json", + operation_id="getReviewsV1", + variant="async", + ) + async def list( + self, + *, + offset: int | None = None, + page: int | None = None, + limit: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ReviewsResult: + """Возвращает список отзывов асинхронно. + + Аргументы: + offset: задает смещение первой записи в выборке. + page: задает номер страницы для постраничной выборки. + limit: ограничивает размер возвращаемой выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ReviewsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_query = ReviewsQuery( + offset=offset if offset is not None else 0, + page=page if page is not None else 1, + limit=limit if limit is not None else 50, + ) + return await self._execute( + LIST_REVIEWS, + query=resolved_query, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncReviewAnswer(AsyncDomainObject): + """Async-доменный объект ответов на отзывы.""" + + __swagger_domain__ = "ratings" + __sdk_factory__ = "review_answer" + __sdk_factory_args__ = {"answer_id": "path.answer_id"} + + answer_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/ratings/v1/answers", + spec="Рейтингииотзывы.json", + operation_id="createReviewAnswerV1", + method_args={"review_id": "body.review_id", "text": "body.message"}, + variant="async", + ) + async def create( + self, + *, + review_id: int, + text: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ReviewAnswerInfo: + """Создает ответ на отзыв асинхронно. + + Аргументы: + review_id: идентифицирует отзыв. + text: передает текст ответа или сообщения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ReviewAnswerInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_REVIEW_ANSWER, + request=CreateReviewAnswerRequest(review_id=review_id, text=text), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "DELETE", + "/ratings/v1/answers/{answer_id}", + spec="Рейтингииотзывы.json", + operation_id="removeReviewAnswerV1", + variant="async", + ) + async def delete( + self, + *, + answer_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ReviewAnswerInfo: + """Удаляет ответ на отзыв асинхронно. + + Аргументы: + answer_id: идентифицирует ответ на отзыв. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ReviewAnswerInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELETE_REVIEW_ANSWER, + path_params={"answer_id": answer_id or self._require_answer_id()}, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_answer_id(self) -> str: + if self.answer_id is None: + raise ValidationError("Для операции требуется `answer_id`.") + return str(self.answer_id) + + +@dataclass(slots=True, frozen=True) +class AsyncRatingProfile(AsyncDomainObject): + """Async-доменный объект рейтингового профиля.""" + + __swagger_domain__ = "ratings" + __sdk_factory__ = "rating_profile" + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/ratings/v1/info", + spec="Рейтингииотзывы.json", + operation_id="getRatingsInfoV1", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> RatingProfileInfo: + """Возвращает рейтинг профиля асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RatingProfileInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_RATINGS_INFO, timeout=timeout, retry=retry) + + +__all__ = ("AsyncRatingProfile", "AsyncReview", "AsyncReviewAnswer") diff --git a/tests/contracts/test_async_swagger_contracts.py b/tests/contracts/test_async_swagger_contracts.py index 38fb5bc..bdef154 100644 --- a/tests/contracts/test_async_swagger_contracts.py +++ b/tests/contracts/test_async_swagger_contracts.py @@ -4,12 +4,18 @@ from avito.core.swagger_registry import load_swagger_registry -def test_async_swagger_bindings_are_discoverable_for_auth() -> None: +def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: discovery = discover_swagger_bindings(registry=load_swagger_registry()) async_bindings = [binding for binding in discovery.bindings if binding.variant == "async"] assert {binding.class_name for binding in async_bindings} == { + "AsyncAccount", + "AsyncAccountHierarchy", "AsyncAlternateTokenClient", + "AsyncRatingProfile", + "AsyncReview", + "AsyncReviewAnswer", "AsyncTariff", "AsyncTokenClient", } + assert len(async_bindings) == 17 diff --git a/tests/domains/accounts/test_accounts_async.py b/tests/domains/accounts/test_accounts_async.py new file mode 100644 index 0000000..52c6155 --- /dev/null +++ b/tests/domains/accounts/test_accounts_async.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from datetime import datetime + +import httpx +import pytest + +from avito.accounts import AsyncAccount, AsyncAccountHierarchy +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core import AsyncPaginatedList +from avito.core.exceptions import AuthenticationError, RateLimitError, TransportError +from avito.core.retries import RetryPolicy +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +def _profile_payload() -> dict[str, object]: + return {"id": 7, "name": "Иван", "email": "user@example.com", "phone": "+7999"} + + +def _balance_payload() -> dict[str, object]: + return {"user_id": 7, "balance": {"real": 150.5, "bonus": 20.0, "currency": "RUB"}} + + +def _operations_payload() -> dict[str, object]: + return { + "total": 1, + "operations": [ + { + "id": "op-1", + "created_at": "2025-01-02T12:00:00Z", + "amount": 120.0, + "type": "payment", + "status": "done", + } + ], + } + + +@pytest.mark.asyncio +async def test_async_account_domain_maps_profile_balance_and_operations() -> None: + def operations_history(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "dateTimeFrom": "2025-01-01T00:00:00+00:00", + "dateTimeTo": "2025-01-31T00:00:00+00:00", + } + return httpx.Response(200, json=_operations_payload()) + + fake = ( + AsyncFakeTransport() + .add_json("GET", "/core/v1/accounts/self", _profile_payload()) + .add_json("GET", "/core/v1/accounts/7/balance/", _balance_payload()) + .add("POST", "/core/v1/accounts/operations_history/", operations_history) + ) + transport = fake.build() + account = AsyncAccount(transport, user_id=7) + + profile = await account.get_self() + balance = await account.get_balance() + history = await account.get_operations_history( + date_from=datetime.fromisoformat("2025-01-01T00:00:00+00:00"), + date_to=datetime.fromisoformat("2025-01-31T00:00:00+00:00"), + ) + + assert profile.user_id == 7 + assert balance.total == 170.5 + assert isinstance(history, AsyncPaginatedList) + assert history.loaded_count == 1 + materialized = await history.materialize() + assert len(materialized) == 1 + assert materialized[0].operation_type == "payment" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_account_balance_resolves_user_id_from_self_when_not_configured() -> None: + fake = ( + AsyncFakeTransport() + .add_json("GET", "/core/v1/accounts/self", {"id": 7}) + .add_json("GET", "/core/v1/accounts/7/balance/", {"user_id": 7, "balance": {"real": 150.0}}) + ) + transport = fake.build() + account = AsyncAccount(transport) + + balance = await account.get_balance() + + assert balance.user_id == 7 + assert [request.path for request in fake.requests] == [ + "/core/v1/accounts/self", + "/core/v1/accounts/7/balance/", + ] + await transport.aclose() + + +def test_async_account_balance_requires_keyword_user_id() -> None: + account = AsyncAccount(AsyncFakeTransport().build()) + + try: + account.get_balance(7) # type: ignore[misc] + except TypeError as error: + assert "positional" in str(error) + else: # pragma: no cover + raise AssertionError("get_balance accepted positional user_id") + + +@pytest.mark.asyncio +async def test_async_account_hierarchy_domain_maps_employees_phones_and_items() -> None: + def link_items(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"employeeId": 10, "itemIds": [1, 2]} + assert request.headers["idempotency-key"] == "link-1" + return httpx.Response(200, json={"success": True, "message": "linked"}) + + def list_items(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"employeeId": 10, "categoryId": 24} + return httpx.Response( + 200, + json={ + "items": [{"item_id": 1, "title": "Объявление", "status": "active", "price": 99}], + "total": 1, + }, + ) + + fake = ( + AsyncFakeTransport() + .add_json("GET", "/checkAhUserV1", {"user_id": 7, "is_active": True, "role": "manager"}) + .add_json( + "GET", + "/getEmployeesV1", + {"employees": [{"employee_id": 10, "user_id": 7, "name": "Пётр"}], "total": 1}, + ) + .add_json( + "GET", + "/listCompanyPhonesV1", + {"phones": [{"id": 1, "phone": "+7000", "comment": "Основной"}]}, + ) + .add("POST", "/linkItemsV1", link_items) + .add("POST", "/listItemsByEmployeeIdV1", list_items) + ) + transport = fake.build() + hierarchy = AsyncAccountHierarchy(transport, user_id=7) + + status = await hierarchy.get_status() + employees = await hierarchy.list_employees() + phones = await hierarchy.list_company_phones() + linked = await hierarchy.link_items(employee_id=10, item_ids=[1, 2], idempotency_key="link-1") + items = await hierarchy.list_items_by_employee(employee_id=10, category_id=24) + + assert status.is_active is True + assert employees.items[0].employee_id == 10 + assert phones.items[0].phone == "+7000" + assert linked.success is True + assert isinstance(items, AsyncPaginatedList) + assert items.loaded_count == 1 + materialized = await items.materialize() + assert materialized[0].title == "Объявление" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_account_factories_return_async_domains() -> None: + fake = ( + AsyncFakeTransport() + .add_json("GET", "/core/v1/accounts/self", _profile_payload()) + .add_json("GET", "/checkAhUserV1", {"user_id": 7, "is_active": True, "role": "manager"}) + ) + client = fake.as_client() + + account = client.account(user_id=7) + hierarchy = client.account_hierarchy(user_id=7) + + assert isinstance(account, AsyncAccount) + assert isinstance(hierarchy, AsyncAccountHierarchy) + assert (await account.get_self()).user_id == 7 + assert (await hierarchy.get_status()).is_active is True + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_accounts_maps_401() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/core/v1/accounts/self", + {"error": "unauthorized"}, + status_code=401, + ) + transport = fake.build() + + with pytest.raises(AuthenticationError): + await AsyncAccount(transport).get_self() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_accounts_maps_429() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/core/v1/accounts/self", + {"error": "rate limit"}, + status_code=429, + ) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=1)) + + with pytest.raises(RateLimitError): + await AsyncAccount(transport).get_self() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_accounts_maps_transport_error() -> None: + def raise_network_error(request: object) -> httpx.Response: + raise httpx.NetworkError("connection failed") + + fake = AsyncFakeTransport().add("GET", "/core/v1/accounts/self", raise_network_error) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=1)) + + with pytest.raises(TransportError): + await AsyncAccount(transport).get_self() + + await transport.aclose() + + +def test_async_client_account_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.account() + with pytest.raises(RuntimeError): + client.account_hierarchy() diff --git a/tests/domains/ratings/test_ratings_async.py b/tests/domains/ratings/test_ratings_async.py new file mode 100644 index 0000000..41ffdc8 --- /dev/null +++ b/tests/domains/ratings/test_ratings_async.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core.exceptions import ( + AuthenticationError, + RateLimitError, + TransportError, + ValidationError, +) +from avito.core.retries import RetryPolicy +from avito.ratings import AsyncRatingProfile, AsyncReview, AsyncReviewAnswer +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +def _reviews_payload() -> dict[str, object]: + return { + "total": 25, + "reviews": [ + { + "id": 123, + "score": 5, + "stage": "done", + "text": "Все отлично", + "createdAt": 1713427200, + "canAnswer": True, + "usedInScore": True, + } + ], + } + + +def _rating_payload() -> dict[str, object]: + return { + "isEnabled": True, + "rating": {"score": 4.7, "reviewsCount": 25, "reviewsWithScoreCount": 20}, + } + + +@pytest.mark.asyncio +async def test_async_ratings_flows() -> None: + def create_answer(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"reviewId": 123, "message": "Спасибо за отзыв"} + return httpx.Response(200, json={"id": 456, "createdAt": 1713427200}) + + fake = ( + AsyncFakeTransport() + .add("POST", "/ratings/v1/answers", create_answer) + .add_json("DELETE", "/ratings/v1/answers/456", {"success": True}) + .add_json("GET", "/ratings/v1/info", _rating_payload()) + .add_json("GET", "/ratings/v1/reviews", _reviews_payload()) + ) + transport = fake.build() + + answer = AsyncReviewAnswer(transport, answer_id="456") + profile = AsyncRatingProfile(transport) + review = AsyncReview(transport) + + assert (await answer.create(review_id=123, text="Спасибо за отзыв")).answer_id == "456" + assert (await answer.delete()).success is True + assert (await profile.get()).score == 4.7 + assert (await review.list(page=2)).items[0].text == "Все отлично" + assert fake.last(method="GET", path="/ratings/v1/reviews").params["page"] == "2" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_review_list_uses_working_default_page() -> None: + fake = AsyncFakeTransport().add_json("GET", "/ratings/v1/reviews", {"reviews": []}) + transport = fake.build() + + assert (await AsyncReview(transport).list()).items == [] + request = fake.last(method="GET", path="/ratings/v1/reviews") + assert request.params["page"] == "1" + assert request.params["limit"] == "50" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_ratings_factories_return_async_domains() -> None: + fake = ( + AsyncFakeTransport() + .add_json("GET", "/ratings/v1/reviews", _reviews_payload()) + .add_json("POST", "/ratings/v1/answers", {"id": 456, "createdAt": 1713427200}) + .add_json("GET", "/ratings/v1/info", _rating_payload()) + ) + client = fake.as_client() + + review = client.review() + answer = client.review_answer() + profile = client.rating_profile() + + assert isinstance(review, AsyncReview) + assert isinstance(answer, AsyncReviewAnswer) + assert isinstance(profile, AsyncRatingProfile) + assert (await review.list()).total == 25 + assert (await answer.create(review_id=123, text="Спасибо")).answer_id == "456" + assert (await profile.get()).reviews_count == 25 + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_review_answer_delete_requires_answer_id() -> None: + transport = AsyncFakeTransport().build() + + with pytest.raises(ValidationError): + await AsyncReviewAnswer(transport).delete() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ratings_maps_401() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/ratings/v1/info", + {"error": "unauthorized"}, + status_code=401, + ) + transport = fake.build() + + with pytest.raises(AuthenticationError): + await AsyncRatingProfile(transport).get() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ratings_maps_429() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/ratings/v1/info", + {"error": "rate limit"}, + status_code=429, + ) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=1)) + + with pytest.raises(RateLimitError): + await AsyncRatingProfile(transport).get() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ratings_maps_transport_error() -> None: + def raise_network_error(request: object) -> httpx.Response: + raise httpx.NetworkError("connection failed") + + fake = AsyncFakeTransport().add("GET", "/ratings/v1/info", raise_network_error) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=1)) + + with pytest.raises(TransportError): + await AsyncRatingProfile(transport).get() + + await transport.aclose() + + +def test_async_client_ratings_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.review() + with pytest.raises(RuntimeError): + client.review_answer() + with pytest.raises(RuntimeError): + client.rating_profile() From 797b9926340e2b2e93c31e8eccb97f46cbaa0eb2 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 20:35:31 +0300 Subject: [PATCH 16/26] M5-6 --- CHANGELOG.d/0000-async-cpa.md | 2 + CHANGELOG.d/0000-async-realty.md | 2 + avito/async_client.py | 82 ++ avito/cpa/__init__.py | 12 + avito/cpa/async_domain.py | 792 ++++++++++++++++++ avito/realty/__init__.py | 10 + avito/realty/async_domain.py | 460 ++++++++++ .../contracts/test_async_swagger_contracts.py | 11 +- tests/domains/cpa/test_cpa_async.py | 404 +++++++++ tests/domains/realty/test_realty_async.py | 202 +++++ 10 files changed, 1976 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.d/0000-async-cpa.md create mode 100644 CHANGELOG.d/0000-async-realty.md create mode 100644 avito/cpa/async_domain.py create mode 100644 avito/realty/async_domain.py create mode 100644 tests/domains/cpa/test_cpa_async.py create mode 100644 tests/domains/realty/test_realty_async.py diff --git a/CHANGELOG.d/0000-async-cpa.md b/CHANGELOG.d/0000-async-cpa.md new file mode 100644 index 0000000..6d2624f --- /dev/null +++ b/CHANGELOG.d/0000-async-cpa.md @@ -0,0 +1,2 @@ +### Added +- Async-поддержка домена cpa: AsyncCallTrackingCall, AsyncCpaArchive, AsyncCpaCall, AsyncCpaChat, AsyncCpaLead (#0000) diff --git a/CHANGELOG.d/0000-async-realty.md b/CHANGELOG.d/0000-async-realty.md new file mode 100644 index 0000000..c9a500b --- /dev/null +++ b/CHANGELOG.d/0000-async-realty.md @@ -0,0 +1,2 @@ +### Added +- Async-поддержка домена realty: AsyncRealtyAnalyticsReport, AsyncRealtyBooking, AsyncRealtyListing, AsyncRealtyPricing (#0000) diff --git a/avito/async_client.py b/avito/async_client.py index f2c8c02..52e8945 100644 --- a/avito/async_client.py +++ b/avito/async_client.py @@ -14,7 +14,20 @@ from avito.core.async_transport import AsyncTransport from avito.core.exceptions import ClientClosedError from avito.core.types import TransportDebugInfo +from avito.cpa import ( + AsyncCallTrackingCall, + AsyncCpaArchive, + AsyncCpaCall, + AsyncCpaChat, + AsyncCpaLead, +) from avito.ratings import AsyncRatingProfile, AsyncReview, AsyncReviewAnswer +from avito.realty import ( + AsyncRealtyAnalyticsReport, + AsyncRealtyBooking, + AsyncRealtyListing, + AsyncRealtyPricing, +) from avito.tariffs import AsyncTariff @@ -124,6 +137,31 @@ def account_hierarchy(self, user_id: int | str | None = None) -> AsyncAccountHie return AsyncAccountHierarchy(self._require_transport(), user_id=user_id) + def cpa_lead(self) -> AsyncCpaLead: + """Создает async-доменный объект CPA-лида.""" + + return AsyncCpaLead(self._require_transport()) + + def cpa_chat(self, chat_id: int | str | None = None) -> AsyncCpaChat: + """Создает async-доменный объект CPA-чата.""" + + return AsyncCpaChat(self._require_transport(), action_id=chat_id) + + def cpa_call(self) -> AsyncCpaCall: + """Создает async-доменный объект CPA-звонка.""" + + return AsyncCpaCall(self._require_transport()) + + def cpa_archive(self, call_id: int | str | None = None) -> AsyncCpaArchive: + """Создает async-доменный объект архивных операций CPA.""" + + return AsyncCpaArchive(self._require_transport(), call_id=call_id) + + def call_tracking_call(self, call_id: int | str | None = None) -> AsyncCallTrackingCall: + """Создает async-доменный объект CallTracking.""" + + return AsyncCallTrackingCall(self._require_transport(), call_id=call_id) + def tariff(self, tariff_id: int | str | None = None) -> AsyncTariff: """Создает async-доменный объект тарифа.""" @@ -144,6 +182,50 @@ def rating_profile(self) -> AsyncRatingProfile: return AsyncRatingProfile(self._require_transport()) + def realty_listing( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> AsyncRealtyListing: + """Создает async-доменный объект объявления недвижимости.""" + + return AsyncRealtyListing(self._require_transport(), item_id=item_id, user_id=user_id) + + def realty_booking( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> AsyncRealtyBooking: + """Создает async-доменный объект бронирования недвижимости.""" + + return AsyncRealtyBooking(self._require_transport(), item_id=item_id, user_id=user_id) + + def realty_pricing( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> AsyncRealtyPricing: + """Создает async-доменный объект цен недвижимости.""" + + return AsyncRealtyPricing(self._require_transport(), item_id=item_id, user_id=user_id) + + def realty_analytics_report( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> AsyncRealtyAnalyticsReport: + """Создает async-доменный объект аналитического отчета недвижимости.""" + + return AsyncRealtyAnalyticsReport( + self._require_transport(), + item_id=item_id, + user_id=user_id, + ) + async def aclose(self) -> None: """Закрывает transport и auth-provider; повторный вызов безопасен.""" diff --git a/avito/cpa/__init__.py b/avito/cpa/__init__.py index e517d26..798c70c 100644 --- a/avito/cpa/__init__.py +++ b/avito/cpa/__init__.py @@ -1,5 +1,12 @@ """Пакет cpa.""" +from avito.cpa.async_domain import ( + AsyncCallTrackingCall, + AsyncCpaArchive, + AsyncCpaCall, + AsyncCpaChat, + AsyncCpaLead, +) from avito.cpa.domain import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.cpa.models import ( CallTrackingCallInfo, @@ -28,6 +35,11 @@ ) __all__ = ( + "AsyncCallTrackingCall", + "AsyncCpaArchive", + "AsyncCpaCall", + "AsyncCpaChat", + "AsyncCpaLead", "CallTrackingCall", "CallTrackingCallInfo", "CallTrackingCallResponse", diff --git a/avito/cpa/async_domain.py b/avito/cpa/async_domain.py new file mode 100644 index 0000000..1b115e4 --- /dev/null +++ b/avito/cpa/async_domain.py @@ -0,0 +1,792 @@ +"""Async-доменные объекты пакета cpa.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.deprecation import deprecated_method, warn_deprecated_once +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import DateInput, serialize_iso_datetime +from avito.cpa.models import ( + CallTrackingCallResponse, + CallTrackingCallsRequest, + CallTrackingCallsResult, + CallTrackingGetCallByIdRequest, + CallTrackingRecord, + CpaActionResult, + CpaAudioRecord, + CpaBalanceInfo, + CpaBalanceInfoRequest, + CpaCallByIdRequest, + CpaCallComplaintRequest, + CpaCallInfo, + CpaCallsByTimeRequest, + CpaCallsResult, + CpaChatInfo, + CpaChatsByTimeRequest, + CpaChatsResult, + CpaLeadComplaintRequest, + CpaPhonesFromChatsRequest, + CpaPhonesResult, +) +from avito.cpa.operations import ( + CPA_HEADERS, + CREATE_CPA_CALL_COMPLAINT, + CREATE_CPA_LEAD_COMPLAINT, + GET_CALLTRACKING_CALL_BY_ID, + GET_CALLTRACKING_CALLS, + GET_CALLTRACKING_RECORD, + GET_CPA_ARCHIVE_BALANCE, + GET_CPA_ARCHIVE_CALL_BY_ID, + GET_CPA_ARCHIVE_RECORD, + GET_CPA_BALANCE, + GET_CPA_CHAT_BY_ACTION_ID, + GET_CPA_PHONES_INFO, + LIST_CPA_CALLS, + LIST_CPA_CHATS, + LIST_CPA_CHATS_CLASSIC, +) + + +@dataclass(slots=True, frozen=True) +class AsyncCpaLead(AsyncDomainObject): + """Доменный объект CPA-лида и связанных lead-операций.""" + + __swagger_domain__ = "cpa" + __sdk_factory__ = "cpa_lead" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/cpa/v1/createComplaintByActionId", + spec="CPAАвито.json", + operation_id="createComplaintByActionId", + variant="async", + method_args={"action_id": "body.action_id", "reason": "body.message"}, + ) + async def create_complaint_by_action_id( + self, + *, + action_id: int, + reason: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaActionResult: + """Создает жалобу по идентификатору CPA-действия. + + Аргументы: + action_id: идентифицирует CPA-действие. + reason: передает причину жалобы или обращения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaActionResult` со статусом выполнения операции. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_CPA_LEAD_COMPLAINT, + request=CpaLeadComplaintRequest(action_id=action_id, reason=reason), + headers=CPA_HEADERS, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpa/v3/balanceInfo", + spec="CPAАвито.json", + operation_id="balanceInfoV3", + variant="async", + ) + async def get_balance_info( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> CpaBalanceInfo: + """Возвращает balance info для CPA-лидов. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaBalanceInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CPA_BALANCE, + request=CpaBalanceInfoRequest(), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncCpaChat(AsyncDomainObject): + """Доменный объект CPA-чата.""" + + __swagger_domain__ = "cpa" + __sdk_factory__ = "cpa_chat" + __sdk_factory_args__ = {"chat_id": "path.chat_id"} + + action_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/cpa/v1/chatByActionId/{actionId}", + spec="CPAАвито.json", + operation_id="chatByActionId", + variant="async", + ) + async def get( + self, + *, + action_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaChatInfo: + """Возвращает CPA-чатов. + + Аргументы: + action_id: идентифицирует CPA-действие. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaChatInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CPA_CHAT_BY_ACTION_ID, + path_params={"actionId": action_id or self._require_action_id()}, + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpa/v2/chatsByTime", + spec="CPAАвито.json", + operation_id="chatsByTime", + variant="async", + method_args={ + "created_at_from": "body.dateTimeFrom", + "limit": "body.limit", + "offset": "body.offset", + }, + ) + async def list( + self, + *, + created_at_from: DateInput, + limit: int, + offset: int, + version: int = 2, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaChatsResult: + """Возвращает список CPA-чатов. + + Аргументы: + created_at_from: задает нижнюю границу времени создания. + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + version: задает версию upstream-контракта, если операция ее поддерживает. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaChatsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + if version == 1: + return await self.list_classic( + created_at_from=created_at_from, + limit=limit, + offset=offset, + timeout=timeout, + retry=retry, + ) + return await self._execute( + LIST_CPA_CHATS, + request=CpaChatsByTimeRequest( + created_at_from=serialize_iso_datetime("created_at_from", created_at_from), + limit=limit, + offset=offset, + ), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpa/v1/chatsByTime", + spec="CPAАвито.json", + operation_id="chatsByTime", + variant="async", + method_args={ + "created_at_from": "body.dateTimeFrom", + "limit": "body.limit", + "offset": "body.offset", + }, + ) + async def list_classic( + self, + *, + created_at_from: DateInput, + limit: int, + offset: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaChatsResult: + """Выполняет legacy-операцию списка CPA-чатов v1 и возвращает типизированную SDK-модель. + + Аргументы: + created_at_from: фильтрует CPA-чаты по нижней границе даты создания. + limit: задает максимальное число записей в ответе. + offset: задает смещение выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaChatsResult` со списком CPA-чатов legacy API. + + Поведение: + Метод оставлен для явного покрытия отдельной Swagger operation. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + warn_deprecated_once( + symbol="AsyncCpaChat.list(version=1)", + replacement="await cpa_chat().list(version=2)", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + return await self._execute( + LIST_CPA_CHATS_CLASSIC, + request=CpaChatsByTimeRequest( + created_at_from=serialize_iso_datetime("created_at_from", created_at_from), + limit=limit, + offset=offset, + ), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpa/v1/phonesInfoFromChats", + spec="CPAАвито.json", + operation_id="phonesInfoFromChats", + variant="async", + method_args={ + "date_time_from": "body.dateTimeFrom", + "limit": "body.limit", + "offset": "body.offset", + }, + ) + async def get_phones_info_from_chats( + self, + *, + date_time_from: DateInput, + limit: int, + offset: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaPhonesResult: + """Возвращает phones info from chats для CPA-чатов. + + Аргументы: + date_time_from: задает нижнюю границу времени поиска. + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaPhonesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CPA_PHONES_INFO, + request=CpaPhonesFromChatsRequest( + date_time_from=serialize_iso_datetime("date_time_from", date_time_from), + limit=limit, + offset=offset, + ), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + def _require_action_id(self) -> str: + if self.action_id is None: + raise ValidationError("Для операции требуется `action_id`.") + return str(self.action_id) + + +@dataclass(slots=True, frozen=True) +class AsyncCpaCall(AsyncDomainObject): + """Доменный объект CPA-звонка.""" + + __swagger_domain__ = "cpa" + __sdk_factory__ = "cpa_call" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/cpa/v2/callsByTime", + spec="CPAАвито.json", + operation_id="getCallsByTimeV2", + variant="async", + method_args={ + "date_time_from": "body.dateTimeFrom", + "limit": "body.limit", + }, + ) + async def list( + self, + *, + date_time_from: DateInput, + limit: int, + offset: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaCallsResult: + """Возвращает список CPA-звонков. + + Аргументы: + date_time_from: задает начало временного интервала. + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaCallsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_CPA_CALLS, + request=CpaCallsByTimeRequest( + date_time_from=serialize_iso_datetime("date_time_from", date_time_from), + limit=limit, + offset=offset, + ), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpa/v1/createComplaint", + spec="CPAАвито.json", + operation_id="postCreateComplaint", + variant="async", + method_args={"call_id": "body.call_id", "reason": "body.message"}, + ) + async def create_complaint( + self, + *, + call_id: int, + reason: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaActionResult: + """Создает жалобу по CPA-звонку. + + Аргументы: + call_id: идентифицирует звонок. + reason: передает причину жалобы или обращения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaActionResult` со статусом выполнения операции. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_CPA_CALL_COMPLAINT, + request=CpaCallComplaintRequest(call_id=call_id, reason=reason), + headers=CPA_HEADERS, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncCpaArchive(AsyncDomainObject): + """Доменный объект архивных операций CPA.""" + + __swagger_domain__ = "cpa" + __sdk_factory__ = "cpa_archive" + __sdk_factory_args__ = {"call_id": "path.call_id"} + + call_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/cpa/v1/call/{call_id}", + spec="CPAАвито.json", + operation_id="getCall", + variant="async", + deprecated=True, + legacy=True, + ) + @deprecated_method( + symbol="AsyncCpaArchive.get_call", + replacement="await call_tracking_call().download", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def get_call( + self, + *, + call_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaAudioRecord: + """Получает архивную запись звонка. + + Deprecated: используйте `call_tracking_call().download`; удаление в версии 1.3.0. + + Аргументы: + call_id: идентифицирует архивную запись звонка. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaAudioRecord` с бинарной записью звонка. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return CpaAudioRecord( + await self._execute( + GET_CPA_ARCHIVE_RECORD, + path_params={"call_id": call_id or self._require_call_id()}, + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + ) + + @swagger_operation( + "POST", + "/cpa/v2/balanceInfo", + spec="CPAАвито.json", + operation_id="balanceInfoV2", + variant="async", + deprecated=True, + legacy=True, + ) + @deprecated_method( + symbol="AsyncCpaArchive.get_balance_info", + replacement="await cpa_lead().get_balance_info", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def get_balance_info( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> CpaBalanceInfo: + """Получает архивный баланс CPA. + + Deprecated: используйте `cpa_lead().get_balance_info`; удаление в версии 1.3.0. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaBalanceInfo` с архивной информацией о балансе CPA. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CPA_ARCHIVE_BALANCE, + request=CpaBalanceInfoRequest(), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpa/v2/callById", + spec="CPAАвито.json", + operation_id="getCallByIdV2", + variant="async", + method_args={"call_id": "body.call_id"}, + deprecated=True, + legacy=True, + ) + @deprecated_method( + symbol="AsyncCpaArchive.get_call_by_id", + replacement="await call_tracking_call().get", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def get_call_by_id( + self, + *, + call_id: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaCallInfo: + """Получает архивные данные звонка. + + Deprecated: используйте `call_tracking_call().get`; удаление в версии 1.3.0. + + Аргументы: + call_id: идентифицирует звонок. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaCallInfo` с архивными данными звонка. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CPA_ARCHIVE_CALL_BY_ID, + request=CpaCallByIdRequest(call_id=call_id), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + def _require_call_id(self) -> str: + if self.call_id is None: + raise ValidationError("Для операции требуется `call_id`.") + return str(self.call_id) + + +@dataclass(slots=True, frozen=True) +class AsyncCallTrackingCall(AsyncDomainObject): + """Доменный объект CallTracking.""" + + __swagger_domain__ = "cpa" + __sdk_factory__ = "call_tracking_call" + __sdk_factory_args__ = {"call_id": "path.call_id"} + + call_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/calltracking/v1/getCallById", + spec="CallTracking[КТ].json", + operation_id="get_call_by_id", + variant="async", + ) + async def get( + self, + *, + call_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CallTrackingCallResponse: + """Возвращает call tracking звонков. + + Аргументы: + call_id: идентифицирует звонок. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CallTrackingCallResponse` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_call_id = call_id or (int(self.call_id) if self.call_id is not None else None) + if resolved_call_id is None: + raise ValidationError("Для операции требуется `call_id`.") + return await self._execute( + GET_CALLTRACKING_CALL_BY_ID, + request=CallTrackingGetCallByIdRequest(call_id=resolved_call_id), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/calltracking/v1/getCalls", + spec="CallTracking[КТ].json", + operation_id="get_calls", + variant="async", + method_args={"date_time_from": "body.date_time_from", "date_time_to": "body.date_time_to"}, + ) + async def list( + self, + *, + date_time_from: DateInput, + date_time_to: DateInput, + limit: int | None = None, + offset: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CallTrackingCallsResult: + """Возвращает список call tracking звонков. + + Аргументы: + date_time_from: задает начало временного интервала. + date_time_to: задает конец временного интервала. + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CallTrackingCallsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CALLTRACKING_CALLS, + request=CallTrackingCallsRequest( + date_time_from=serialize_iso_datetime("date_time_from", date_time_from), + date_time_to=serialize_iso_datetime("date_time_to", date_time_to), + limit=limit, + offset=offset, + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/calltracking/v1/getRecordByCallId", + spec="CallTracking[КТ].json", + operation_id="get_record_by_call_id", + variant="async", + method_args={"call_id": "query.callId"}, + ) + async def download( + self, + *, + call_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CallTrackingRecord: + """Скачивает запись call tracking звонка. + + Аргументы: + call_id: идентифицирует звонок. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CallTrackingRecord` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return CallTrackingRecord( + await self._execute( + GET_CALLTRACKING_RECORD, + query={"callId": call_id or self._require_call_id()}, + timeout=timeout, + retry=retry, + ) + ) + + def _require_call_id(self) -> str: + if self.call_id is None: + raise ValidationError("Для операции требуется `call_id`.") + return str(self.call_id) + + +__all__ = ("AsyncCallTrackingCall", "AsyncCpaArchive", "AsyncCpaCall", "AsyncCpaChat", "AsyncCpaLead") diff --git a/avito/realty/__init__.py b/avito/realty/__init__.py index 36409da..dc64f23 100644 --- a/avito/realty/__init__.py +++ b/avito/realty/__init__.py @@ -1,5 +1,11 @@ """Пакет realty.""" +from avito.realty.async_domain import ( + AsyncRealtyAnalyticsReport, + AsyncRealtyBooking, + AsyncRealtyListing, + AsyncRealtyPricing, +) from avito.realty.domain import ( RealtyAnalyticsReport, RealtyBooking, @@ -25,6 +31,10 @@ ) __all__ = ( + "AsyncRealtyAnalyticsReport", + "AsyncRealtyBooking", + "AsyncRealtyListing", + "AsyncRealtyPricing", "RealtyActionResult", "RealtyAnalyticsInfo", "RealtyAnalyticsReport", diff --git a/avito/realty/async_domain.py b/avito/realty/async_domain.py new file mode 100644 index 0000000..bd33c4b --- /dev/null +++ b/avito/realty/async_domain.py @@ -0,0 +1,460 @@ +"""Async-доменные объекты пакета realty.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import DateInput, serialize_iso_date +from avito.realty.models import ( + RealtyActionResult, + RealtyAnalyticsInfo, + RealtyBaseParamsUpdateRequest, + RealtyBookingsQuery, + RealtyBookingsResult, + RealtyBookingsUpdateRequest, + RealtyInterval, + RealtyIntervalsRequest, + RealtyMarketPriceInfo, + RealtyPricePeriod, + RealtyPricesUpdateRequest, +) +from avito.realty.operations import ( + GET_INTERVALS, + GET_MARKET_PRICE_CORRESPONDENCE, + GET_REPORT_FOR_CLASSIFIED, + LIST_REALTY_BOOKINGS, + UPDATE_BASE_PARAMS, + UPDATE_BOOKINGS_INFO, + UPDATE_REALTY_PRICES, +) + + +@dataclass(slots=True, frozen=True) +class AsyncRealtyListing(AsyncDomainObject): + """Async-доменный объект объявления краткосрочной аренды.""" + + __swagger_domain__ = "realty" + __sdk_factory__ = "realty_listing" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/realty/v1/items/intervals", + spec="Краткосрочнаяаренда.json", + operation_id="putIntervals", + method_args={"intervals": "body.intervals", "item_id": "body.item_id"}, + variant="async", + ) + async def get_intervals( + self, + *, + intervals: list[RealtyInterval], + item_id: int | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyActionResult: + """Возвращает intervals для посутчной аренды асинхронно. + + Аргументы: + intervals: передает интервалы доступности объявления. + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyActionResult` со статусом выполнения операции. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_INTERVALS, + request=RealtyIntervalsRequest( + item_id=item_id or int(self._require_item_id()), + intervals=intervals, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/realty/v1/items/{item_id}/base", + spec="Краткосрочнаяаренда.json", + operation_id="postBaseParams", + method_args={"min_stay_days": "body.minimal_duration"}, + variant="async", + ) + async def update_base_params( + self, + *, + min_stay_days: int, + item_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyActionResult: + """Обновляет base params для посутчной аренды асинхронно. + + Аргументы: + min_stay_days: задает минимальное число дней проживания. + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyActionResult` со статусом выполнения операции. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_BASE_PARAMS, + path_params={"item_id": item_id or self._require_item_id()}, + request=RealtyBaseParamsUpdateRequest(min_stay_days=min_stay_days), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_item_id(self) -> str: + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return str(self.item_id) + + +@dataclass(slots=True, frozen=True) +class AsyncRealtyBooking(AsyncDomainObject): + """Async-доменный объект бронирований недвижимости.""" + + __swagger_domain__ = "realty" + __sdk_factory__ = "realty_booking" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/core/v1/accounts/{user_id}/items/{item_id}/bookings", + spec="Краткосрочнаяаренда.json", + operation_id="putBookingsInfo", + method_args={"blocked_dates": "body.bookings"}, + variant="async", + ) + async def update_bookings_info( + self, + *, + blocked_dates: list[DateInput], + user_id: int | str | None = None, + item_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyActionResult: + """Обновляет информацию о бронированиях недвижимости асинхронно. + + Аргументы: + blocked_dates: передает заблокированные даты бронирования. + user_id: идентифицирует пользователя или аккаунт Авито. + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyActionResult` со статусом выполнения операции. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_BOOKINGS_INFO, + path_params={ + "user_id": user_id or self._require_user_id(), + "item_id": item_id or self._require_item_id(), + }, + request=RealtyBookingsUpdateRequest( + blocked_dates=[ + serialize_iso_date(f"blocked_dates[{index}]", blocked_date) + for index, blocked_date in enumerate(blocked_dates) + ] + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/realty/v1/accounts/{user_id}/items/{item_id}/bookings", + spec="Краткосрочнаяаренда.json", + operation_id="getRealtyBookings", + method_args={"date_start": "query.date_start", "date_end": "query.date_end"}, + variant="async", + ) + async def list_realty_bookings( + self, + *, + date_start: DateInput, + date_end: DateInput, + with_unpaid: bool | None = None, + user_id: int | str | None = None, + item_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyBookingsResult: + """Возвращает список realty bookings для бронирований недвижимости асинхронно. + + Аргументы: + date_start: задает начальную дату периода бронирований. + date_end: задает конечную дату периода бронирований. + with_unpaid: включает неоплаченные бронирования в результат. + user_id: идентифицирует пользователя или аккаунт Авито. + item_id: идентифицирует объявление Авито. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyBookingsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_REALTY_BOOKINGS, + path_params={ + "user_id": user_id or self._require_user_id(), + "item_id": item_id or self._require_item_id(), + }, + query=RealtyBookingsQuery( + date_start=serialize_iso_date("date_start", date_start), + date_end=serialize_iso_date("date_end", date_end), + with_unpaid=with_unpaid, + ), + timeout=timeout, + retry=retry, + ) + + def _require_item_id(self) -> str: + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return str(self.item_id) + + def _require_user_id(self) -> str: + if self.user_id is None: + raise ValidationError("Для операции требуется `user_id`.") + return str(self.user_id) + + +@dataclass(slots=True, frozen=True) +class AsyncRealtyPricing(AsyncDomainObject): + """Async-доменный объект цен краткосрочной аренды.""" + + __swagger_domain__ = "realty" + __sdk_factory__ = "realty_pricing" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/realty/v1/accounts/{user_id}/items/{item_id}/prices", + spec="Краткосрочнаяаренда.json", + operation_id="postRealtyPrices", + method_args={"periods": "body.prices"}, + variant="async", + ) + async def update_realty_prices( + self, + *, + periods: list[RealtyPricePeriod], + user_id: int | str | None = None, + item_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyActionResult: + """Обновляет realty prices для цен недвижимости асинхронно. + + Аргументы: + periods: передает периоды цен. + user_id: идентифицирует пользователя или аккаунт Авито. + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyActionResult` со статусом выполнения операции. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_REALTY_PRICES, + path_params={ + "user_id": user_id or self._require_user_id(), + "item_id": item_id or self._require_item_id(), + }, + request=RealtyPricesUpdateRequest(periods=periods), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_item_id(self) -> str: + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return str(self.item_id) + + def _require_user_id(self) -> str: + if self.user_id is None: + raise ValidationError("Для операции требуется `user_id`.") + return str(self.user_id) + + +@dataclass(slots=True, frozen=True) +class AsyncRealtyAnalyticsReport(AsyncDomainObject): + """Async-доменный объект аналитики по недвижимости.""" + + __swagger_domain__ = "realty" + __sdk_factory__ = "realty_analytics_report" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/realty/v1/marketPriceCorrespondence/{itemId}/{price}", + spec="Аналитикапонедвижимости.json", + operation_id="market_price_correspondence_v1", + method_args={"price": "path.price"}, + variant="async", + ) + async def get_market_price_correspondence( + self, + *, + item_id: int | str | None = None, + price: int | str, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyMarketPriceInfo: + """Возвращает соответствие цены объявления рынку недвижимости асинхронно. + + Аргументы: + item_id: идентифицирует объявление Авито. + price: передает цену для аналитического расчета. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyMarketPriceInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_MARKET_PRICE_CORRESPONDENCE, + path_params={ + "itemId": item_id or self._require_item_id(), + "price": price, + }, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/realty/v1/report/create/{itemId}", + spec="Аналитикапонедвижимости.json", + operation_id="CreateReportForClassified", + variant="async", + ) + async def get_report_for_classified( + self, + *, + item_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyAnalyticsInfo: + """Возвращает аналитический отчет по объявлению недвижимости асинхронно. + + Аргументы: + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyAnalyticsInfo` с типизированными данными ответа API. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_REPORT_FOR_CLASSIFIED, + path_params={"itemId": item_id or self._require_item_id()}, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_item_id(self) -> str: + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return str(self.item_id) + + +__all__ = ( + "AsyncRealtyAnalyticsReport", + "AsyncRealtyBooking", + "AsyncRealtyListing", + "AsyncRealtyPricing", +) diff --git a/tests/contracts/test_async_swagger_contracts.py b/tests/contracts/test_async_swagger_contracts.py index bdef154..f89f11c 100644 --- a/tests/contracts/test_async_swagger_contracts.py +++ b/tests/contracts/test_async_swagger_contracts.py @@ -12,10 +12,19 @@ def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: "AsyncAccount", "AsyncAccountHierarchy", "AsyncAlternateTokenClient", + "AsyncCallTrackingCall", + "AsyncCpaArchive", + "AsyncCpaCall", + "AsyncCpaChat", + "AsyncCpaLead", "AsyncRatingProfile", + "AsyncRealtyAnalyticsReport", + "AsyncRealtyBooking", + "AsyncRealtyListing", + "AsyncRealtyPricing", "AsyncReview", "AsyncReviewAnswer", "AsyncTariff", "AsyncTokenClient", } - assert len(async_bindings) == 17 + assert len(async_bindings) == 38 diff --git a/tests/domains/cpa/test_cpa_async.py b/tests/domains/cpa/test_cpa_async.py new file mode 100644 index 0000000..4ec0fd6 --- /dev/null +++ b/tests/domains/cpa/test_cpa_async.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +import logging + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core import ValidationError +from avito.core.retries import RetryPolicy +from avito.cpa import ( + AsyncCallTrackingCall, + AsyncCpaArchive, + AsyncCpaCall, + AsyncCpaChat, + AsyncCpaLead, +) +from avito.cpa.models import CpaCallStatusId +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_cpa_chat_and_phone_flows() -> None: + def chats_v1(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "dateTimeFrom": "2026-04-18T00:00:00+03:00", + "limit": 10, + "offset": 0, + } + return httpx.Response( + 200, + json={ + "chats": [ + { + "chat": {"id": "chat-v1", "actionId": "legacy-1"}, + "buyer": {"userId": 502, "name": "Петр"}, + "item": {"id": 9002, "title": "Самокат"}, + "isArbitrageAvailable": False, + } + ] + }, + ) + + def chats_v2(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "dateTimeFrom": "2026-04-18T00:00:00+03:00", + "limit": 10, + "offset": 0, + } + return httpx.Response( + 200, + json={ + "chats": [ + { + "chat": {"id": "chat-v2", "actionId": "act-2"}, + "buyer": {"userId": 503, "name": "Мария"}, + "item": {"id": 9003, "title": "Ноутбук"}, + "isArbitrageAvailable": True, + } + ] + }, + ) + + def phones(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "dateTimeFrom": "2026-04-18T00:00:00+03:00", + "limit": 10, + "offset": 0, + } + return httpx.Response( + 200, + json={ + "total": 2, + "results": [ + { + "id": 101, + "date": "2026-04-18T12:00:00+03:00", + "phone_number": "+79990000001", + }, + { + "id": 102, + "date": "2026-04-18T12:05:00+03:00", + "phone_number": "+79990000002", + }, + ], + }, + ) + + fake = ( + AsyncFakeTransport() + .add_json( + "GET", + "/cpa/v1/chatByActionId/act-1", + { + "chat": { + "chat": {"id": "chat-1", "actionId": "act-1"}, + "buyer": {"userId": 501, "name": "Иван"}, + "item": {"id": 9001, "title": "Велосипед"}, + "isArbitrageAvailable": True, + } + }, + ) + .add("POST", "/cpa/v1/chatsByTime", chats_v1) + .add("POST", "/cpa/v2/chatsByTime", chats_v2) + .add("POST", "/cpa/v1/phonesInfoFromChats", phones) + ) + transport = fake.build() + chat = AsyncCpaChat(transport, action_id="act-1") + + assert (await chat.get()).item_title == "Велосипед" + with pytest.deprecated_call(match="cpa_chat\\(\\)\\.list\\(version=2\\)"): + classic_chats = await chat.list( + created_at_from="2026-04-18T00:00:00+03:00", + limit=10, + offset=0, + version=1, + ) + assert classic_chats.items[0].buyer_name == "Петр" + assert ( + await chat.list( + created_at_from="2026-04-18T00:00:00+03:00", + limit=10, + offset=0, + ) + ).items[0].is_arbitrage_available is True + assert ( + await chat.get_phones_info_from_chats( + date_time_from="2026-04-18T00:00:00+03:00", + limit=10, + offset=0, + ) + ).items[1].phone_number == "+79990000002" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_cpa_calls_archive_and_balance_flows() -> None: + audio_bytes = b"ID3 fake audio" + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/cpa/v2/callsByTime", + { + "calls": [ + { + "id": 2001, + "itemId": 3001, + "buyerPhone": "+79990000010", + "sellerPhone": "+79990000011", + "virtualPhone": "+79990000012", + "statusId": 2, + "price": 171600, + "duration": 119, + "waitingDuration": 0.5, + "createTime": "2026-04-18T11:00:00+03:00", + "recordUrl": "https://example.com/record-2001.mp3", + } + ] + }, + ) + .add_json("POST", "/cpa/v1/createComplaint", {"success": True}) + .add_json("POST", "/cpa/v1/createComplaintByActionId", {"success": True}) + .add_json("POST", "/cpa/v3/balanceInfo", {"balance": -5000}) + .add_json("POST", "/cpa/v2/balanceInfo", {"balance": -5000, "advance": 1000, "debt": 0}) + .add_json( + "POST", + "/cpa/v2/callById", + { + "calls": { + "id": 2001, + "itemId": 3001, + "buyerPhone": "+79990000010", + "sellerPhone": "+79990000011", + "virtualPhone": "+79990000012", + "statusId": 2, + "price": 171600, + "duration": 119, + "waitingDuration": 0.5, + "createTime": "2026-04-18T11:00:00+03:00", + } + }, + ) + .add( + "GET", + "/cpa/v1/call/2001", + httpx.Response( + 200, + content=audio_bytes, + headers={ + "content-type": "audio/mpeg", + "content-disposition": 'attachment; filename="call-2001.mp3"', + }, + ), + ) + ) + transport = fake.build() + cpa_call = AsyncCpaCall(transport) + cpa_lead = AsyncCpaLead(transport) + archive = AsyncCpaArchive(transport, call_id="2001") + + assert ( + await cpa_call.list(date_time_from="2026-04-18T00:00:00+03:00", limit=100) + ).items[0].record_url == "https://example.com/record-2001.mp3" + assert (await cpa_call.create_complaint(call_id=2001, reason="spam")).success is True + assert ( + await cpa_lead.create_complaint_by_action_id(action_id=101, reason="duplicate") + ).success is True + assert (await cpa_lead.get_balance_info()).balance == -5000 + with pytest.deprecated_call(match="cpa_lead\\(\\)\\.get_balance_info"): + archived_balance = await archive.get_balance_info() + with pytest.deprecated_call(match="call_tracking_call\\(\\)\\.get"): + archived_call = await archive.get_call_by_id(call_id=2001) + with pytest.deprecated_call(match="call_tracking_call\\(\\)\\.download"): + archived_audio = await archive.get_call() + assert archived_balance.advance == 1000 + assert archived_call.call_id == "2001" + assert archived_audio.binary.content == audio_bytes + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_cpa_complaint_idempotency_key_is_stable_across_retry() -> None: + calls = {"count": 0} + seen_keys: list[str | None] = [] + + def create_complaint(request: RecordedRequest) -> httpx.Response: + calls["count"] += 1 + seen_keys.append(request.headers.get("idempotency-key")) + if calls["count"] == 1: + raise httpx.ConnectError("offline") + assert request.path == "/cpa/v1/createComplaint" + return httpx.Response(200, json={"success": True}) + + fake = AsyncFakeTransport().add("POST", "/cpa/v1/createComplaint", create_complaint) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=2)) + cpa_call = AsyncCpaCall(transport) + + result = await cpa_call.create_complaint( + call_id=2001, + reason="spam", + idempotency_key="idem-cpa-complaint", + ) + + assert result.success is True + assert calls["count"] == 2 + assert seen_keys == ["idem-cpa-complaint", "idem-cpa-complaint"] + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_cpa_call_unknown_status_id_maps_to_unknown_and_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + fake = AsyncFakeTransport().add_json( + "POST", + "/cpa/v2/callsByTime", + {"calls": [{"id": 2001, "itemId": 3001, "statusId": 998}]}, + ) + transport = fake.build() + caplog.set_level(logging.WARNING, logger="avito.core.enums") + cpa_call = AsyncCpaCall(transport) + + first = ( + await cpa_call.list( + date_time_from="2026-04-18T00:00:00+03:00", + limit=100, + ) + ).items[0] + second = ( + await cpa_call.list( + date_time_from="2026-04-18T00:00:00+03:00", + limit=100, + ) + ).items[0] + + assert first.status_id is CpaCallStatusId.UNKNOWN + assert second.status_id is CpaCallStatusId.UNKNOWN + records = [ + record + for record in caplog.records + if getattr(record, "enum", None) == "cpa.call_status_id" + and getattr(record, "value", None) == 998 + ] + assert len(records) == 1 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_calltracking_flows() -> None: + audio_bytes = b"RIFF fake wave" + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/calltracking/v1/getCallById/", + { + "call": { + "callId": 7001, + "itemId": 9901, + "buyerPhone": "+79990000100", + "sellerPhone": "+79990000101", + "virtualPhone": "+79990000102", + "callTime": "2026-04-18T09:00:00Z", + "talkDuration": 67, + "waitingDuration": 1.25, + }, + "error": {"code": 0, "message": ""}, + }, + ) + .add_json( + "POST", + "/calltracking/v1/getCalls/", + { + "calls": [ + { + "callId": 7001, + "itemId": 9901, + "buyerPhone": "+79990000100", + "sellerPhone": "+79990000101", + "virtualPhone": "+79990000102", + "callTime": "2026-04-18T09:00:00Z", + "talkDuration": 67, + "waitingDuration": 1.25, + } + ], + "error": {"code": 0, "message": ""}, + }, + ) + .add( + "GET", + "/calltracking/v1/getRecordByCallId/", + httpx.Response( + 200, + content=audio_bytes, + headers={ + "content-type": "audio/wav", + "content-disposition": 'attachment; filename="record-7001.wav"', + }, + ), + ) + ) + transport = fake.build() + call = AsyncCallTrackingCall(transport, call_id="7001") + + assert (await call.get()).call.call_id == "7001" + assert ( + await call.list( + date_time_from="2026-04-01T00:00:00Z", + date_time_to="2026-04-18T23:59:59Z", + limit=100, + offset=0, + ) + ).items[0].buyer_phone == "+79990000100" + assert (await call.download()).binary.content == audio_bytes + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_cpa_rejects_invalid_datetime_before_transport() -> None: + transport = AsyncFakeTransport().build() + chat = AsyncCpaChat(transport) + call = AsyncCpaCall(transport) + tracking = AsyncCallTrackingCall(transport) + + with pytest.raises(ValidationError, match="created_at_from"): + await chat.list(created_at_from="18.04.2026", limit=10, offset=0) + with pytest.raises(ValidationError, match="date_time_from"): + await call.list(date_time_from="", limit=100) + with pytest.raises(ValidationError, match="date_time_to"): + await tracking.list(date_time_from="2026-04-01T00:00:00Z", date_time_to="not-a-date") + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_cpa_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client() + + assert isinstance(client.cpa_lead(), AsyncCpaLead) + assert isinstance(client.cpa_chat(chat_id="act-1"), AsyncCpaChat) + assert isinstance(client.cpa_call(), AsyncCpaCall) + assert isinstance(client.cpa_archive(call_id=2001), AsyncCpaArchive) + assert isinstance(client.call_tracking_call(call_id=7001), AsyncCallTrackingCall) + await client.aclose() + + +def test_async_client_cpa_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.cpa_lead() + with pytest.raises(RuntimeError): + client.cpa_chat() + with pytest.raises(RuntimeError): + client.cpa_call() + with pytest.raises(RuntimeError): + client.cpa_archive() + with pytest.raises(RuntimeError): + client.call_tracking_call() diff --git a/tests/domains/realty/test_realty_async.py b/tests/domains/realty/test_realty_async.py new file mode 100644 index 0000000..69da33e --- /dev/null +++ b/tests/domains/realty/test_realty_async.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core import ValidationError +from avito.realty import ( + AsyncRealtyAnalyticsReport, + AsyncRealtyBooking, + AsyncRealtyListing, + AsyncRealtyPricing, +) +from avito.realty.models import RealtyInterval, RealtyPricePeriod +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_realty_maps_bookings_pricing_listing_and_analytics() -> None: + def update_bookings(request: RecordedRequest): + assert request.json_body == { + "bookings": [{"date_start": "2026-04-18", "date_end": "2026-04-18"}] + } + return _success_response() + + def update_prices(request: RecordedRequest): + assert request.json_body == { + "prices": [{"date_from": "2026-05-01", "night_price": 5000}] + } + return _success_response() + + def get_intervals(request: RecordedRequest): + assert request.json_body == { + "item_id": 20, + "intervals": [{"date_start": "2026-05-01", "date_end": "2026-05-01", "open": 1}], + } + return _success_response() + + def update_base(request: RecordedRequest): + assert request.json_body == {"minimal_duration": 2} + return _success_response() + + fake = ( + AsyncFakeTransport() + .add("POST", "/core/v1/accounts/10/items/20/bookings", update_bookings) + .add_json( + "GET", + "/realty/v1/accounts/10/items/20/bookings", + { + "bookings": [ + { + "avito_booking_id": 777, + "status": "active", + "check_in": "2026-05-01", + "check_out": "2026-05-05", + "guest_count": 2, + "nights": 4, + "base_price": 12000, + "contact": { + "name": "Иван", + "email": "ivan@example.com", + "phone": "9997770000", + }, + "safe_deposit": { + "owner_amount": 4500, + "tax": 500, + "total_amount": 5000, + }, + } + ] + }, + ) + .add("POST", "/realty/v1/accounts/10/items/20/prices", update_prices) + .add("POST", "/realty/v1/items/intervals", get_intervals) + .add("POST", "/realty/v1/items/20/base", update_base) + .add_json( + "GET", + "/realty/v1/marketPriceCorrespondence/20/5000000", + {"correspondence": "normal"}, + ) + .add_json( + "POST", + "/realty/v1/report/create/20", + {"success": {"success": {"reportLink": "https://example.com/realty-report/20"}}}, + ) + ) + transport = fake.build() + booking = AsyncRealtyBooking(transport, item_id="20", user_id="10") + pricing = AsyncRealtyPricing(transport, item_id="20", user_id="10") + listing = AsyncRealtyListing(transport, item_id="20") + analytics = AsyncRealtyAnalyticsReport(transport, item_id="20") + + assert (await booking.update_bookings_info(blocked_dates=["2026-04-18"])).success is True + bookings = await booking.list_realty_bookings( + date_start="2026-05-01", + date_end="2026-05-05", + with_unpaid=True, + ) + assert bookings.items[0].contact is not None + assert bookings.items[0].contact.name == "Иван" + assert ( + await pricing.update_realty_prices( + periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)] + ) + ).status == "success" + assert ( + await listing.get_intervals(intervals=[RealtyInterval(date="2026-05-01", available=True)]) + ).success is True + assert (await listing.update_base_params(min_stay_days=2)).success is True + assert (await analytics.get_market_price_correspondence(price=5000000)).correspondence == "normal" + assert ( + await analytics.get_report_for_classified() + ).report_link == "https://example.com/realty-report/20" + assert fake.last(method="GET", path="/realty/v1/accounts/10/items/20/bookings").params == { + "date_start": "2026-05-01", + "date_end": "2026-05-05", + "with_unpaid": "true", + } + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_realty_write_operation_forwards_idempotency_key() -> None: + def update_prices(request: RecordedRequest): + assert request.headers["idempotency-key"] == "idem-realty-prices" + assert request.json_body == { + "prices": [{"date_from": "2026-05-01", "night_price": 5000}] + } + return _success_response() + + fake = AsyncFakeTransport().add( + "POST", + "/realty/v1/accounts/10/items/20/prices", + update_prices, + ) + transport = fake.build() + pricing = AsyncRealtyPricing(transport, item_id="20", user_id="10") + + result = await pricing.update_realty_prices( + periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)], + idempotency_key="idem-realty-prices", + ) + + assert result.status == "success" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_realty_rejects_invalid_dates_before_transport() -> None: + transport = AsyncFakeTransport().build() + booking = AsyncRealtyBooking(transport, item_id="20", user_id="10") + pricing = AsyncRealtyPricing(transport, item_id="20", user_id="10") + listing = AsyncRealtyListing(transport, item_id="20") + + with pytest.raises(ValidationError, match="date_start"): + await booking.list_realty_bookings(date_start="01.05.2026", date_end="2026-05-05") + with pytest.raises(ValidationError, match="blocked_dates"): + await booking.update_bookings_info(blocked_dates=["not-a-date"]) + with pytest.raises(ValidationError, match="date_from"): + await pricing.update_realty_prices( + periods=[RealtyPricePeriod(date_from="not-a-date", price=5000)] + ) + with pytest.raises(ValidationError, match="date"): + await listing.get_intervals(intervals=[RealtyInterval(date="not-a-date", available=True)]) + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_realty_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client() + + assert isinstance(client.realty_listing(item_id=20, user_id=10), AsyncRealtyListing) + assert isinstance(client.realty_booking(item_id=20, user_id=10), AsyncRealtyBooking) + assert isinstance(client.realty_pricing(item_id=20, user_id=10), AsyncRealtyPricing) + assert isinstance( + client.realty_analytics_report(item_id=20, user_id=10), + AsyncRealtyAnalyticsReport, + ) + await client.aclose() + + +def test_async_client_realty_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.realty_listing() + with pytest.raises(RuntimeError): + client.realty_booking() + with pytest.raises(RuntimeError): + client.realty_pricing() + with pytest.raises(RuntimeError): + client.realty_analytics_report() + + +def _success_response(): + import httpx + + return httpx.Response(200, json={"result": "success"}) From ce0811a5b0a244dd6bef4f8b4c92512b3abf42d1 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 20:43:08 +0300 Subject: [PATCH 17/26] M7 --- CHANGELOG.d/0000-async-messenger.md | 2 + avito/async_client.py | 47 + avito/messenger/__init__.py | 12 + avito/messenger/async_domain.py | 915 ++++++++++++++++++ .../contracts/test_async_swagger_contracts.py | 7 +- .../domains/messenger/test_messenger_async.py | 263 +++++ 6 files changed, 1245 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.d/0000-async-messenger.md create mode 100644 avito/messenger/async_domain.py create mode 100644 tests/domains/messenger/test_messenger_async.py diff --git a/CHANGELOG.d/0000-async-messenger.md b/CHANGELOG.d/0000-async-messenger.md new file mode 100644 index 0000000..66df8c5 --- /dev/null +++ b/CHANGELOG.d/0000-async-messenger.md @@ -0,0 +1,2 @@ +### Added +- Async-поддержка домена messenger: AsyncChat, AsyncChatMedia, AsyncChatMessage, AsyncChatWebhook, AsyncSpecialOfferCampaign (#0000) diff --git a/avito/async_client.py b/avito/async_client.py index 52e8945..2abc314 100644 --- a/avito/async_client.py +++ b/avito/async_client.py @@ -21,6 +21,13 @@ AsyncCpaChat, AsyncCpaLead, ) +from avito.messenger import ( + AsyncChat, + AsyncChatMedia, + AsyncChatMessage, + AsyncChatWebhook, + AsyncSpecialOfferCampaign, +) from avito.ratings import AsyncRatingProfile, AsyncReview, AsyncReviewAnswer from avito.realty import ( AsyncRealtyAnalyticsReport, @@ -226,6 +233,46 @@ def realty_analytics_report( user_id=user_id, ) + def chat( + self, chat_id: int | str | None = None, *, user_id: int | str | None = None + ) -> AsyncChat: + """Создает async-доменный объект чата.""" + + return AsyncChat(self._require_transport(), chat_id=chat_id, user_id=user_id) + + def chat_message( + self, + message_id: int | str | None = None, + *, + chat_id: int | str | None = None, + user_id: int | str | None = None, + ) -> AsyncChatMessage: + """Создает async-доменный объект сообщения чата.""" + + return AsyncChatMessage( + self._require_transport(), + chat_id=chat_id, + message_id=message_id, + user_id=user_id, + ) + + def chat_webhook(self) -> AsyncChatWebhook: + """Создает async-доменный объект webhook мессенджера.""" + + return AsyncChatWebhook(self._require_transport()) + + def chat_media(self, *, user_id: int | str | None = None) -> AsyncChatMedia: + """Создает async-доменный объект медиа мессенджера.""" + + return AsyncChatMedia(self._require_transport(), user_id=user_id) + + def special_offer_campaign( + self, campaign_id: int | str | None = None + ) -> AsyncSpecialOfferCampaign: + """Создает async-доменный объект рассылки спецпредложений.""" + + return AsyncSpecialOfferCampaign(self._require_transport(), campaign_id=campaign_id) + async def aclose(self) -> None: """Закрывает transport и auth-provider; повторный вызов безопасен.""" diff --git a/avito/messenger/__init__.py b/avito/messenger/__init__.py index e6530a0..c7ca149 100644 --- a/avito/messenger/__init__.py +++ b/avito/messenger/__init__.py @@ -1,5 +1,12 @@ """Пакет messenger.""" +from avito.messenger.async_domain import ( + AsyncChat, + AsyncChatMedia, + AsyncChatMessage, + AsyncChatWebhook, + AsyncSpecialOfferCampaign, +) from avito.messenger.domain import ( Chat, ChatMedia, @@ -33,6 +40,11 @@ ) __all__ = ( + "AsyncChat", + "AsyncChatMedia", + "AsyncChatMessage", + "AsyncChatWebhook", + "AsyncSpecialOfferCampaign", "Chat", "ChatInfo", "ChatMedia", diff --git a/avito/messenger/async_domain.py b/avito/messenger/async_domain.py new file mode 100644 index 0000000..64d41cc --- /dev/null +++ b/avito/messenger/async_domain.py @@ -0,0 +1,915 @@ +"""Async-доменные объекты пакета messenger.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import DateInput, serialize_iso_datetime +from avito.messenger.models import ( + BlacklistRequest, + ChatInfo, + ChatsResult, + MessageActionResult, + MessagesResult, + MultiConfirmSpecialOfferRequest, + MultiCreateSpecialOfferRequest, + MultiCreateSpecialOfferResult, + SendImageMessageRequest, + SendMessageRequest, + SpecialOfferAvailableRequest, + SpecialOfferAvailableResult, + SpecialOfferStatsRequest, + SpecialOfferStatsResult, + SubscriptionsResult, + TariffInfo, + UnsubscribeWebhookRequest, + UpdateWebhookRequest, + UploadImageFile, + UploadImagesRequest, + UploadImagesResult, + VoiceFilesResult, + WebhookActionResult, +) +from avito.messenger.operations import ( + ADD_TO_BLACKLIST, + CONFIRM_MULTI_SPECIAL_OFFER, + CREATE_MULTI_SPECIAL_OFFER, + DELETE_MESSAGE, + GET_AVAILABLE_SPECIAL_OFFERS, + GET_CHAT, + GET_SPECIAL_OFFER_STATS, + GET_SPECIAL_OFFER_TARIFF_INFO, + GET_SUBSCRIPTIONS, + GET_VOICE_FILES, + LIST_CHATS, + LIST_MESSAGES, + READ_CHAT, + SEND_IMAGE_MESSAGE, + SEND_MESSAGE, + UNSUBSCRIBE_WEBHOOK, + UPDATE_WEBHOOK_V3, + UPLOAD_IMAGES, +) + + +@dataclass(slots=True, frozen=True) +class AsyncChat(AsyncDomainObject): + """Async-доменный объект чата.""" + + __swagger_domain__ = "messenger" + __sdk_factory__ = "chat" + __sdk_factory_args__ = {"chat_id": "path.chat_id", "user_id": "path.user_id"} + + chat_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/messenger/v2/accounts/{user_id}/chats/{chat_id}", + spec="Мессенджер.json", + operation_id="getChatByIdV2", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> ChatInfo: + """Получает чат по `chat_id` асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ChatInfo` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CHAT, + path_params={ + "user_id": self._require_user_id(), + "chat_id": self._require_chat_id(), + }, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/messenger/v2/accounts/{user_id}/chats", + spec="Мессенджер.json", + operation_id="getChatsV2", + variant="async", + ) + async def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> ChatsResult: + """Возвращает список чатов асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ChatsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_CHATS, + path_params={"user_id": self._require_user_id()}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/chats/{chat_id}/read", + spec="Мессенджер.json", + operation_id="chatRead", + variant="async", + ) + async def mark_read( + self, + *, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessageActionResult: + """Помечает чат как прочитанный асинхронно. + + Аргументы: + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessageActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + READ_CHAT, + path_params={ + "user_id": self._require_user_id(), + "chat_id": self._require_chat_id(), + }, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v2/accounts/{user_id}/blacklist", + spec="Мессенджер.json", + operation_id="postBlacklistV2", + method_args={"blacklisted_user_id": "body.users[].user_id"}, + variant="async", + ) + async def blacklist( + self, + *, + blacklisted_user_id: int, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessageActionResult: + """Добавляет пользователя в blacklist асинхронно. + + Аргументы: + blacklisted_user_id: идентификатор пользователя, которого нужно добавить в черный список. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessageActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + ADD_TO_BLACKLIST, + path_params={"user_id": self._require_user_id()}, + request=BlacklistRequest(blacklisted_user_id=blacklisted_user_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_user_id(self) -> int: + if self.user_id is None: + raise ValidationError("Для операции требуется `user_id`.") + return int(self.user_id) + + def _require_chat_id(self) -> str: + if self.chat_id is None: + raise ValidationError("Для операции требуется `chat_id`.") + return str(self.chat_id) + + +@dataclass(slots=True, frozen=True) +class AsyncChatMessage(AsyncDomainObject): + """Async-доменный объект сообщения чата.""" + + __swagger_domain__ = "messenger" + __sdk_factory__ = "chat_message" + __sdk_factory_args__ = { + "message_id": "path.message_id", + "chat_id": "path.chat_id", + "user_id": "path.user_id", + } + + chat_id: int | str | None = None + message_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/messenger/v3/accounts/{user_id}/chats/{chat_id}/messages/", + spec="Мессенджер.json", + operation_id="getMessagesV3", + variant="async", + ) + async def list( + self, + *, + chat_id: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessagesResult: + """Возвращает список сообщений чата асинхронно. + + Аргументы: + chat_id: идентифицирует чат. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessagesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_MESSAGES, + path_params={ + "user_id": self._require_user_id(), + "chat_id": chat_id or self._require_chat_id(), + }, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages", + spec="Мессенджер.json", + operation_id="postSendMessage", + method_args={"message": "body.message"}, + variant="async", + ) + async def send_message( + self, + *, + chat_id: str | None = None, + message: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessageActionResult: + """Отправляет текстовое сообщение асинхронно. + + Аргументы: + chat_id: идентификатор чата. + message: текст отправляемого сообщения. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessageActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SEND_MESSAGE, + path_params={ + "user_id": self._require_user_id(), + "chat_id": chat_id or self._require_chat_id(), + }, + request=SendMessageRequest(message=message), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages/image", + spec="Мессенджер.json", + operation_id="postSendImageMessage", + method_args={"image_id": "body.image_id"}, + variant="async", + ) + async def send_image( + self, + *, + chat_id: str | None = None, + image_id: str, + caption: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessageActionResult: + """Отправляет сообщение с изображением асинхронно. + + Аргументы: + chat_id: идентификатор чата. + image_id: идентификатор изображения для отправки. + caption: подпись к изображению, если поддерживается операцией. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessageActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SEND_IMAGE_MESSAGE, + path_params={ + "user_id": self._require_user_id(), + "chat_id": chat_id or self._require_chat_id(), + }, + request=SendImageMessageRequest(image_id=image_id, caption=caption), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages/{message_id}", + spec="Мессенджер.json", + operation_id="deleteMessage", + variant="async", + ) + async def delete( + self, + *, + chat_id: str | None = None, + message_id: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessageActionResult: + """Удаляет сообщение асинхронно. + + Аргументы: + chat_id: идентификатор чата. + message_id: идентификатор сообщения. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessageActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_message_id = message_id or self._require_message_id() + return await self._execute( + DELETE_MESSAGE, + path_params={ + "user_id": self._require_user_id(), + "chat_id": chat_id or self._require_chat_id(), + "message_id": resolved_message_id, + }, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_user_id(self) -> int: + if self.user_id is None: + raise ValidationError("Для операции требуется `user_id`.") + return int(self.user_id) + + def _require_chat_id(self) -> str: + if self.chat_id is None: + raise ValidationError("Для операции требуется `chat_id`.") + return str(self.chat_id) + + def _require_message_id(self) -> str: + if self.message_id is None: + raise ValidationError("Для операции требуется `message_id`.") + return str(self.message_id) + + +@dataclass(slots=True, frozen=True) +class AsyncChatWebhook(AsyncDomainObject): + """Async-доменный объект webhook мессенджера.""" + + __swagger_domain__ = "messenger" + __sdk_factory__ = "chat_webhook" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/messenger/v1/subscriptions", + spec="Мессенджер.json", + operation_id="getSubscriptions", + variant="async", + ) + async def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> SubscriptionsResult: + """Возвращает список webhook-подписок чатов асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `SubscriptionsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_SUBSCRIPTIONS, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/messenger/v1/webhook/unsubscribe", + spec="Мессенджер.json", + operation_id="postWebhookUnsubscribe", + method_args={"url": "body.url"}, + variant="async", + ) + async def unsubscribe( + self, + *, + url: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> WebhookActionResult: + """Отключает webhook асинхронно. + + Аргументы: + url: URL источника данных. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `WebhookActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UNSUBSCRIBE_WEBHOOK, + request=UnsubscribeWebhookRequest(url=url), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v3/webhook", + spec="Мессенджер.json", + operation_id="postWebhookV3", + method_args={"url": "body.url"}, + variant="async", + ) + async def subscribe( + self, + *, + url: str, + secret: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> WebhookActionResult: + """Включает webhook v3 асинхронно. + + Аргументы: + url: URL источника данных. + secret: секрет webhook-подписки для проверки входящих событий. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `WebhookActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_WEBHOOK_V3, + request=UpdateWebhookRequest(url=url, secret=secret), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncChatMedia(AsyncDomainObject): + """Async-доменный объект media-функций мессенджера.""" + + __swagger_domain__ = "messenger" + __sdk_factory__ = "chat_media" + __sdk_factory_args__ = {"user_id": "path.user_id"} + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/messenger/v1/accounts/{user_id}/getVoiceFiles", + spec="Мессенджер.json", + operation_id="getVoiceFiles", + variant="async", + ) + async def get_voice_files( + self, + *, + voice_ids: Sequence[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VoiceFilesResult: + """Получает голосовые сообщения асинхронно. + + Аргументы: + voice_ids: идентификаторы голосовых файлов. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VoiceFilesResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_voice_ids = list(voice_ids or ["voice-1"]) + return await self._execute( + GET_VOICE_FILES, + path_params={"user_id": self._require_user_id()}, + query={"voice_ids": ",".join(resolved_voice_ids)}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/uploadImages", + spec="Мессенджер.json", + operation_id="uploadImages", + method_args={"files": "body.uploadfile[]"}, + variant="async", + ) + async def upload_images( + self, + *, + files: list[UploadImageFile], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> UploadImagesResult: + """Загружает изображения для сообщений асинхронно. + + Аргументы: + files: файлы изображений для загрузки. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `UploadImagesResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPLOAD_IMAGES, + path_params={"user_id": self._require_user_id()}, + files=UploadImagesRequest(files=files).to_files(), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_user_id(self) -> int: + if self.user_id is None: + raise ValidationError("Для операции требуется `user_id`.") + return int(self.user_id) + + +@dataclass(slots=True, frozen=True) +class AsyncSpecialOfferCampaign(AsyncDomainObject): + """Async-доменный объект рассылки скидок и спецпредложений.""" + + __swagger_domain__ = "messenger" + __sdk_factory__ = "special_offer_campaign" + __sdk_factory_args__ = {"campaign_id": "path.campaign_id"} + + campaign_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/special-offers/v1/available", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiAvailable", + method_args={"item_ids": "body.item_ids"}, + variant="async", + ) + async def get_available( + self, + *, + item_ids: list[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> SpecialOfferAvailableResult: + """Получает объявления, доступные для рассылки, асинхронно. + + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `SpecialOfferAvailableResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AVAILABLE_SPECIAL_OFFERS, + request=SpecialOfferAvailableRequest(item_ids=item_ids), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/special-offers/v1/multiCreate", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiMultiCreate", + method_args={"item_ids": "body.itemIds"}, + variant="async", + ) + async def create_multi( + self, + *, + item_ids: list[int], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MultiCreateSpecialOfferResult: + """Создает рассылку спецпредложений асинхронно. + + Аргументы: + item_ids: передает список объявлений для рассылки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MultiCreateSpecialOfferResult` с идентификатором и статусом рассылки. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_MULTI_SPECIAL_OFFER, + request=MultiCreateSpecialOfferRequest(item_ids=item_ids), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/special-offers/v1/multiConfirm", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiMultiConfirm", + method_args={ + "dispatch_id": "body.dispatches[].dispatchId", + "recipients_count": "body.dispatches[].recipientsCount", + "offer_slug": "body.dispatches[].offerSlug", + }, + variant="async", + ) + async def confirm_multi( + self, + *, + dispatch_id: int, + recipients_count: int, + offer_slug: str, + discount_value: int | None = None, + expires_at: int | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> WebhookActionResult: + """Подтверждает и оплачивает рассылку асинхронно. + + Аргументы: + dispatch_id: идентифицирует рассылку. + recipients_count: задает число получателей рассылки. + offer_slug: задает выбранный вариант предложения. + discount_value: задает финальный размер скидки, если он применим. + expires_at: задает timestamp окончания предложения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `WebhookActionResult` со статусом подтверждения. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CONFIRM_MULTI_SPECIAL_OFFER, + request=MultiConfirmSpecialOfferRequest( + dispatch_id=dispatch_id, + recipients_count=recipients_count, + offer_slug=offer_slug, + discount_value=discount_value, + expires_at=expires_at, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/special-offers/v1/stats", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiStats", + method_args={ + "date_time_from": "body.dateTimeFrom", + "date_time_to": "body.dateTimeTo", + }, + variant="async", + ) + async def get_stats( + self, + *, + date_time_from: DateInput, + date_time_to: DateInput, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> SpecialOfferStatsResult: + """Получает статистику рассылки асинхронно. + + Аргументы: + date_time_from: задает начало периода в формате RFC3339. + date_time_to: задает конец периода в формате RFC3339. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `SpecialOfferStatsResult` со статистикой рассылки. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_SPECIAL_OFFER_STATS, + request=SpecialOfferStatsRequest( + date_time_from=serialize_iso_datetime("date_time_from", date_time_from), + date_time_to=serialize_iso_datetime("date_time_to", date_time_to), + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/special-offers/v1/tariffInfo", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiTariffInfo", + variant="async", + ) + async def get_tariff_info( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> TariffInfo: + """Получает информацию о тарифе спецпредложений асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TariffInfo` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_SPECIAL_OFFER_TARIFF_INFO, timeout=timeout, retry=retry) + + def _require_campaign_id(self) -> str: + if self.campaign_id is None: + raise ValidationError("Для операции требуется `campaign_id`.") + return str(self.campaign_id) + + +__all__ = ( + "AsyncChat", + "AsyncChatMedia", + "AsyncChatMessage", + "AsyncChatWebhook", + "AsyncSpecialOfferCampaign", +) diff --git a/tests/contracts/test_async_swagger_contracts.py b/tests/contracts/test_async_swagger_contracts.py index f89f11c..e0ca3d5 100644 --- a/tests/contracts/test_async_swagger_contracts.py +++ b/tests/contracts/test_async_swagger_contracts.py @@ -13,6 +13,10 @@ def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: "AsyncAccountHierarchy", "AsyncAlternateTokenClient", "AsyncCallTrackingCall", + "AsyncChat", + "AsyncChatMedia", + "AsyncChatMessage", + "AsyncChatWebhook", "AsyncCpaArchive", "AsyncCpaCall", "AsyncCpaChat", @@ -24,7 +28,8 @@ def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: "AsyncRealtyPricing", "AsyncReview", "AsyncReviewAnswer", + "AsyncSpecialOfferCampaign", "AsyncTariff", "AsyncTokenClient", } - assert len(async_bindings) == 38 + assert len(async_bindings) == 56 diff --git a/tests/domains/messenger/test_messenger_async.py b/tests/domains/messenger/test_messenger_async.py new file mode 100644 index 0000000..8e0a3a9 --- /dev/null +++ b/tests/domains/messenger/test_messenger_async.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core import ValidationError +from avito.messenger import ( + AsyncChat, + AsyncChatMedia, + AsyncChatMessage, + AsyncChatWebhook, + AsyncSpecialOfferCampaign, +) +from avito.messenger.models import UploadImageFile +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_messenger_chat_message_and_media_flows() -> None: + def blacklist(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"users": [{"user_id": 42}]} + assert request.headers["idempotency-key"] == "idem-blacklist" + return httpx.Response(200, json={"success": True}) + + def send_message(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "message": {"text": "Здравствуйте"}, + "type": "text", + } + return httpx.Response(200, json={"success": True, "message_id": "msg-1", "status": "sent"}) + + def send_image(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"image_id": "img-1", "caption": "Фото"} + return httpx.Response( + 200, json={"success": True, "message_id": "msg-img-1", "status": "sent"} + ) + + fake = ( + AsyncFakeTransport() + .add_json( + "GET", + "/messenger/v2/accounts/7/chats", + {"chats": [{"id": "chat-1", "user_id": 7, "title": "Покупатель"}]}, + ) + .add_json( + "GET", + "/messenger/v2/accounts/7/chats/chat-1", + {"id": "chat-1", "user_id": 7, "title": "Покупатель"}, + ) + .add("POST", "/messenger/v2/accounts/7/blacklist", blacklist) + .add_json("POST", "/messenger/v1/accounts/7/chats/chat-1/read", {"success": True}) + .add_json( + "GET", + "/messenger/v3/accounts/7/chats/chat-1/messages/", + {"messages": [{"id": "msg-1", "chat_id": "chat-1", "text": "Здравствуйте"}]}, + ) + .add("POST", "/messenger/v1/accounts/7/chats/chat-1/messages", send_message) + .add("POST", "/messenger/v1/accounts/7/chats/chat-1/messages/image", send_image) + .add_json( + "POST", + "/messenger/v1/accounts/7/chats/chat-1/messages/msg-1", + {"success": True, "status": "confirmed"}, + ) + .add_json( + "GET", + "/messenger/v1/accounts/7/getVoiceFiles", + {"voice_files": [{"id": "voice-1", "url": "https://cdn/voice-1.ogg", "duration": 3}]}, + ) + .add_json( + "POST", + "/messenger/v1/accounts/7/uploadImages", + {"images": [{"image_id": "img-1", "url": "https://cdn/img-1.jpg"}]}, + ) + ) + transport = fake.build() + chat = AsyncChat(transport, chat_id="chat-1", user_id=7) + message = AsyncChatMessage(transport, chat_id="chat-1", message_id="msg-1", user_id=7) + media = AsyncChatMedia(transport, user_id=7) + + chats = await chat.list() + info = await chat.get() + blocked = await chat.blacklist(blacklisted_user_id=42, idempotency_key="idem-blacklist") + read = await chat.mark_read() + messages = await message.list() + sent = await message.send_message(message="Здравствуйте") + image_sent = await message.send_image(image_id="img-1", caption="Фото") + deleted = await message.delete() + voices = await media.get_voice_files(voice_ids=["voice-1"]) + uploaded = await media.upload_images( + files=[ + UploadImageFile( + field_name="image", + filename="photo.jpg", + content=b"binary", + content_type="image/jpeg", + ) + ] + ) + + assert chats.items[0].chat_id == "chat-1" + assert info.title == "Покупатель" + assert blocked.success is True + assert read.success is True + assert messages.items[0].message_id == "msg-1" + assert sent.message_id == "msg-1" + assert image_sent.message_id == "msg-img-1" + assert deleted.success is True + assert voices.items[0].id == "voice-1" + assert uploaded.items[0].image_id == "img-1" + assert fake.last(method="GET", path="/messenger/v1/accounts/7/getVoiceFiles").params == { + "voice_ids": "voice-1" + } + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_messenger_webhook_and_special_offer_flows() -> None: + def subscribe(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"url": "https://example.com/hook", "secret": "top-secret"} + return httpx.Response(200, json={"success": True, "status": "subscribed"}) + + def unsubscribe(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"url": "https://example.com/hook"} + return httpx.Response(200, json={"success": True, "status": "confirmed"}) + + def available(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"itemIds": [1, 2]} + return httpx.Response( + 200, + json={"items": [{"itemId": 1, "title": "Объявление", "available": True}]}, + ) + + def create_multi(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"itemIds": [1]} + return httpx.Response(200, json={"campaign_id": "camp-1", "status": "draft"}) + + def confirm_multi(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "dispatches": [ + { + "dispatchId": 1, + "recipientsCount": 20, + "offerSlug": "discount", + "discountValue": 10, + } + ], + "expiresAt": 1767225600, + } + return httpx.Response(200, json={"success": True, "status": "confirmed"}) + + def stats(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "dateTimeFrom": "2026-05-01T00:00:00+03:00", + "dateTimeTo": "2026-05-02T00:00:00+03:00", + } + return httpx.Response( + 200, + json={ + "campaign_id": "camp-1", + "sent_count": 20, + "delivered_count": 18, + "read_count": 10, + }, + ) + + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/messenger/v1/subscriptions", + { + "subscriptions": [ + {"url": "https://example.com/hook", "version": "v3", "status": "active"} + ] + }, + ) + .add("POST", "/messenger/v3/webhook", subscribe) + .add("POST", "/messenger/v1/webhook/unsubscribe", unsubscribe) + .add("POST", "/special-offers/v1/available", available) + .add("POST", "/special-offers/v1/multiCreate", create_multi) + .add("POST", "/special-offers/v1/multiConfirm", confirm_multi) + .add("POST", "/special-offers/v1/stats", stats) + .add_json( + "POST", "/special-offers/v1/tariffInfo", {"price": 5.5, "currency": "RUB", "limit": 100} + ) + ) + transport = fake.build() + webhook = AsyncChatWebhook(transport) + campaign = AsyncSpecialOfferCampaign(transport, campaign_id="camp-1") + + subscriptions = await webhook.list() + subscribed = await webhook.subscribe(url="https://example.com/hook", secret="top-secret") + unsubscribed = await webhook.unsubscribe(url="https://example.com/hook") + available_result = await campaign.get_available(item_ids=[1, 2]) + created = await campaign.create_multi(item_ids=[1]) + confirmed = await campaign.confirm_multi( + dispatch_id=1, + recipients_count=20, + offer_slug="discount", + discount_value=10, + expires_at=1767225600, + ) + stats_result = await campaign.get_stats( + date_time_from="2026-05-01T00:00:00+03:00", + date_time_to="2026-05-02T00:00:00+03:00", + ) + tariff = await campaign.get_tariff_info() + + assert subscriptions.items[0].status == "active" + assert subscribed.status == "subscribed" + assert unsubscribed.status == "confirmed" + assert available_result.items[0].item_id == 1 + assert created.status == "draft" + assert confirmed.status == "confirmed" + assert stats_result.delivered_count == 18 + assert tariff.daily_limit == 100 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_messenger_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client() + + assert isinstance(client.chat("chat-1", user_id=7), AsyncChat) + assert isinstance(client.chat_message("msg-1", chat_id="chat-1", user_id=7), AsyncChatMessage) + assert isinstance(client.chat_webhook(), AsyncChatWebhook) + assert isinstance(client.chat_media(user_id=7), AsyncChatMedia) + assert isinstance(client.special_offer_campaign("camp-1"), AsyncSpecialOfferCampaign) + await client.aclose() + + +def test_async_client_messenger_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.chat() + with pytest.raises(RuntimeError): + client.chat_message() + with pytest.raises(RuntimeError): + client.chat_webhook() + with pytest.raises(RuntimeError): + client.chat_media() + with pytest.raises(RuntimeError): + client.special_offer_campaign() + + +@pytest.mark.asyncio +async def test_async_special_offer_stats_reject_invalid_datetime_before_transport() -> None: + fake = AsyncFakeTransport() + transport = fake.build() + campaign = AsyncSpecialOfferCampaign(transport, campaign_id="camp-1") + + with pytest.raises(ValidationError, match="date_time_from"): + await campaign.get_stats(date_time_from="not-a-date", date_time_to="2026-05-02T00:00:00Z") + + assert fake.count() == 0 + await transport.aclose() From c818c2671b4947fdaf92b99ff56491b5334e1739 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 20:57:50 +0300 Subject: [PATCH 18/26] M8 --- CHANGELOG.d/0000-async-jobs.md | 2 + avito/async_client.py | 32 + avito/jobs/__init__.py | 12 + avito/jobs/async_domain.py | 1225 +++++++++++++++++ .../contracts/test_async_swagger_contracts.py | 7 +- tests/domains/jobs/test_jobs_async.py | 326 +++++ 6 files changed, 1603 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.d/0000-async-jobs.md create mode 100644 avito/jobs/async_domain.py create mode 100644 tests/domains/jobs/test_jobs_async.py diff --git a/CHANGELOG.d/0000-async-jobs.md b/CHANGELOG.d/0000-async-jobs.md new file mode 100644 index 0000000..42082d2 --- /dev/null +++ b/CHANGELOG.d/0000-async-jobs.md @@ -0,0 +1,2 @@ +### Added +- Async-поддержка домена jobs: AsyncApplication, AsyncJobDictionary, AsyncJobWebhook, AsyncResume, AsyncVacancy (#0000) diff --git a/avito/async_client.py b/avito/async_client.py index 2abc314..3f34b14 100644 --- a/avito/async_client.py +++ b/avito/async_client.py @@ -21,6 +21,13 @@ AsyncCpaChat, AsyncCpaLead, ) +from avito.jobs import ( + AsyncApplication, + AsyncJobDictionary, + AsyncJobWebhook, + AsyncResume, + AsyncVacancy, +) from avito.messenger import ( AsyncChat, AsyncChatMedia, @@ -273,6 +280,31 @@ def special_offer_campaign( return AsyncSpecialOfferCampaign(self._require_transport(), campaign_id=campaign_id) + def vacancy(self, vacancy_id: int | str | None = None) -> AsyncVacancy: + """Создает async-доменный объект вакансии.""" + + return AsyncVacancy(self._require_transport(), vacancy_id=vacancy_id) + + def application(self) -> AsyncApplication: + """Создает async-доменный объект откликов.""" + + return AsyncApplication(self._require_transport()) + + def resume(self, resume_id: int | str | None = None) -> AsyncResume: + """Создает async-доменный объект резюме.""" + + return AsyncResume(self._require_transport(), resume_id=resume_id) + + def job_webhook(self) -> AsyncJobWebhook: + """Создает async-доменный объект webhook Авито Работы.""" + + return AsyncJobWebhook(self._require_transport()) + + def job_dictionary(self, dictionary_id: int | str | None = None) -> AsyncJobDictionary: + """Создает async-доменный объект справочника Авито Работы.""" + + return AsyncJobDictionary(self._require_transport(), dictionary_id=dictionary_id) + async def aclose(self) -> None: """Закрывает transport и auth-provider; повторный вызов безопасен.""" diff --git a/avito/jobs/__init__.py b/avito/jobs/__init__.py index 69a3491..ab0e1fe 100644 --- a/avito/jobs/__init__.py +++ b/avito/jobs/__init__.py @@ -1,5 +1,12 @@ """Пакет jobs.""" +from avito.jobs.async_domain import ( + AsyncApplication, + AsyncJobDictionary, + AsyncJobWebhook, + AsyncResume, + AsyncVacancy, +) from avito.jobs.domain import Application, JobDictionary, JobWebhook, Resume, Vacancy from avito.jobs.models import ( ApplicationActionRequest, @@ -49,6 +56,11 @@ "ApplicationStatesResult", "ApplicationViewedItem", "ApplicationViewedRequest", + "AsyncApplication", + "AsyncJobDictionary", + "AsyncJobWebhook", + "AsyncResume", + "AsyncVacancy", "JobActionResult", "JobActionStatus", "JobEnrichmentStatus", diff --git a/avito/jobs/async_domain.py b/avito/jobs/async_domain.py new file mode 100644 index 0000000..e342312 --- /dev/null +++ b/avito/jobs/async_domain.py @@ -0,0 +1,1225 @@ +"""Async-доменные объекты пакета jobs.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import DateInput, serialize_iso_datetime +from avito.jobs.models import ( + ApplicationActionRequest, + ApplicationIdsQuery, + ApplicationIdsRequest, + ApplicationIdsResult, + ApplicationsResult, + ApplicationStatesResult, + ApplicationViewedItem, + ApplicationViewedRequest, + ApplicationViewedRequestItem, + JobActionResult, + JobDictionariesResult, + JobDictionaryValuesResult, + JobWebhookInfo, + JobWebhooksResult, + JobWebhookUpdateRequest, + ResumeContactInfo, + ResumeInfo, + ResumeSearchQuery, + ResumesResult, + VacanciesQuery, + VacanciesResult, + VacancyArchiveRequest, + VacancyAutoRenewalRequest, + VacancyBillingTypeInput, + VacancyClassicCreateRequest, + VacancyClassicUpdateRequest, + VacancyCreateRequest, + VacancyEmploymentInput, + VacancyExperienceInput, + VacancyIdsRequest, + VacancyInfo, + VacancyProlongateRequest, + VacancyScheduleInput, + VacancyStatusesResult, + VacancyUpdateRequest, +) +from avito.jobs.operations import ( + APPLY_APPLICATION_ACTIONS, + ARCHIVE_VACANCY, + CREATE_VACANCY, + CREATE_VACANCY_CLASSIC, + DELETE_JOB_WEBHOOK, + GET_APPLICATION_IDS, + GET_APPLICATION_STATES, + GET_APPLICATIONS_BY_IDS, + GET_JOB_DICTIONARY, + GET_JOB_WEBHOOK, + GET_RESUME, + GET_RESUME_CONTACTS, + GET_VACANCIES_BY_IDS, + GET_VACANCY, + GET_VACANCY_STATUSES, + LIST_JOB_DICTIONARIES, + LIST_JOB_WEBHOOKS, + LIST_VACANCIES, + PROLONGATE_VACANCY, + SEARCH_RESUMES, + SET_APPLICATIONS_IS_VIEWED, + UPDATE_JOB_WEBHOOK, + UPDATE_VACANCY, + UPDATE_VACANCY_AUTO_RENEWAL, + UPDATE_VACANCY_CLASSIC, +) + + +@dataclass(slots=True, frozen=True) +class AsyncVacancy(AsyncDomainObject): + """Доменный объект вакансий.""" + + __swagger_domain__ = "jobs" + __sdk_factory__ = "vacancy" + __sdk_factory_args__ = {"vacancy_id": "path.vacancy_id"} + + vacancy_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/job/v2/vacancies", + spec="АвитоРабота.json", + operation_id="vacancyCreateV2", + method_args={"title": "body.title", "billing_type": "body.billing_type"}, + variant="async", + ) + async def create( + self, + *, + title: str, + billing_type: VacancyBillingTypeInput, + description: str | None = None, + business_area: int | None = None, + employment: VacancyEmploymentInput | None = None, + schedule: VacancyScheduleInput | None = None, + experience: VacancyExperienceInput | None = None, + version: int = 2, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Создает вакансию. + + Аргументы: + title: передает название вакансии. + billing_type: задает тип биллинга. + description: передает описание вакансии для legacy v1 operation. + business_area: задает сферу деятельности для legacy v1 operation. + employment: задает тип занятости для legacy v1 operation. + schedule: задает режим работы для legacy v1 operation. + experience: задает требуемый опыт для legacy v1 operation. + version: задает версию upstream-контракта, если операция ее поддерживает. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + if version == 1: + if ( + description is None + or business_area is None + or employment is None + or schedule is None + or experience is None + ): + raise ValidationError("Для создания вакансии v1 требуются поля Swagger.") + return await self.create_classic( + title=title, + description=description, + billing_type=billing_type, + business_area=business_area, + employment=employment, + schedule=schedule, + experience=experience, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return await self._execute( + CREATE_VACANCY, + request=VacancyCreateRequest(title=title, billing_type=billing_type), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/job/v1/vacancies", + spec="АвитоРабота.json", + operation_id="vacancyCreate", + method_args={ + "title": "body.name", + "description": "body.description", + "billing_type": "body.billing_type", + "business_area": "body.business_area", + "employment": "body.employment", + "schedule": "body.schedule.id", + "experience": "body.experience", + }, + variant="async", + ) + async def create_classic( + self, + *, + title: str, + description: str, + billing_type: VacancyBillingTypeInput, + business_area: int, + employment: VacancyEmploymentInput, + schedule: VacancyScheduleInput, + experience: VacancyExperienceInput, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Создает вакансию через legacy v1 operation. + + Аргументы: + title: передает название вакансии в Swagger поле `name`. + description: передает описание вакансии. + billing_type: задает тип биллинга. + business_area: задает сферу деятельности. + employment: задает тип занятости. + schedule: задает режим работы. + experience: задает требуемый опыт. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_VACANCY_CLASSIC, + request=VacancyClassicCreateRequest( + title=title, + description=description, + billing_type=billing_type, + business_area=business_area, + employment=employment, + schedule=schedule, + experience=experience, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/job/v2/vacancies/update/{vacancy_uuid}", + spec="АвитоРабота.json", + operation_id="vacancyUpdateV2", + method_args={"title": "body.title", "billing_type": "body.billing_type"}, + variant="async", + ) + async def update( + self, + *, + title: str, + billing_type: VacancyBillingTypeInput, + vacancy_id: int | str | None = None, + vacancy_uuid: str | None = None, + version: int = 2, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Обновляет вакансию. + + Аргументы: + title: передает название вакансии. + billing_type: задает тип биллинга. + vacancy_id: идентифицирует вакансию. + vacancy_uuid: идентифицирует вакансию по UUID. + version: задает версию upstream-контракта, если операция ее поддерживает. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + if version == 1: + return await self.update_classic( + vacancy_id=vacancy_id or self._require_vacancy_id(), + title=title, + billing_type=billing_type, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return await self._execute( + UPDATE_VACANCY, + path_params={"vacancy_uuid": vacancy_uuid or self._require_vacancy_id()}, + request=VacancyUpdateRequest(title=title, billing_type=billing_type), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "PUT", + "/job/v1/vacancies/{vacancy_id}", + spec="АвитоРабота.json", + operation_id="vacancyUpdate", + method_args={"title": "body.name", "billing_type": "body.billing_type"}, + variant="async", + ) + async def update_classic( + self, + *, + title: str, + billing_type: VacancyBillingTypeInput, + vacancy_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Обновляет вакансию через legacy v1 operation. + + Аргументы: + title: передает название вакансии в Swagger поле `name`. + billing_type: задает тип биллинга. + vacancy_id: идентифицирует вакансию. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_VACANCY_CLASSIC, + path_params={"vacancy_id": vacancy_id or self._require_vacancy_id()}, + request=VacancyClassicUpdateRequest(title=title, billing_type=billing_type), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "PUT", + "/job/v1/vacancies/archived/{vacancy_id}", + spec="АвитоРабота.json", + operation_id="vacancyArchive", + method_args={"employee_id": "body.employee_id"}, + variant="async", + ) + async def delete( + self, + *, + employee_id: int, + vacancy_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Удаляет вакансию. + + Аргументы: + employee_id: идентифицирует сотрудника аккаунта. + vacancy_id: идентифицирует вакансию. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + ARCHIVE_VACANCY, + path_params={"vacancy_id": vacancy_id or self._require_vacancy_id()}, + request=VacancyArchiveRequest(employee_id=employee_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/job/v1/vacancies/{vacancy_id}/prolongate", + spec="АвитоРабота.json", + operation_id="vacancyProlongate", + method_args={"billing_type": "body.billing_type"}, + variant="async", + ) + async def prolongate( + self, + *, + billing_type: VacancyBillingTypeInput, + vacancy_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Продлевает вакансий. + + Аргументы: + billing_type: задает тип биллинга для продления вакансии. + vacancy_id: идентифицирует вакансию. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + PROLONGATE_VACANCY, + path_params={"vacancy_id": vacancy_id or self._require_vacancy_id()}, + request=VacancyProlongateRequest(billing_type=billing_type), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/job/v2/vacancies", + spec="АвитоРабота.json", + operation_id="searchVacancy", + variant="async", + ) + async def list( + self, + *, + query: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VacanciesResult: + """Возвращает список вакансий. + + Аргументы: + query: передает поисковую строку или фильтр upstream API. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VacanciesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_VACANCIES, + query=VacanciesQuery(query=query), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/job/v2/vacancies/{vacancy_id}", + spec="АвитоРабота.json", + operation_id="vacancyGetItem", + variant="async", + ) + async def get( + self, + *, + vacancy_id: int | str | None = None, + query: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VacancyInfo: + """Возвращает вакансий. + + Аргументы: + vacancy_id: идентифицирует вакансию. + query: передает поисковую строку или фильтр upstream API. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VacancyInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_VACANCY, + path_params={"vacancy_id": vacancy_id or self._require_vacancy_id()}, + query=VacanciesQuery(query=query), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/job/v2/vacancies/batch", + spec="АвитоРабота.json", + operation_id="vacanciesGetByIds", + method_args={"ids": "body.ids"}, + variant="async", + ) + async def get_by_ids( + self, + *, + ids: Sequence[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VacanciesResult: + """Возвращает вакансий. + + Аргументы: + ids: передает идентификаторы объектов для пакетной операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VacanciesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_VACANCIES_BY_IDS, + request=VacancyIdsRequest(ids=list(ids)), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/job/v2/vacancies/statuses", + spec="АвитоРабота.json", + operation_id="vacancyGetStatuses", + method_args={"ids": "body.ids"}, + variant="async", + ) + async def get_statuses( + self, + *, + ids: Sequence[str], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VacancyStatusesResult: + """Возвращает statuses для вакансий. + + Аргументы: + ids: передает идентификаторы объектов для пакетной операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VacancyStatusesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_VACANCY_STATUSES, + request=VacancyIdsRequest(ids=list(ids)), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "PUT", + "/job/v2/vacancies/{vacancy_uuid}/auto_renewal", + spec="АвитоРабота.json", + operation_id="vacancyAutoRenewal", + method_args={"auto_renewal": "body.auto_renewal"}, + variant="async", + ) + async def update_auto_renewal( + self, + *, + auto_renewal: bool, + vacancy_uuid: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Обновляет настройку автопродления вакансии. + + Аргументы: + auto_renewal: включает или отключает автопродление вакансии. + vacancy_uuid: идентифицирует вакансию по UUID. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_VACANCY_AUTO_RENEWAL, + path_params={"vacancy_uuid": vacancy_uuid or self._require_vacancy_id()}, + request=VacancyAutoRenewalRequest(auto_renewal=auto_renewal), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_vacancy_id(self) -> str: + if self.vacancy_id is None: + raise ValidationError("Для операции требуется идентификатор вакансии.") + return str(self.vacancy_id) + + +@dataclass(slots=True, frozen=True) +class AsyncApplication(AsyncDomainObject): + """Доменный объект откликов.""" + + __swagger_domain__ = "jobs" + __sdk_factory__ = "application" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/job/v1/applications/apply_actions", + spec="АвитоРабота.json", + operation_id="applicationsApplyActions", + method_args={"ids": "body.ids", "action": "body.action"}, + variant="async", + ) + async def apply( + self, + *, + ids: Sequence[str], + action: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Применяет действие к откликов на вакансии. + + Аргументы: + ids: передает идентификаторы объектов для пакетной операции. + action: задает действие над откликами. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + APPLY_APPLICATION_ACTIONS, + request=ApplicationActionRequest(ids=list(ids), action=action), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/job/v1/applications/get_by_ids", + spec="АвитоРабота.json", + operation_id="applicationsGetByIds", + method_args={"ids": "body.ids"}, + variant="async", + ) + async def get_by_ids( + self, + *, + ids: Sequence[str], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ApplicationsResult: + """Возвращает отклики по идентификаторам и возвращает типизированную SDK-модель. + + Аргументы: + ids: передает идентификаторы откликов. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ApplicationsResult` со списком найденных откликов. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_APPLICATIONS_BY_IDS, + request=ApplicationIdsRequest(ids=list(ids)), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/job/v1/applications/get_ids", + spec="АвитоРабота.json", + operation_id="applicationsGetIds", + method_args={"updated_at_from": "query.updatedAtFrom"}, + variant="async", + ) + async def get_ids( + self, + *, + updated_at_from: DateInput, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ApplicationIdsResult: + """Возвращает идентификаторы откликов по фильтру и возвращает типизированную SDK-модель. + + Аргументы: + updated_at_from: фильтрует отклики по нижней границе даты обновления. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ApplicationIdsResult` со списком идентификаторов откликов. + + Поведение: + `updated_at_from` сериализуется в ISO datetime перед выполнением запроса. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_APPLICATION_IDS, + query=ApplicationIdsQuery( + updated_at_from=serialize_iso_datetime("updated_at_from", updated_at_from) + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/job/v1/applications/get_states", + spec="АвитоРабота.json", + operation_id="applicationsGetStates", + variant="async", + ) + async def get_states( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> ApplicationStatesResult: + """Возвращает states для откликов на вакансии. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ApplicationStatesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_APPLICATION_STATES, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/job/v1/applications/set_is_viewed", + spec="АвитоРабота.json", + operation_id="applicationsSetIsViewed", + method_args={"applies": "body.applies"}, + variant="async", + ) + async def update( + self, + *, + applies: Sequence[ApplicationViewedItem], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Обновляет отметки просмотра откликов на вакансии. + + Аргументы: + applies: передает список отметок просмотра откликов. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SET_APPLICATIONS_IS_VIEWED, + request=ApplicationViewedRequest( + applies=[ + ApplicationViewedRequestItem(id=item.id, is_viewed=item.is_viewed) + for item in applies + ] + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncResume(AsyncDomainObject): + """Доменный объект резюме.""" + + __swagger_domain__ = "jobs" + __sdk_factory__ = "resume" + __sdk_factory_args__ = {"resume_id": "path.resume_id"} + + resume_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/job/v1/resumes", + spec="АвитоРабота.json", + operation_id="resumesGet", + variant="async", + ) + async def list( + self, + *, + query: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResumesResult: + """Возвращает список резюме. + + Аргументы: + query: передает поисковую строку или фильтр upstream API. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ResumesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SEARCH_RESUMES, + query=ResumeSearchQuery(query=query) if query is not None else None, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/job/v2/resumes/{resume_id}", + spec="АвитоРабота.json", + operation_id="resumeGetItem", + variant="async", + ) + async def get( + self, + *, + resume_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResumeInfo: + """Возвращает резюме. + + Аргументы: + resume_id: идентифицирует резюме. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ResumeInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_RESUME, + path_params={"resume_id": str(resume_id or self._require_resume_id())}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/job/v1/resumes/{resume_id}/contacts", + spec="АвитоРабота.json", + operation_id="resumeGetContacts", + variant="async", + ) + async def get_contacts( + self, + *, + resume_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResumeContactInfo: + """Возвращает contacts для резюме. + + Аргументы: + resume_id: идентифицирует резюме. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ResumeContactInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_RESUME_CONTACTS, + path_params={"resume_id": str(resume_id or self._require_resume_id())}, + timeout=timeout, + retry=retry, + ) + + def _require_resume_id(self) -> str: + if self.resume_id is None: + raise ValidationError("Для операции требуется `resume_id`.") + return str(self.resume_id) + + +@dataclass(slots=True, frozen=True) +class AsyncJobWebhook(AsyncDomainObject): + """Доменный объект webhook откликов.""" + + __swagger_domain__ = "jobs" + __sdk_factory__ = "job_webhook" + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/job/v1/applications/webhook", + spec="АвитоРабота.json", + operation_id="applicationsWebhookGet", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> JobWebhookInfo: + """Возвращает webhook-уведомлений Авито Работы. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobWebhookInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_JOB_WEBHOOK, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/job/v1/applications/webhooks", + spec="АвитоРабота.json", + operation_id="applicationsWebhooksGet", + variant="async", + ) + async def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> JobWebhooksResult: + """Возвращает список webhook-уведомлений Авито Работы. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobWebhooksResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(LIST_JOB_WEBHOOKS, timeout=timeout, retry=retry) + + @swagger_operation( + "PUT", + "/job/v1/applications/webhook", + spec="АвитоРабота.json", + operation_id="applicationsWebhookPut", + method_args={"url": "body.url", "secret": "body.secret"}, + variant="async", + ) + async def update( + self, + *, + url: str, + secret: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobWebhookInfo: + """Обновляет webhook-уведомление Авито Работы. + + Аргументы: + url: задает URL webhook-подписки. + secret: задает секрет webhook-подписки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobWebhookInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_JOB_WEBHOOK, + request=JobWebhookUpdateRequest(url=url, secret=secret), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "DELETE", + "/job/v1/applications/webhook", + spec="АвитоРабота.json", + operation_id="applicationsWebhookDelete", + variant="async", + ) + async def delete( + self, + *, + url: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Удаляет webhook-уведомление Авито Работы. + + Аргументы: + url: задает URL webhook-подписки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELETE_JOB_WEBHOOK, + query={"url": url} if url is not None else None, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncJobDictionary(AsyncDomainObject): + """Доменный объект словарей вакансий.""" + + __swagger_domain__ = "jobs" + __sdk_factory__ = "job_dictionary" + __sdk_factory_args__ = {"dictionary_id": "path.dictionary_id"} + + dictionary_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/job/v2/vacancy/dict", + spec="АвитоРабота.json", + operation_id="getDicts", + variant="async", + ) + async def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> JobDictionariesResult: + """Возвращает список справочников Авито Работы. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobDictionariesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(LIST_JOB_DICTIONARIES, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/job/v2/vacancy/dict/{dictionary_id}", + spec="АвитоРабота.json", + operation_id="getDictByID", + variant="async", + ) + async def get( + self, + *, + dictionary_id: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobDictionaryValuesResult: + """Возвращает справочников Авито Работы. + + Аргументы: + dictionary_id: идентифицирует справочник. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobDictionaryValuesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_JOB_DICTIONARY, + path_params={"dictionary_id": dictionary_id or self._require_dictionary_id()}, + timeout=timeout, + retry=retry, + ) + + def _require_dictionary_id(self) -> str: + if self.dictionary_id is None: + raise ValidationError("Для операции требуется `dictionary_id`.") + return str(self.dictionary_id) + + +__all__ = ( + "AsyncApplication", + "AsyncJobDictionary", + "AsyncJobWebhook", + "AsyncResume", + "AsyncVacancy", +) diff --git a/tests/contracts/test_async_swagger_contracts.py b/tests/contracts/test_async_swagger_contracts.py index e0ca3d5..3d6ef49 100644 --- a/tests/contracts/test_async_swagger_contracts.py +++ b/tests/contracts/test_async_swagger_contracts.py @@ -21,6 +21,11 @@ def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: "AsyncCpaCall", "AsyncCpaChat", "AsyncCpaLead", + "AsyncApplication", + "AsyncJobDictionary", + "AsyncJobWebhook", + "AsyncResume", + "AsyncVacancy", "AsyncRatingProfile", "AsyncRealtyAnalyticsReport", "AsyncRealtyBooking", @@ -32,4 +37,4 @@ def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: "AsyncTariff", "AsyncTokenClient", } - assert len(async_bindings) == 56 + assert len(async_bindings) == 81 diff --git a/tests/domains/jobs/test_jobs_async.py b/tests/domains/jobs/test_jobs_async.py new file mode 100644 index 0000000..0bc3671 --- /dev/null +++ b/tests/domains/jobs/test_jobs_async.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core import ValidationError +from avito.jobs import ( + AsyncApplication, + AsyncJobDictionary, + AsyncJobWebhook, + AsyncResume, + AsyncVacancy, +) +from avito.jobs.models import ( + ApplicationViewedItem, + VacancyBillingType, + VacancyEmployment, + VacancyExperience, + VacancySchedule, +) +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_application_webhook_and_resume_flows() -> None: + fake = ( + AsyncFakeTransport() + .add_json( + "GET", + "/job/v1/applications/get_ids", + { + "items": [{"id": "app-1", "updatedAt": "2026-04-18T10:00:00+03:00"}], + "cursor": "app-1", + }, + ) + .add_json( + "POST", + "/job/v1/applications/get_by_ids", + { + "applies": [ + { + "id": "app-1", + "vacancy_id": 101, + "state": "new", + "is_viewed": False, + "applicant": {"name": "Иван"}, + } + ] + }, + ) + .add_json( + "GET", + "/job/v1/applications/get_states", + {"states": [{"slug": "new", "description": "Новый отклик"}]}, + ) + .add_json("POST", "/job/v1/applications/set_is_viewed", {"ok": True, "status": "viewed"}) + .add_json( + "POST", + "/job/v1/applications/apply_actions", + {"ok": True, "status": "invited"}, + ) + .add_json( + "GET", + "/job/v1/applications/webhook", + {"url": "https://example.com/job", "is_active": True, "version": "v1"}, + ) + .add_json( + "PUT", + "/job/v1/applications/webhook", + {"url": "https://example.com/job", "is_active": True, "version": "v1"}, + ) + .add_json("DELETE", "/job/v1/applications/webhook", {"ok": True}) + .add_json( + "GET", + "/job/v1/applications/webhooks", + [{"url": "https://example.com/job", "is_active": True, "version": "v1"}], + ) + .add_json( + "GET", + "/job/v1/resumes/", + { + "meta": {"cursor": "2", "total": 1}, + "resumes": [ + { + "id": "res-1", + "title": "Оператор call-центра", + "name": "Петр", + "location": "Москва", + "salary": 90000, + } + ], + }, + ) + .add_json( + "GET", + "/job/v1/resumes/res-1/contacts/", + {"name": "Петр", "phone": "+79990000000", "email": "petr@example.com"}, + ) + .add_json( + "GET", + "/job/v2/resumes/res-1", + { + "id": "res-1", + "title": "Оператор call-центра", + "fullName": "Петр Петров", + "address_details": {"location": "Москва"}, + "salary": {"from": 90000}, + }, + ) + ) + transport = fake.build() + application = AsyncApplication(transport) + webhook = AsyncJobWebhook(transport) + resume = AsyncResume(transport, resume_id="res-1") + + assert (await application.get_ids(updated_at_from="2026-04-18")).items[0].id == "app-1" + assert (await application.get_by_ids(ids=["app-1"])).items[0].applicant_name == "Иван" + assert (await application.get_states()).items[0].slug == "new" + assert ( + await application.update(applies=[ApplicationViewedItem(id="app-1", is_viewed=True)]) + ).status == "viewed" + assert (await application.apply(ids=["app-1"], action="invited")).status == "invited" + assert (await webhook.get()).url == "https://example.com/job" + assert ( + await webhook.update( + url="https://example.com/job", + secret="cb1e150b-c5bf-4c3e-acd1-20ec88bdb3a1", + idempotency_key="idem-webhook", + ) + ).is_active is True + assert (await webhook.delete(url="https://example.com/job")).success is True + assert (await webhook.list()).items[0].version == "v1" + assert (await resume.list(query="оператор")).items[0].candidate_name == "Петр" + assert (await resume.get_contacts()).phone == "+79990000000" + assert (await resume.get()).location == "Москва" + assert ( + fake.last(method="PUT", path="/job/v1/applications/webhook").headers["idempotency-key"] + == "idem-webhook" + ) + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_vacancy_and_dictionary_flows() -> None: + def update_auto_renewal(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"auto_renewal": True} + assert request.headers["idempotency-key"] == "idem-auto-renewal" + return httpx.Response(200, json={"ok": True, "status": "auto-renewal-updated"}) + + fake = ( + AsyncFakeTransport() + .add_json("POST", "/job/v1/vacancies", {"id": 101, "status": "created"}, status_code=201) + .add_json("PUT", "/job/v1/vacancies/101", {"ok": True, "status": "updated"}) + .add_json( + "PUT", + "/job/v1/vacancies/archived/101", + {"ok": True, "status": "archived"}, + ) + .add_json( + "POST", + "/job/v1/vacancies/101/prolongate", + {"ok": True, "status": "prolongated"}, + ) + .add_json( + "GET", + "/job/v2/vacancies", + { + "vacancies": [ + {"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"} + ], + "total": 1, + }, + ) + .add_json( + "POST", + "/job/v2/vacancies", + {"vacancy_uuid": "vac-uuid-1", "status": "created"}, + status_code=202, + ) + .add_json( + "POST", + "/job/v2/vacancies/batch", + { + "vacancies": [ + {"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"} + ] + }, + ) + .add_json( + "POST", + "/job/v2/vacancies/statuses", + {"items": [{"id": 101, "uuid": "vac-uuid-1", "status": "active"}]}, + ) + .add_json( + "POST", + "/job/v2/vacancies/update/vac-uuid-1", + {"vacancy_uuid": "vac-uuid-1", "status": "updated"}, + status_code=202, + ) + .add_json( + "GET", + "/job/v2/vacancies/101", + { + "id": 101, + "uuid": "vac-uuid-1", + "title": "Продавец", + "status": "active", + "url": "https://avito.ru/vacancy/101", + }, + ) + .add("PUT", "/job/v2/vacancies/vac-uuid-1/auto_renewal", update_auto_renewal) + .add_json("GET", "/job/v2/vacancy/dict", [{"id": "profession", "description": "Профессия"}]) + .add_json( + "GET", + "/job/v2/vacancy/dict/profession", + [{"id": 10106, "name": "IT, интернет, телеком", "deprecated": True}], + ) + ) + transport = fake.build() + vacancy = AsyncVacancy(transport, vacancy_id="101") + dictionary = AsyncJobDictionary(transport, dictionary_id="profession") + + assert ( + await vacancy.create( + title="Продавец", + billing_type=VacancyBillingType.PACKAGE, + description="Описание вакансии", + business_area=7, + employment=VacancyEmployment.FULL, + schedule=VacancySchedule.FIXED, + experience=VacancyExperience.NO_MATTER, + version=1, + ) + ).id == "101" + assert ( + await vacancy.update(title="Старший продавец", billing_type="package", version=1) + ).status == "updated" + assert (await vacancy.delete(employee_id=7)).status == "archived" + assert (await vacancy.prolongate(billing_type="package")).status == "prolongated" + assert (await vacancy.list()).items[0].uuid == "vac-uuid-1" + assert ( + await vacancy.create(title="Вакансия v2", billing_type=VacancyBillingType.PACKAGE) + ).id == "vac-uuid-1" + assert (await vacancy.get_by_ids(ids=[101])).items[0].title == "Продавец" + assert (await vacancy.get_statuses(ids=["vac-uuid-1"])).items[0].status == "active" + assert ( + await vacancy.update( + title="Вакансия v2 updated", + billing_type="package", + version=2, + vacancy_uuid="vac-uuid-1", + ) + ).status == "updated" + assert (await vacancy.get()).url == "https://avito.ru/vacancy/101" + assert ( + await vacancy.update_auto_renewal( + auto_renewal=True, + vacancy_uuid="vac-uuid-1", + idempotency_key="idem-auto-renewal", + ) + ).status == "auto-renewal-updated" + assert (await dictionary.list()).items[0].id == "profession" + assert (await dictionary.get()).items[0].deprecated is True + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_application_rejects_invalid_updated_at_before_transport() -> None: + fake = AsyncFakeTransport() + transport = fake.build() + application = AsyncApplication(transport) + + with pytest.raises(ValidationError, match="updated_at_from"): + await application.get_ids(updated_at_from="18-04-2026") + + assert fake.count() == 0 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_vacancy_rejects_unknown_closed_values_before_transport() -> None: + fake = AsyncFakeTransport() + transport = fake.build() + vacancy = AsyncVacancy(transport) + + with pytest.raises(ValidationError, match="billing_type"): + await vacancy.create(title="Вакансия", billing_type="unknown") + with pytest.raises(ValidationError, match="employment"): + await vacancy.create( + title="Вакансия", + billing_type=VacancyBillingType.PACKAGE, + description="Описание", + business_area=7, + employment="unknown", + schedule=VacancySchedule.FIXED, + experience=VacancyExperience.NO_MATTER, + version=1, + ) + + assert fake.count() == 0 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_jobs_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client() + + assert isinstance(client.vacancy("101"), AsyncVacancy) + assert isinstance(client.application(), AsyncApplication) + assert isinstance(client.resume("res-1"), AsyncResume) + assert isinstance(client.job_webhook(), AsyncJobWebhook) + assert isinstance(client.job_dictionary("profession"), AsyncJobDictionary) + await client.aclose() + + +def test_async_client_jobs_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError, match="async with"): + client.vacancy("101") From 346b0c243d0d16cd13845aabbc2016206a20ce9f Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 21:06:48 +0300 Subject: [PATCH 19/26] M9 --- CHANGELOG.d/0000-async-promotion.md | 2 + avito/async_client.py | 40 + avito/promotion/__init__.py | 14 + avito/promotion/async_domain.py | 1461 +++++++++++++++++ .../contracts/test_async_swagger_contracts.py | 8 +- .../domains/promotion/test_promotion_async.py | 486 ++++++ 6 files changed, 2010 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.d/0000-async-promotion.md create mode 100644 avito/promotion/async_domain.py create mode 100644 tests/domains/promotion/test_promotion_async.py diff --git a/CHANGELOG.d/0000-async-promotion.md b/CHANGELOG.d/0000-async-promotion.md new file mode 100644 index 0000000..c70a60f --- /dev/null +++ b/CHANGELOG.d/0000-async-promotion.md @@ -0,0 +1,2 @@ +### Added +- Async-поддержка домена promotion: AsyncPromotionOrder, AsyncBbipPromotion, AsyncTrxPromotion, AsyncCpaAuction, AsyncTargetActionPricing, AsyncAutostrategyCampaign. diff --git a/avito/async_client.py b/avito/async_client.py index 3f34b14..b73af3e 100644 --- a/avito/async_client.py +++ b/avito/async_client.py @@ -35,6 +35,14 @@ AsyncChatWebhook, AsyncSpecialOfferCampaign, ) +from avito.promotion import ( + AsyncAutostrategyCampaign, + AsyncBbipPromotion, + AsyncCpaAuction, + AsyncPromotionOrder, + AsyncTargetActionPricing, + AsyncTrxPromotion, +) from avito.ratings import AsyncRatingProfile, AsyncReview, AsyncReviewAnswer from avito.realty import ( AsyncRealtyAnalyticsReport, @@ -305,6 +313,38 @@ def job_dictionary(self, dictionary_id: int | str | None = None) -> AsyncJobDict return AsyncJobDictionary(self._require_transport(), dictionary_id=dictionary_id) + def promotion_order(self, order_id: int | str | None = None) -> AsyncPromotionOrder: + """Создает async-доменный объект заявок promotion.""" + + return AsyncPromotionOrder(self._require_transport(), order_id=order_id) + + def bbip_promotion(self, item_id: int | str | None = None) -> AsyncBbipPromotion: + """Создает async-доменный объект BBIP-продвижения.""" + + return AsyncBbipPromotion(self._require_transport(), item_id=item_id) + + def trx_promotion(self, item_id: int | str | None = None) -> AsyncTrxPromotion: + """Создает async-доменный объект TrxPromo.""" + + return AsyncTrxPromotion(self._require_transport(), item_id=item_id) + + def cpa_auction(self, item_id: int | str | None = None) -> AsyncCpaAuction: + """Создает async-доменный объект CPA-аукциона.""" + + return AsyncCpaAuction(self._require_transport(), item_id=item_id) + + def target_action_pricing(self, item_id: int | str | None = None) -> AsyncTargetActionPricing: + """Создает async-доменный объект цены целевого действия.""" + + return AsyncTargetActionPricing(self._require_transport(), item_id=item_id) + + def autostrategy_campaign( + self, campaign_id: int | str | None = None + ) -> AsyncAutostrategyCampaign: + """Создает async-доменный объект кампании автостратегии.""" + + return AsyncAutostrategyCampaign(self._require_transport(), campaign_id=campaign_id) + async def aclose(self) -> None: """Закрывает transport и auth-provider; повторный вызов безопасен.""" diff --git a/avito/promotion/__init__.py b/avito/promotion/__init__.py index 6c14cbe..2e393dc 100644 --- a/avito/promotion/__init__.py +++ b/avito/promotion/__init__.py @@ -1,5 +1,13 @@ """Пакет promotion.""" +from avito.promotion.async_domain import ( + AsyncAutostrategyCampaign, + AsyncBbipPromotion, + AsyncCpaAuction, + AsyncPromotionOrder, + AsyncTargetActionPricing, + AsyncTrxPromotion, +) from avito.promotion.domain import ( AutostrategyCampaign, BbipPromotion, @@ -63,6 +71,12 @@ ) __all__ = ( + "AsyncAutostrategyCampaign", + "AsyncBbipPromotion", + "AsyncCpaAuction", + "AsyncPromotionOrder", + "AsyncTargetActionPricing", + "AsyncTrxPromotion", "AutostrategyBudget", "AutostrategyCampaign", "AutostrategyStat", diff --git a/avito/promotion/async_domain.py b/avito/promotion/async_domain.py new file mode 100644 index 0000000..aed7229 --- /dev/null +++ b/avito/promotion/async_domain.py @@ -0,0 +1,1461 @@ +"""Async-доменные объекты пакета promotion.""" + +from __future__ import annotations + +import builtins +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import ( + validate_non_empty, + validate_non_empty_string, + validate_positive_int, +) +from avito.promotion.models import ( + AutostrategyBudget, + AutostrategyStat, + BbipForecastsResult, + BbipItem, + BbipSuggestsResult, + CampaignActionResult, + CampaignDetailsResult, + CampaignListFilter, + CampaignOrderBy, + CampaignsResult, + CampaignType, + CampaignUpdateTimeFilter, + CancelTrxPromotionRequest, + CpaAuctionBidInput, + CpaAuctionBidsResult, + CreateAutostrategyBudgetRequest, + CreateAutostrategyCampaignRequest, + CreateBbipForecastsRequest, + CreateBbipOrderRequest, + CreateBbipSuggestsRequest, + CreateItemBid, + CreateItemBidsRequest, + CreateTrxPromotionApplyRequest, + DeletePromotionRequest, + GetAutostrategyCampaignInfoRequest, + GetAutostrategyStatRequest, + GetPromotionOrderStatusRequest, + GetPromotionsByItemIdsRequest, + GetTrxCommissionsRequest, + ListAutostrategyCampaignsRequest, + ListPromotionOrdersRequest, + ListPromotionServicesRequest, + PromotionActionResult, + PromotionOrdersResult, + PromotionOrderStatusResult, + PromotionServiceDictionary, + PromotionServicesResult, + PromotionStatus, + StopAutostrategyCampaignRequest, + TargetActionBudgetType, + TargetActionGetBidsResult, + TargetActionPromotionsByItemIdsResult, + TrxCommissionsResult, + TrxItem, + UpdateAutoBidRequest, + UpdateAutostrategyCampaignRequest, + UpdateManualBidRequest, +) +from avito.promotion.operations import ( + APPLY_TRX, + CANCEL_TRX, + CREATE_AUTOSTRATEGY_BUDGET, + CREATE_AUTOSTRATEGY_CAMPAIGN, + CREATE_BBIP_ORDER, + CREATE_CPA_AUCTION_BIDS, + DELETE_AUTOSTRATEGY_CAMPAIGN, + DELETE_TARGET_ACTION_PROMOTION, + GET_AUTOSTRATEGY_CAMPAIGN, + GET_AUTOSTRATEGY_STAT, + GET_BBIP_FORECASTS, + GET_BBIP_SUGGESTS, + GET_CPA_AUCTION_BIDS, + GET_ORDER_STATUS, + GET_SERVICE_DICTIONARY, + GET_TARGET_ACTION_BIDS, + GET_TARGET_ACTION_PROMOTIONS, + GET_TRX_COMMISSIONS, + LIST_AUTOSTRATEGY_CAMPAIGNS, + LIST_ORDERS, + LIST_SERVICES, + TRX_HEADERS, + UPDATE_AUTOSTRATEGY_CAMPAIGN, + UPDATE_TARGET_ACTION_AUTO, + UPDATE_TARGET_ACTION_MANUAL, +) + + +def _preview_result( + *, + action: str, + target: Mapping[str, object], + request_payload: Mapping[str, object], +) -> PromotionActionResult: + return PromotionActionResult( + action=action, + target=dict(target), + status=PromotionStatus.PREVIEW, + applied=False, + request_payload=dict(request_payload), + details={"validated": True}, + ) + + +def _validate_optional_datetime(name: str, value: datetime | None) -> None: + if value is not None and not isinstance(value, datetime): + raise ValidationError(f"`{name}` должен быть datetime.") + + +@dataclass(slots=True, frozen=True) +class AsyncPromotionOrder(AsyncDomainObject): + """Доменный объект заявок и словарей promotion API.""" + + __swagger_domain__ = "promotion" + __sdk_factory__ = "promotion_order" + __sdk_factory_args__ = {"order_id": "path.order_id"} + + order_id: int | str | None = None + + @swagger_operation( + "POST", + "/promotion/v1/items/services/dict", + spec="Продвижение.json", + operation_id="get_dict_of_services_v1", + variant="async", + ) + async def get_service_dictionary( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> PromotionServiceDictionary: + """Получает словарь услуг продвижения. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionServiceDictionary` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_SERVICE_DICTIONARY, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/promotion/v1/items/services/get", + spec="Продвижение.json", + operation_id="get_services_by_items_v1", + method_args={"item_ids": "body.item_ids"}, + variant="async", + ) + async def list_services( + self, + *, + item_ids: list[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionServicesResult: + """Возвращает доступные услуги продвижения для объявлений. + + Аргументы: + item_ids: передает идентификаторы объявлений или товаров. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionServicesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_SERVICES, + request=ListPromotionServicesRequest(item_ids=item_ids), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/promotion/v1/items/services/orders/get", + spec="Продвижение.json", + operation_id="list_orders_by_user_v1", + variant="async", + ) + async def list_orders( + self, + *, + item_ids: list[int] | None = None, + order_ids: list[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionOrdersResult: + """Возвращает заказы продвижения по объявлениям или идентификаторам заказов. + + Аргументы: + item_ids: передает идентификаторы объявлений или товаров. + order_ids: передает идентификаторы заказов. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionOrdersResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_ORDERS, + request=ListPromotionOrdersRequest(item_ids=item_ids, order_ids=order_ids), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/promotion/v1/items/services/orders/status", + spec="Продвижение.json", + operation_id="get_order_status_v1", + variant="async", + ) + async def get_order_status( + self, + *, + order_ids: list[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionOrderStatusResult: + """Получает статусы заявок на продвижение. + + Аргументы: + order_ids: идентификаторы заказов продвижения. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionOrderStatusResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_order_ids = order_ids or ( + [str(self.order_id)] if self.order_id is not None else [] + ) + if not resolved_order_ids: + raise ValidationError("Для операции требуется хотя бы один `order_id`.") + return await self._execute( + GET_ORDER_STATUS, + request=GetPromotionOrderStatusRequest(order_ids=resolved_order_ids), + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncBbipPromotion(AsyncDomainObject): + """Доменный объект BBIP-продвижения.""" + + __swagger_domain__ = "promotion" + __sdk_factory__ = "bbip_promotion" + __sdk_factory_args__ = {"item_id": "path.item_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/promotion/v1/items/services/bbip/forecasts/get", + spec="Продвижение.json", + operation_id="get_bbip_forecasts_by_items_v1", + method_args={"items": "body.items"}, + variant="async", + ) + async def get_forecasts( + self, + *, + items: list[BbipItem], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> BbipForecastsResult: + """Получает прогнозы BBIP. + + Аргументы: + items: элементы запроса с объявлениями, ставками или настройками продвижения. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `BbipForecastsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_BBIP_FORECASTS, + request=CreateBbipForecastsRequest(items=list(items)), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "PUT", + "/promotion/v1/items/services/bbip/orders/create", + spec="Продвижение.json", + operation_id="create_bbip_order_for_items_v1", + method_args={"items": "body.items"}, + variant="async", + ) + async def create_order( + self, + *, + items: list[BbipItem], + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Подключает BBIP-продвижение. + + Аргументы: + items: элементы запроса с объявлениями, ставками или настройками продвижения. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + validate_non_empty("items", items) + for index, item in enumerate(items): + validate_positive_int(f"items[{index}].item_id", item.item_id) + validate_positive_int(f"items[{index}].duration", item.duration) + validate_positive_int(f"items[{index}].price", item.price) + validate_positive_int(f"items[{index}].old_price", item.old_price) + bbip_items = list(items) + request_payload = CreateBbipOrderRequest(items=bbip_items).to_payload() + target: dict[str, object] = {"item_ids": [item.item_id for item in items]} + if dry_run: + return _preview_result( + action="create_order", + target=target, + request_payload=request_payload, + ) + payload = await self._execute( + CREATE_BBIP_ORDER, + request=CreateBbipOrderRequest(items=bbip_items), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="create_order", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "POST", + "/promotion/v1/items/services/bbip/suggests/get", + spec="Продвижение.json", + operation_id="get_bbip_suggests_by_items_v1", + variant="async", + ) + async def get_suggests( + self, + *, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> BbipSuggestsResult: + """Получает варианты бюджета BBIP. + + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `BbipSuggestsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_ids = item_ids or self._resource_item_ids() + return await self._execute( + GET_BBIP_SUGGESTS, + request=CreateBbipSuggestsRequest(item_ids=resolved_item_ids), + timeout=timeout, + retry=retry, + ) + + def _resource_item_ids(self) -> list[int]: + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id` или список `item_ids`.") + return [int(self.item_id)] + + +@dataclass(slots=True, frozen=True) +class AsyncTrxPromotion(AsyncDomainObject): + """Доменный объект TrxPromo.""" + + __swagger_domain__ = "promotion" + __sdk_factory__ = "trx_promotion" + __sdk_factory_args__ = {"item_id": "path.item_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/trx-promo/1/apply", + spec="TrxPromo.json", + operation_id="api_trx_promo_open_api_apply", + method_args={"items": "body.items"}, + variant="async", + ) + async def apply( + self, + *, + items: list[TrxItem], + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Запускает TrxPromo. + + Аргументы: + items: элементы запроса с объявлениями, ставками или настройками продвижения. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + validate_non_empty("items", items) + for index, item in enumerate(items): + validate_positive_int(f"items[{index}].item_id", item.item_id) + validate_positive_int(f"items[{index}].commission", item.commission) + if not isinstance(item.date_from, datetime): + raise ValidationError(f"items[{index}].date_from должен быть datetime.") + trx_items = list(items) + request_payload = CreateTrxPromotionApplyRequest(items=trx_items).to_payload() + target: dict[str, object] = {"item_ids": [item.item_id for item in items]} + if dry_run: + return _preview_result(action="apply", target=target, request_payload=request_payload) + payload = await self._execute( + APPLY_TRX, + request=CreateTrxPromotionApplyRequest(items=trx_items), + headers=TRX_HEADERS, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="apply", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "POST", + "/trx-promo/1/cancel", + spec="TrxPromo.json", + operation_id="api_trx_promo_open_api_cancel", + method_args={"item_ids": "body.itemIDs"}, + variant="async", + ) + async def delete( + self, + *, + item_ids: list[int] | None = None, + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Останавливает TrxPromo. + + Аргументы: + item_ids: список идентификаторов объявлений. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_ids = item_ids or self._resource_item_ids() + validate_non_empty("item_ids", resolved_item_ids) + request_payload = CancelTrxPromotionRequest(item_ids=resolved_item_ids).to_payload() + target = {"item_ids": list(resolved_item_ids)} + if dry_run: + return _preview_result(action="delete", target=target, request_payload=request_payload) + payload = await self._execute( + CANCEL_TRX, + request=CancelTrxPromotionRequest(item_ids=resolved_item_ids), + headers=TRX_HEADERS, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="delete", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "GET", + "/trx-promo/1/commissions", + spec="TrxPromo.json", + operation_id="api_trx_promo_open_api_commissions", + method_args={"item_ids": "body.item_ids"}, + variant="async", + ) + async def get_commissions( + self, + *, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> TrxCommissionsResult: + """Получает доступные комиссии TrxPromo. + + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TrxCommissionsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_ids = item_ids or self._resource_item_ids() + return await self._execute( + GET_TRX_COMMISSIONS, + request=GetTrxCommissionsRequest(item_ids=resolved_item_ids), + headers=TRX_HEADERS, + timeout=timeout, + retry=retry, + ) + + def _resource_item_ids(self) -> list[int]: + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id` или список `item_ids`.") + return [int(self.item_id)] + + +@dataclass(slots=True, frozen=True) +class AsyncCpaAuction(AsyncDomainObject): + """Доменный объект CPA-аукциона.""" + + __swagger_domain__ = "promotion" + __sdk_factory__ = "cpa_auction" + __sdk_factory_args__ = {"item_id": "path.item_id"} + + item_id: int | str | None = None + + @swagger_operation( + "GET", + "/auction/1/bids", + spec="CPA-аукцион.json", + operation_id="getUserBids", + variant="async", + ) + async def get_user_bids( + self, + *, + from_item_id: int | None = None, + batch_size: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaAuctionBidsResult: + """Получает действующие и доступные ставки. + + Аргументы: + from_item_id: идентификатор объявления, с которого начинается выборка. + batch_size: размер пакетной выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaAuctionBidsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CPA_AUCTION_BIDS, + query={"fromItemID": from_item_id, "batchSize": batch_size}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/auction/1/bids", + spec="CPA-аукцион.json", + operation_id="saveItemBids", + method_args={"items": "body.items"}, + variant="async", + ) + async def create_item_bids( + self, + *, + items: list[CpaAuctionBidInput], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Сохраняет новые ставки по объявлениям. + + Аргументы: + items: элементы запроса с объявлениями, ставками или настройками продвижения. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + bids = [CreateItemBid(item_id=item.item_id, price_penny=item.price_penny) for item in items] + request = CreateItemBidsRequest(items=bids) + payload = await self._execute( + CREATE_CPA_AUCTION_BIDS, + request=request, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="create_item_bids", + target={"item_ids": [item.item_id for item in items]}, + request_payload=request.to_payload(), + ) + + +@dataclass(slots=True, frozen=True) +class AsyncTargetActionPricing(AsyncDomainObject): + """Доменный объект цены целевого действия.""" + + __swagger_domain__ = "promotion" + __sdk_factory__ = "target_action_pricing" + __sdk_factory_args__ = {"item_id": "path.item_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/cpxpromo/1/getBids/{itemId}", + spec="Настройкаценыцелевогодействия.json", + operation_id="getBids", + variant="async", + ) + async def get_bids( + self, + *, + item_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> TargetActionGetBidsResult: + """Получает детализированные цены и бюджеты. + + Аргументы: + item_id: идентификатор объявления. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TargetActionGetBidsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_TARGET_ACTION_BIDS, + path_params={"itemId": item_id or self._require_item_id()}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpxpromo/1/getPromotionsByItemIds", + spec="Настройкаценыцелевогодействия.json", + operation_id="getPromotionsByItemIds", + variant="async", + ) + async def get_promotions_by_item_ids( + self, + *, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> TargetActionPromotionsByItemIdsResult: + """Получает текущие настройки по нескольким объявлениям. + + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TargetActionPromotionsByItemIdsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_ids = item_ids or [self._require_item_id()] + return await self._execute( + GET_TARGET_ACTION_PROMOTIONS, + request=GetPromotionsByItemIdsRequest(item_ids=resolved_item_ids), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpxpromo/1/remove", + spec="Настройкаценыцелевогодействия.json", + operation_id="removePromotion", + variant="async", + ) + async def delete( + self, + *, + item_id: int | None = None, + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Останавливает продвижение. + + Аргументы: + item_id: идентификатор объявления. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_id = item_id or self._require_item_id() + validate_positive_int("item_id", resolved_item_id) + request_payload = DeletePromotionRequest(item_id=resolved_item_id).to_payload() + target = {"item_id": resolved_item_id} + if dry_run: + return _preview_result(action="delete", target=target, request_payload=request_payload) + payload = await self._execute( + DELETE_TARGET_ACTION_PROMOTION, + request=DeletePromotionRequest(item_id=resolved_item_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="delete", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "POST", + "/cpxpromo/1/setAuto", + spec="Настройкаценыцелевогодействия.json", + operation_id="saveAutoBid", + method_args={ + "action_type_id": "body.action_type_id", + "budget_penny": "body.budget_penny", + "budget_type": "body.budget_type", + }, + variant="async", + ) + async def update_auto( + self, + *, + action_type_id: int, + budget_penny: int, + budget_type: TargetActionBudgetType | str, + item_id: int | None = None, + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Применяет автоматическую настройку. + + Аргументы: + action_type_id: идентификатор целевого действия. + budget_penny: бюджет в копейках. + budget_type: тип бюджета кампании. + item_id: идентификатор объявления. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_id = item_id or self._require_item_id() + validate_positive_int("item_id", resolved_item_id) + validate_positive_int("action_type_id", action_type_id) + validate_positive_int("budget_penny", budget_penny) + validate_non_empty_string("budget_type", budget_type) + request_payload = UpdateAutoBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + budget_penny=budget_penny, + budget_type=budget_type, + ).to_payload() + target = {"item_id": resolved_item_id} + if dry_run: + return _preview_result( + action="update_auto", + target=target, + request_payload=request_payload, + ) + payload = await self._execute( + UPDATE_TARGET_ACTION_AUTO, + request=UpdateAutoBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + budget_penny=budget_penny, + budget_type=budget_type, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="update_auto", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "POST", + "/cpxpromo/1/setManual", + spec="Настройкаценыцелевогодействия.json", + operation_id="saveManualBid", + method_args={"action_type_id": "body.action_type_id", "bid_penny": "body.bid_penny"}, + variant="async", + ) + async def update_manual( + self, + *, + action_type_id: int, + bid_penny: int, + limit_penny: int | None = None, + item_id: int | None = None, + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Применяет ручную настройку. + + Аргументы: + action_type_id: идентификатор целевого действия. + bid_penny: ставка в копейках. + limit_penny: лимит расходов в копейках. + item_id: идентификатор объявления. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_id = item_id or self._require_item_id() + validate_positive_int("item_id", resolved_item_id) + validate_positive_int("action_type_id", action_type_id) + validate_positive_int("bid_penny", bid_penny) + if limit_penny is not None: + validate_positive_int("limit_penny", limit_penny) + request_payload = UpdateManualBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + bid_penny=bid_penny, + limit_penny=limit_penny, + ).to_payload() + target = {"item_id": resolved_item_id} + if dry_run: + return _preview_result( + action="update_manual", + target=target, + request_payload=request_payload, + ) + payload = await self._execute( + UPDATE_TARGET_ACTION_MANUAL, + request=UpdateManualBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + bid_penny=bid_penny, + limit_penny=limit_penny, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="update_manual", + target=target, + request_payload=request_payload, + ) + + def _require_item_id(self) -> int: + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return int(self.item_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAutostrategyCampaign(AsyncDomainObject): + """Доменный объект кампаний автостратегии.""" + + __swagger_domain__ = "promotion" + __sdk_factory__ = "autostrategy_campaign" + __sdk_factory_args__ = {"campaign_id": "path.campaign_id"} + + campaign_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/autostrategy/v1/budget", + spec="Автостратегия.json", + operation_id="getAutostrategyBudget", + method_args={"campaign_type": "body.campaign_type"}, + variant="async", + ) + async def create_budget( + self, + *, + campaign_type: CampaignType | str, + start_time: datetime | None = None, + finish_time: datetime | None = None, + items: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutostrategyBudget: + """Рассчитывает бюджет кампании. + + Аргументы: + campaign_type: тип автостратегии или рекламной кампании. + start_time: дата и время начала кампании. + finish_time: дата и время окончания кампании. + items: элементы запроса с объявлениями, ставками или настройками продвижения. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutostrategyBudget` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + _validate_optional_datetime("start_time", start_time) + _validate_optional_datetime("finish_time", finish_time) + return await self._execute( + CREATE_AUTOSTRATEGY_BUDGET, + request=CreateAutostrategyBudgetRequest( + campaign_type=campaign_type, + start_time=start_time, + finish_time=finish_time, + items=items, + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autostrategy/v1/campaign/create", + spec="Автостратегия.json", + operation_id="createAutostrategyCampaign", + method_args={"campaign_type": "body.campaign_type", "title": "body.title"}, + variant="async", + ) + async def create( + self, + *, + campaign_type: CampaignType | str, + title: str, + budget: int | None = None, + budget_bonus: int | None = None, + budget_real: int | None = None, + calc_id: int | None = None, + description: str | None = None, + finish_time: datetime | None = None, + items: list[int] | None = None, + start_time: datetime | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CampaignActionResult: + """Создает новую кампанию. + + Аргументы: + campaign_type: тип автостратегии или рекламной кампании. + title: название кампании. + budget: бюджет кампании. + budget_bonus: бонусный бюджет кампании. + budget_real: реальный бюджет кампании. + calc_id: идентификатор расчета или прогноза кампании. + description: описание кампании. + finish_time: дата и время окончания кампании. + items: элементы запроса с объявлениями, ставками или настройками продвижения. + start_time: дата и время начала кампании. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + _validate_optional_datetime("start_time", start_time) + _validate_optional_datetime("finish_time", finish_time) + return await self._execute( + CREATE_AUTOSTRATEGY_CAMPAIGN, + request=CreateAutostrategyCampaignRequest( + campaign_type=campaign_type, + title=title, + budget=budget, + budget_bonus=budget_bonus, + budget_real=budget_real, + calc_id=calc_id, + description=description, + finish_time=finish_time, + items=items, + start_time=start_time, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autostrategy/v1/campaign/edit", + spec="Автостратегия.json", + operation_id="editAutostrategyCampaign", + method_args={"campaign_id": "body.campaignId", "version": "body.version"}, + variant="async", + ) + async def update( + self, + *, + version: int, + campaign_id: int | None = None, + budget: int | None = None, + calc_id: int | None = None, + description: str | None = None, + finish_time: datetime | None = None, + items: list[int] | None = None, + start_time: datetime | None = None, + title: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CampaignActionResult: + """Редактирует кампанию. + + Аргументы: + version: версия кампании для optimistic locking или согласованного обновления. + campaign_id: идентификатор кампании. + budget: бюджет кампании. + calc_id: идентификатор расчета или прогноза кампании. + description: описание кампании. + finish_time: дата и время окончания кампании. + items: элементы запроса с объявлениями, ставками или настройками продвижения. + start_time: дата и время начала кампании. + title: название кампании. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + _validate_optional_datetime("start_time", start_time) + _validate_optional_datetime("finish_time", finish_time) + return await self._execute( + UPDATE_AUTOSTRATEGY_CAMPAIGN, + request=UpdateAutostrategyCampaignRequest( + campaign_id=campaign_id or self._require_campaign_id(), + version=version, + budget=budget, + calc_id=calc_id, + description=description, + finish_time=finish_time, + items=items, + start_time=start_time, + title=title, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autostrategy/v1/campaign/info", + spec="Автостратегия.json", + operation_id="getAutostrategyCampaignInfo", + method_args={"campaign_id": "body.campaign_id"}, + variant="async", + ) + async def get( + self, + *, + campaign_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CampaignDetailsResult: + """Получает полную информацию о кампании. + + Аргументы: + campaign_id: идентификатор кампании. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignDetailsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AUTOSTRATEGY_CAMPAIGN, + request=GetAutostrategyCampaignInfoRequest( + campaign_id=campaign_id or self._require_campaign_id() + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autostrategy/v1/campaign/stop", + spec="Автостратегия.json", + operation_id="stopAutostrategyCampaign", + method_args={"campaign_id": "body.campaignId", "version": "body.version"}, + variant="async", + ) + async def delete( + self, + *, + version: int, + campaign_id: int | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CampaignActionResult: + """Останавливает кампанию. + + Аргументы: + version: версия кампании для optimistic locking или согласованного обновления. + campaign_id: идентификатор кампании. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELETE_AUTOSTRATEGY_CAMPAIGN, + request=StopAutostrategyCampaignRequest( + campaign_id=campaign_id or self._require_campaign_id(), + version=version, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autostrategy/v1/campaigns", + spec="Автостратегия.json", + operation_id="getAutostrategyCampaigns", + variant="async", + ) + async def list( + self, + *, + limit: int = 100, + offset: int | None = None, + status_id: builtins.list[int] | None = None, + order_by: builtins.list[tuple[str, str]] | None = None, + updated_from: datetime | None = None, + updated_to: datetime | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CampaignsResult: + """Возвращает кампании автостратегии с фильтрами и пагинацией. + + Аргументы: + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + status_id: фильтрует результат по числовому статусу. + order_by: задает порядок сортировки результата. + updated_from: фильтрует записи, обновленные не раньше указанного времени. + updated_to: фильтрует записи, обновленные не позже указанного времени. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + filter_payload = ( + CampaignListFilter( + by_update_time=CampaignUpdateTimeFilter( + from_time=updated_from, + to_time=updated_to, + ) + ) + if updated_from is not None or updated_to is not None + else None + ) + order_by_payload = ( + [CampaignOrderBy(column=column, direction=direction) for column, direction in order_by] + if order_by is not None + else None + ) + return await self._execute( + LIST_AUTOSTRATEGY_CAMPAIGNS, + request=ListAutostrategyCampaignsRequest( + limit=limit, + offset=offset, + status_id=status_id, + order_by=order_by_payload, + filter=filter_payload, + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autostrategy/v1/stat", + spec="Автостратегия.json", + operation_id="getAutostrategyStat", + variant="async", + ) + async def get_stat( + self, + *, + campaign_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutostrategyStat: + """Получает статистику кампании. + + Аргументы: + campaign_id: идентификатор кампании. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutostrategyStat` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AUTOSTRATEGY_STAT, + request=GetAutostrategyStatRequest( + campaign_id=campaign_id or self._require_campaign_id() + ), + timeout=timeout, + retry=retry, + ) + + def _require_campaign_id(self) -> int: + if self.campaign_id is None: + raise ValidationError("Для операции требуется `campaign_id`.") + return int(self.campaign_id) + + +__all__ = ( + "AsyncAutostrategyCampaign", + "AsyncBbipPromotion", + "AsyncCpaAuction", + "AsyncPromotionOrder", + "AsyncTargetActionPricing", + "AsyncTrxPromotion", +) diff --git a/tests/contracts/test_async_swagger_contracts.py b/tests/contracts/test_async_swagger_contracts.py index 3d6ef49..a6473ab 100644 --- a/tests/contracts/test_async_swagger_contracts.py +++ b/tests/contracts/test_async_swagger_contracts.py @@ -18,9 +18,12 @@ def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: "AsyncChatMessage", "AsyncChatWebhook", "AsyncCpaArchive", + "AsyncCpaAuction", "AsyncCpaCall", "AsyncCpaChat", "AsyncCpaLead", + "AsyncAutostrategyCampaign", + "AsyncBbipPromotion", "AsyncApplication", "AsyncJobDictionary", "AsyncJobWebhook", @@ -34,7 +37,10 @@ def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: "AsyncReview", "AsyncReviewAnswer", "AsyncSpecialOfferCampaign", + "AsyncPromotionOrder", "AsyncTariff", + "AsyncTargetActionPricing", "AsyncTokenClient", + "AsyncTrxPromotion", } - assert len(async_bindings) == 81 + assert len(async_bindings) == 105 diff --git a/tests/domains/promotion/test_promotion_async.py b/tests/domains/promotion/test_promotion_async.py new file mode 100644 index 0000000..363d70a --- /dev/null +++ b/tests/domains/promotion/test_promotion_async.py @@ -0,0 +1,486 @@ +from __future__ import annotations + +from datetime import datetime + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core import ResponseMappingError, ValidationError +from avito.promotion import ( + AsyncAutostrategyCampaign, + AsyncBbipPromotion, + AsyncCpaAuction, + AsyncPromotionOrder, + AsyncTargetActionPricing, + AsyncTrxPromotion, +) +from avito.promotion.models import ( + BbipItem, + CpaAuctionBidInput, + TrxItem, +) +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_promotion_service_dictionary_and_orders_flow() -> None: + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/promotion/v1/items/services/dict", + {"items": [{"code": "x2", "title": "X2"}]}, + ) + .add_json( + "POST", + "/promotion/v1/items/services/get", + { + "items": [ + { + "itemId": 101, + "serviceCode": "x2", + "serviceName": "X2", + "price": 9900, + "status": "available", + } + ] + }, + ) + .add_json( + "POST", + "/promotion/v1/items/services/orders/get", + { + "items": [ + { + "orderId": "ord-1", + "itemId": 101, + "serviceCode": "x2", + "status": "created", + } + ] + }, + ) + .add_json( + "POST", + "/promotion/v1/items/services/orders/status", + {"orderId": "ord-1", "status": "processed", "items": [], "errors": []}, + ) + ) + transport = fake.build() + promotion = AsyncPromotionOrder(transport, order_id="ord-1") + + assert (await promotion.get_service_dictionary()).items[0].code == "x2" + assert (await promotion.list_services(item_ids=[101])).items[0].price == 9900 + assert (await promotion.list_orders(item_ids=[101])).items[0].order_id == "ord-1" + assert (await promotion.get_order_status()).status == "processed" + assert fake.last(method="POST", path="/promotion/v1/items/services/get").json_body == { + "itemIds": [101] + } + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_bbip_trx_cpa_and_target_action_flows() -> None: + def create_cpa_bids(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"items": [{"itemID": 101, "pricePenny": 1500}]} + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True}]}) + + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/promotion/v1/items/services/bbip/forecasts/get", + { + "items": [ + { + "itemId": 101, + "min": 10, + "max": 25, + "totalPrice": 7000, + "totalOldPrice": 8400, + } + ] + }, + ) + .add_json( + "PUT", + "/promotion/v1/items/services/bbip/orders/create", + {"items": [{"itemId": 101, "success": True, "status": "created"}]}, + ) + .add_json( + "POST", + "/promotion/v1/items/services/bbip/suggests/get", + { + "items": [ + { + "itemId": 101, + "duration": {"from": 1, "to": 7, "recommended": 5}, + "budgets": [{"price": 1000, "oldPrice": 1200, "isRecommended": True}], + } + ] + }, + ) + .add_json( + "POST", + "/trx-promo/1/apply", + {"success": {"items": [{"itemID": 101, "success": True}]}}, + ) + .add_json( + "POST", + "/trx-promo/1/cancel", + {"success": {"items": [{"itemID": 101, "success": True}]}}, + ) + .add_json( + "GET", + "/trx-promo/1/commissions", + { + "success": { + "items": [ + { + "itemID": 101, + "commission": 1500, + "isActive": True, + "validCommissionRange": { + "valueMin": 100, + "valueMax": 2000, + "step": 100, + }, + } + ] + } + }, + ) + .add_json( + "GET", + "/auction/1/bids", + { + "items": [ + { + "itemID": 101, + "pricePenny": 1300, + "availablePrices": [{"pricePenny": 1200, "goodness": 1}], + } + ] + }, + ) + .add("POST", "/auction/1/bids", create_cpa_bids) + .add_json( + "GET", + "/cpxpromo/1/getBids/101", + { + "actionTypeID": 5, + "selectedType": "manual", + "manual": { + "bidPenny": 1400, + "limitPenny": 15000, + "recBidPenny": 1500, + "minBidPenny": 1000, + "maxBidPenny": 2000, + "minLimitPenny": 5000, + "maxLimitPenny": 50000, + "bids": [{"valuePenny": 1500, "minForecast": 2, "maxForecast": 5}], + }, + }, + ) + .add_json( + "POST", + "/cpxpromo/1/getPromotionsByItemIds", + { + "items": [ + { + "itemID": 102, + "actionTypeID": 7, + "autoPromotion": {"budgetPenny": 9000, "budgetType": "7d"}, + } + ] + }, + ) + .add_json( + "POST", + "/cpxpromo/1/remove", + {"items": [{"itemID": 101, "success": True, "status": "removed"}]}, + ) + .add_json( + "POST", + "/cpxpromo/1/setAuto", + {"items": [{"itemID": 101, "success": True, "status": "auto"}]}, + ) + .add_json( + "POST", + "/cpxpromo/1/setManual", + {"items": [{"itemID": 101, "success": True, "status": "manual"}]}, + ) + ) + transport = fake.build() + bbip = AsyncBbipPromotion(transport, item_id=101) + trx = AsyncTrxPromotion(transport, item_id=101) + auction = AsyncCpaAuction(transport) + pricing = AsyncTargetActionPricing(transport, item_id=101) + bbip_item = BbipItem(item_id=101, duration=7, price=1000, old_price=1200) + trx_item = TrxItem( + item_id=101, + commission=1500, + date_from=datetime.fromisoformat("2026-04-18T00:00:00+00:00"), + ) + + assert (await bbip.get_forecasts(items=[bbip_item])).items[0].max_views == 25 + assert (await bbip.create_order(items=[bbip_item])).status == "created" + assert (await bbip.get_suggests()).items[0].duration is not None + assert (await trx.apply(items=[trx_item])).applied is True + assert (await trx.delete()).applied is True + assert (await trx.get_commissions()).items[0].valid_commission_range is not None + assert (await auction.get_user_bids(from_item_id=100, batch_size=50)).items[0].available_prices[ + 0 + ].price_penny == 1200 + assert ( + await auction.create_item_bids(items=[CpaAuctionBidInput(item_id=101, price_penny=1500)]) + ).applied is True + assert (await pricing.get_bids()).manual is not None + assert (await pricing.get_promotions_by_item_ids(item_ids=[101, 102])).items[0].auto is not None + assert (await pricing.delete()).status == "removed" + assert ( + await pricing.update_auto(action_type_id=5, budget_penny=8000, budget_type="7d") + ).status == "auto" + assert (await pricing.update_manual(action_type_id=5, bid_penny=1500)).status == "manual" + assert fake.last(method="GET", path="/auction/1/bids").params == { + "fromItemID": "100", + "batchSize": "50", + } + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_autostrategy_flows() -> None: + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/autostrategy/v1/budget", + { + "calcId": 501, + "budget": { + "recommended": {"total": 10100}, + "minimal": {"total": 5100}, + "priceRanges": [], + }, + }, + ) + .add_json( + "POST", + "/autostrategy/v1/campaign/create", + {"campaign": {"campaignId": 77, "campaignType": "AS", "version": 3}}, + ) + .add_json( + "POST", + "/autostrategy/v1/campaign/edit", + {"campaign": {"campaignId": 77, "campaignType": "AS", "version": 4}}, + ) + .add_json( + "POST", + "/autostrategy/v1/campaign/info", + { + "campaign": { + "campaignId": 77, + "campaignType": "AS", + "statusId": 1, + "budget": 10000, + "balance": 9000, + "title": "Весенняя кампания", + "version": 4, + }, + "forecast": {"calls": {"from": 2, "to": 5}, "views": {"from": 30, "to": 50}}, + "items": [{"itemId": 101, "isActive": True}], + }, + ) + .add_json( + "POST", + "/autostrategy/v1/campaign/stop", + {"campaign": {"campaignId": 77, "campaignType": "AS", "version": 5}}, + ) + .add_json( + "POST", + "/autostrategy/v1/campaigns", + { + "campaigns": [{"campaignId": 77, "campaignType": "AS", "statusId": 1}], + "totalCount": 1, + }, + ) + .add_json( + "POST", + "/autostrategy/v1/stat", + {"stat": [{"date": "2026-04-18", "calls": 30}], "totals": {"calls": 30}}, + ) + ) + transport = fake.build() + campaign = AsyncAutostrategyCampaign(transport, campaign_id=77) + start_time = datetime.fromisoformat("2026-04-20T00:00:00+00:00") + finish_time = datetime.fromisoformat("2026-04-27T00:00:00+00:00") + + assert ( + await campaign.create_budget( + campaign_type="AS", + start_time=start_time, + finish_time=finish_time, + items=[101, 102], + ) + ).calc_id == 501 + assert ( + await campaign.create( + campaign_type="AS", + title="Весенняя кампания", + budget=10000, + calc_id=501, + items=[101, 102], + start_time=start_time, + finish_time=finish_time, + ) + ).campaign is not None + assert (await campaign.update(campaign_id=77, version=3, title="Обновленная кампания")).campaign + assert (await campaign.get()).campaign is not None + assert (await campaign.delete(version=4)).campaign is not None + assert ( + await campaign.list( + limit=20, + offset=10, + status_id=[1, 2], + order_by=[("startTime", "asc")], + updated_from=datetime.fromisoformat("2026-04-01T00:00:00+00:00"), + updated_to=datetime.fromisoformat("2026-04-30T00:00:00+00:00"), + ) + ).total_count == 1 + assert (await campaign.get_stat()).totals is not None + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_promotion_dry_run_does_not_call_transport() -> None: + fake = AsyncFakeTransport() + transport = fake.build() + bbip = AsyncBbipPromotion(transport, item_id=101) + trx = AsyncTrxPromotion(transport, item_id=101) + pricing = AsyncTargetActionPricing(transport, item_id=101) + bbip_item = BbipItem(item_id=101, duration=7, price=1000, old_price=1200) + trx_item = TrxItem( + item_id=101, + commission=1500, + date_from=datetime.fromisoformat("2026-04-18T00:00:00+00:00"), + ) + + assert (await bbip.create_order(items=[bbip_item], dry_run=True)).status == "preview" + assert (await trx.apply(items=[trx_item], dry_run=True)).status == "preview" + assert ( + await pricing.update_manual(action_type_id=5, bid_penny=1500, dry_run=True) + ).status == "preview" + assert fake.requests == [] + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_autostrategy_datetime_parameters_fail_fast_on_invalid_type() -> None: + transport = AsyncFakeTransport().build() + campaign = AsyncAutostrategyCampaign(transport, campaign_id=77) + + with pytest.raises(ValidationError, match="`start_time` должен быть datetime."): + await campaign.create_budget(campaign_type="AS", start_time="2026-04-20T00:00:00+00:00") # type: ignore[arg-type] + with pytest.raises(ValidationError, match="`finish_time` должен быть datetime."): + await campaign.create( + campaign_type="AS", + title="Весенняя кампания", + finish_time="2026-04-27T00:00:00+00:00", # type: ignore[arg-type] + ) + with pytest.raises(ValidationError, match="`start_time` должен быть datetime."): + await campaign.update( + version=3, + start_time="2026-04-20T00:00:00+00:00", # type: ignore[arg-type] + ) + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_idempotency_key_forwarded_once_per_retry_chain() -> None: + seen_keys: list[str | None] = [] + + def fail_once(request: RecordedRequest) -> httpx.Response: + seen_keys.append(request.headers.get("idempotency-key")) + raise httpx.ConnectError( + "offline", + request=httpx.Request("POST", "https://api.avito.ru/cpxpromo/1/setManual"), + ) + + def succeed(request: RecordedRequest) -> httpx.Response: + seen_keys.append(request.headers.get("idempotency-key")) + return httpx.Response( + 200, + json={"items": [{"itemID": 101, "success": True, "status": "manual"}]}, + ) + + fake = AsyncFakeTransport().add("POST", "/cpxpromo/1/setManual", fail_once, succeed) + transport = fake.build() + pricing = AsyncTargetActionPricing(transport, item_id=101) + + result = await pricing.update_manual( + action_type_id=5, + bid_penny=1500, + idempotency_key="idem-123", + ) + + assert result.status == "manual" + assert seen_keys == ["idem-123", "idem-123"] + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_promotion_read_mappers_raise_on_invalid_shape() -> None: + fake = ( + AsyncFakeTransport() + .add_json("POST", "/promotion/v1/items/services/orders/status", {"items": []}) + .add_json("GET", "/cpxpromo/1/getBids/101", {"items": []}) + .add_json("POST", "/cpxpromo/1/getPromotionsByItemIds", {"items": [{"itemID": 102}]}) + ) + transport = fake.build() + + with pytest.raises(ResponseMappingError): + await AsyncPromotionOrder(transport, order_id="ord-2").get_order_status() + with pytest.raises(ResponseMappingError): + await AsyncTargetActionPricing(transport, item_id=101).get_bids() + with pytest.raises(ResponseMappingError): + await AsyncTargetActionPricing(transport, item_id=101).get_promotions_by_item_ids( + item_ids=[102] + ) + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_promotion_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client() + + assert isinstance(client.promotion_order(order_id="ord-1"), AsyncPromotionOrder) + assert isinstance(client.bbip_promotion(item_id=101), AsyncBbipPromotion) + assert isinstance(client.trx_promotion(item_id=101), AsyncTrxPromotion) + assert isinstance(client.cpa_auction(item_id=101), AsyncCpaAuction) + assert isinstance(client.target_action_pricing(item_id=101), AsyncTargetActionPricing) + assert isinstance(client.autostrategy_campaign(campaign_id=77), AsyncAutostrategyCampaign) + await client.aclose() + + +def test_async_client_promotion_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.promotion_order() + with pytest.raises(RuntimeError): + client.bbip_promotion() + with pytest.raises(RuntimeError): + client.trx_promotion() + with pytest.raises(RuntimeError): + client.cpa_auction() + with pytest.raises(RuntimeError): + client.target_action_pricing() + with pytest.raises(RuntimeError): + client.autostrategy_campaign() From 2ebedbe1521b48e7ae135b5add2d6ae19108746a Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 21:18:02 +0300 Subject: [PATCH 20/26] M10 --- CHANGELOG.d/0000-async-autoteka.md | 2 + avito/async_client.py | 41 + avito/autoteka/__init__.py | 12 + avito/autoteka/async_domain.py | 1246 +++++++++++++++++ tests/auth/test_async_provider.py | 47 + .../contracts/test_async_swagger_contracts.py | 7 +- tests/domains/autoteka/test_autoteka_async.py | 313 +++++ 7 files changed, 1667 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.d/0000-async-autoteka.md create mode 100644 avito/autoteka/async_domain.py create mode 100644 tests/domains/autoteka/test_autoteka_async.py diff --git a/CHANGELOG.d/0000-async-autoteka.md b/CHANGELOG.d/0000-async-autoteka.md new file mode 100644 index 0000000..3afc085 --- /dev/null +++ b/CHANGELOG.d/0000-async-autoteka.md @@ -0,0 +1,2 @@ +### Added +- Async-поддержка домена autoteka: AsyncAutotekaVehicle, AsyncAutotekaReport, AsyncAutotekaMonitoring, AsyncAutotekaScoring, AsyncAutotekaValuation. diff --git a/avito/async_client.py b/avito/async_client.py index b73af3e..de7251a 100644 --- a/avito/async_client.py +++ b/avito/async_client.py @@ -10,6 +10,13 @@ from avito.auth.async_provider import AsyncAuthProvider from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient from avito.auth.settings import AuthSettings +from avito.autoteka import ( + AsyncAutotekaMonitoring, + AsyncAutotekaReport, + AsyncAutotekaScoring, + AsyncAutotekaValuation, + AsyncAutotekaVehicle, +) from avito.config import AvitoSettings from avito.core.async_transport import AsyncTransport from avito.core.exceptions import ClientClosedError @@ -345,6 +352,40 @@ def autostrategy_campaign( return AsyncAutostrategyCampaign(self._require_transport(), campaign_id=campaign_id) + def autoteka_vehicle( + self, + vehicle_id: int | str | None = None, + ) -> AsyncAutotekaVehicle: + """Создает async-доменный объект автомобиля Автотеки.""" + + return AsyncAutotekaVehicle(self._require_transport(), vehicle_id=vehicle_id) + + def autoteka_report( + self, + report_id: int | str | None = None, + ) -> AsyncAutotekaReport: + """Создает async-доменный объект отчетов Автотеки.""" + + return AsyncAutotekaReport(self._require_transport(), report_id=report_id) + + def autoteka_monitoring(self) -> AsyncAutotekaMonitoring: + """Создает async-доменный объект мониторинга Автотеки.""" + + return AsyncAutotekaMonitoring(self._require_transport()) + + def autoteka_scoring( + self, + scoring_id: int | str | None = None, + ) -> AsyncAutotekaScoring: + """Создает async-доменный объект скоринга Автотеки.""" + + return AsyncAutotekaScoring(self._require_transport(), scoring_id=scoring_id) + + def autoteka_valuation(self) -> AsyncAutotekaValuation: + """Создает async-доменный объект оценки автомобиля Автотеки.""" + + return AsyncAutotekaValuation(self._require_transport()) + async def aclose(self) -> None: """Закрывает transport и auth-provider; повторный вызов безопасен.""" diff --git a/avito/autoteka/__init__.py b/avito/autoteka/__init__.py index 9289dfb..92e024f 100644 --- a/avito/autoteka/__init__.py +++ b/avito/autoteka/__init__.py @@ -1,5 +1,12 @@ """Пакет autoteka.""" +from avito.autoteka.async_domain import ( + AsyncAutotekaMonitoring, + AsyncAutotekaReport, + AsyncAutotekaScoring, + AsyncAutotekaValuation, + AsyncAutotekaVehicle, +) from avito.autoteka.domain import ( AutotekaMonitoring, AutotekaReport, @@ -41,6 +48,11 @@ ) __all__ = ( + "AsyncAutotekaMonitoring", + "AsyncAutotekaReport", + "AsyncAutotekaScoring", + "AsyncAutotekaValuation", + "AsyncAutotekaVehicle", "AutotekaLeadEvent", "AutotekaLeadsResult", "AutotekaMonitoring", diff --git a/avito/autoteka/async_domain.py b/avito/autoteka/async_domain.py new file mode 100644 index 0000000..28889b1 --- /dev/null +++ b/avito/autoteka/async_domain.py @@ -0,0 +1,1246 @@ +"""Async-доменные объекты пакета autoteka.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from avito.autoteka.models import ( + AutotekaLeadsResult, + AutotekaPackageInfo, + AutotekaPreviewInfo, + AutotekaReportInfo, + AutotekaReportsResult, + AutotekaScoringInfo, + AutotekaSpecificationInfo, + AutotekaTeaserInfo, + AutotekaValuationInfo, + CatalogResolveRequest, + CatalogResolveResult, + ExternalItemPreviewRequest, + ItemIdRequest, + LeadsRequest, + MonitoringBucketRequest, + MonitoringBucketResult, + MonitoringEventsQuery, + MonitoringEventsResult, + PlateNumberRequest, + PreviewReportRequest, + RegNumberRequest, + TeaserCreateRequest, + ValuationBySpecificationRequest, + VehicleIdRequest, + VinRequest, +) +from avito.autoteka.operations import ( + ADD_MONITORING_BUCKET, + CATALOG_RESOLVE, + CREATE_PREVIEW_BY_EXTERNAL_ITEM, + CREATE_PREVIEW_BY_ITEM_ID, + CREATE_PREVIEW_BY_REG_NUMBER, + CREATE_PREVIEW_BY_VIN, + CREATE_REPORT, + CREATE_REPORT_BY_VEHICLE_ID, + CREATE_SCORING_BY_VEHICLE_ID, + CREATE_SPECIFICATION_BY_PLATE_NUMBER, + CREATE_SPECIFICATION_BY_VEHICLE_ID, + CREATE_SYNC_REPORT_BY_REG_NUMBER, + CREATE_SYNC_REPORT_BY_VIN, + CREATE_TEASER, + DELETE_MONITORING_BUCKET, + GET_ACTIVE_PACKAGE, + GET_LEADS, + GET_MONITORING_REG_ACTIONS, + GET_PREVIEW, + GET_REPORT, + GET_SCORING_BY_ID, + GET_SPECIFICATION_BY_ID, + GET_TEASER, + GET_VALUATION_BY_SPECIFICATION, + LIST_REPORTS, + REMOVE_MONITORING_BUCKET, +) +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.async_transport import AsyncTransport +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation + + +async def _autoteka_headers(transport: AsyncTransport) -> dict[str, str]: + auth_provider = transport.auth_provider + if auth_provider is None: + return {} + return {"Authorization": f"Bearer {await auth_provider.get_autoteka_access_token()}"} + + +@dataclass(slots=True, frozen=True) +class AsyncAutotekaVehicle(AsyncDomainObject): + """Доменный объект превью, спецификаций, тизеров и каталога.""" + + __swagger_domain__ = "autoteka" + __sdk_factory__ = "autoteka_vehicle" + __sdk_factory_args__ = {"vehicle_id": "path.vehicle_id"} + + vehicle_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/autoteka/v1/catalogs/resolve", + spec="Автотека.json", + operation_id="catalogsResolve", + variant="async", + method_args={"brand_id": "body.fieldsValueIds[].valueId"}, + ) + async def resolve_catalog( + self, + *, + brand_id: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CatalogResolveResult: + """Актуализирует параметры автокаталога. + + Аргументы: + brand_id: идентифицирует марку автомобиля в каталоге. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CatalogResolveResult` с актуализированными параметрами каталога. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CATALOG_RESOLVE, + request=CatalogResolveRequest(brand_id=brand_id), + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/get-leads", + spec="Автотека.json", + operation_id="getLeads", + variant="async", + method_args={"subscription_id": "body.subscriptionId", "limit": "body.limit"}, + ) + async def get_leads( + self, + *, + subscription_id: int, + limit: int, + last_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaLeadsResult: + """Возвращает leads для автомобилей Автотеки. + + Аргументы: + subscription_id: идентифицирует подписку Сигнала. + limit: ограничивает размер возвращаемой выборки. + last_id: задает последний прочитанный идентификатор для постраничной выдачи. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaLeadsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_LEADS, + request=LeadsRequest(subscription_id=subscription_id, limit=limit, last_id=last_id), + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/previews", + spec="Автотека.json", + operation_id="postPreviewByVin", + variant="async", + method_args={"vin": "body.vin"}, + ) + async def create_preview_by_vin( + self, + *, + vin: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaPreviewInfo: + """Создает preview автомобиля Автотеки по VIN. + + Аргументы: + vin: передает VIN автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_PREVIEW_BY_VIN, + request=VinRequest(vin=vin), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/previews/{previewId}", + spec="Автотека.json", + operation_id="getPreview", + variant="async", + ) + async def get_preview( + self, + *, + preview_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaPreviewInfo: + """Возвращает preview для автомобилей Автотеки. + + Аргументы: + preview_id: идентифицирует preview Автотеки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_PREVIEW, + path_params={"previewId": preview_id or self._require_vehicle_id("preview_id")}, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/request-preview-by-external-item", + spec="Автотека.json", + operation_id="postPreviewByExternalItem", + variant="async", + method_args={"item_id": "body.itemId", "site": "body.site"}, + ) + async def create_preview_by_external_item( + self, + *, + item_id: str, + site: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaPreviewInfo: + """Создает preview автомобиля Автотеки по внешнему объявлению. + + Аргументы: + item_id: идентифицирует объявление Авито. + site: задает площадку внешнего объявления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_PREVIEW_BY_EXTERNAL_ITEM, + request=ExternalItemPreviewRequest(item_id=item_id, site=site), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/request-preview-by-item-id", + spec="Автотека.json", + operation_id="postPreviewByItemId", + variant="async", + method_args={"item_id": "body.item_id"}, + ) + async def create_preview_by_item_id( + self, + *, + item_id: int, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaPreviewInfo: + """Создает preview автомобиля Автотеки по объявлению Авито. + + Аргументы: + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_PREVIEW_BY_ITEM_ID, + request=ItemIdRequest(item_id=item_id), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/request-preview-by-regnumber", + spec="Автотека.json", + operation_id="postPreviewByRegNumber", + variant="async", + method_args={"reg_number": "body.reg_number"}, + ) + async def create_preview_by_reg_number( + self, + *, + reg_number: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaPreviewInfo: + """Создает preview автомобиля Автотеки по госномеру. + + Аргументы: + reg_number: передает государственный номер автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_PREVIEW_BY_REG_NUMBER, + request=RegNumberRequest(reg_number=reg_number), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/specifications/by-plate-number", + spec="Автотека.json", + operation_id="specificationByPlateNumber", + variant="async", + method_args={"plate_number": "body.plate_number"}, + ) + async def create_specification_by_plate_number( + self, + *, + plate_number: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaSpecificationInfo: + """Создает спецификацию автомобиля Автотеки по номерному знаку. + + Аргументы: + plate_number: передает номерной знак автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaSpecificationInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_SPECIFICATION_BY_PLATE_NUMBER, + request=PlateNumberRequest(plate_number=plate_number), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/specifications/by-vehicle-id", + spec="Автотека.json", + operation_id="specificationByVehicleId", + variant="async", + method_args={"vehicle_id": "body.vehicle_id"}, + ) + async def create_specification_by_vehicle_id( + self, + *, + vehicle_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaSpecificationInfo: + """Создает спецификацию автомобиля Автотеки по vehicle_id. + + Аргументы: + vehicle_id: идентифицирует автомобиль в Автотеке. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaSpecificationInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_SPECIFICATION_BY_VEHICLE_ID, + request=VehicleIdRequest(vehicle_id=vehicle_id), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/specifications/specification/{specificationID}", + spec="Автотека.json", + operation_id="specificationGetById", + variant="async", + ) + async def get_specification_by_id( + self, + *, + specification_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaSpecificationInfo: + """Возвращает спецификацию автомобиля Автотеки по идентификатору. + + Аргументы: + specification_id: идентифицирует спецификацию автомобиля. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaSpecificationInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_SPECIFICATION_BY_ID, + path_params={ + "specificationID": specification_id or self._require_vehicle_id("specification_id") + }, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/teasers", + spec="Автотека.json", + operation_id="postTeaser", + variant="async", + method_args={"vehicle_id": "body.vehicle_id"}, + ) + async def create_teaser( + self, + *, + vehicle_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaTeaserInfo: + """Создает тизер автомобиля Автотеки. + + Аргументы: + vehicle_id: идентифицирует автомобиль в Автотеке. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaTeaserInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_TEASER, + request=TeaserCreateRequest(vehicle_id=vehicle_id), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/teasers/{teaser_id}", + spec="Автотека.json", + operation_id="getTeaser", + variant="async", + ) + async def get_teaser( + self, + *, + teaser_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaTeaserInfo: + """Возвращает teaser для автомобилей Автотеки. + + Аргументы: + teaser_id: идентифицирует тизер Автотеки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaTeaserInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_TEASER, + path_params={"teaser_id": teaser_id or self._require_vehicle_id("teaser_id")}, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + def _require_vehicle_id(self, field_name: str) -> str: + if self.vehicle_id is None: + raise ValidationError(f"Для операции требуется `{field_name}`.") + return str(self.vehicle_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAutotekaReport(AsyncDomainObject): + """Доменный объект отчетов и пакетов Автотеки.""" + + __swagger_domain__ = "autoteka" + __sdk_factory__ = "autoteka_report" + __sdk_factory_args__ = {"report_id": "path.report_id"} + + report_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/autoteka/v1/packages/active_package", + spec="Автотека.json", + operation_id="getActivePackage", + variant="async", + ) + async def get_active_package( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutotekaPackageInfo: + """Возвращает active package для отчетов Автотеки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaPackageInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_ACTIVE_PACKAGE, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/reports", + spec="Автотека.json", + operation_id="postReport", + variant="async", + method_args={"preview_id": "body.preview_id"}, + ) + async def create_report( + self, + *, + preview_id: int, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaReportInfo: + """Создает отчет Автотеки по preview. + + Аргументы: + preview_id: идентифицирует preview Автотеки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_REPORT, + request=PreviewReportRequest(preview_id=preview_id), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/reports-by-vehicle-id", + spec="Автотека.json", + operation_id="postReportByVehicleId", + variant="async", + method_args={"vehicle_id": "body.vehicle_id"}, + ) + async def create_report_by_vehicle_id( + self, + *, + vehicle_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaReportInfo: + """Создает отчет Автотеки по vehicle_id. + + Аргументы: + vehicle_id: идентифицирует автомобиль в Автотеке. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_REPORT_BY_VEHICLE_ID, + request=VehicleIdRequest(vehicle_id=vehicle_id), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/reports/list", + spec="Автотека.json", + operation_id="getReportList", + variant="async", + ) + async def list_reports( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutotekaReportsResult: + """Возвращает список reports для отчетов Автотеки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaReportsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_REPORTS, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/reports/{report_id}", + spec="Автотека.json", + operation_id="getReport", + variant="async", + ) + async def get_report( + self, + *, + report_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaReportInfo: + """Возвращает report для отчетов Автотеки. + + Аргументы: + report_id: идентифицирует отчет Автотеки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_REPORT, + path_params={"report_id": report_id or self._require_report_id()}, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/sync/create-by-regnumber", + spec="Автотека.json", + operation_id="postSyncCreateReportByRegNumber", + variant="async", + method_args={"reg_number": "body.reg_number"}, + ) + async def create_sync_report_by_reg_number( + self, + *, + reg_number: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaReportInfo: + """Создает синхронный отчет Автотеки по госномеру. + + Аргументы: + reg_number: передает государственный номер автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_SYNC_REPORT_BY_REG_NUMBER, + request=RegNumberRequest(reg_number=reg_number), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/sync/create-by-vin", + spec="Автотека.json", + operation_id="postSyncCreateReportByVin", + variant="async", + method_args={"vin": "body.vin"}, + ) + async def create_sync_report_by_vin( + self, + *, + vin: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaReportInfo: + """Создает синхронный отчет Автотеки по VIN. + + Аргументы: + vin: передает VIN автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_SYNC_REPORT_BY_VIN, + request=VinRequest(vin=vin), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_report_id(self) -> str: + if self.report_id is None: + raise ValidationError("Для операции требуется `report_id`.") + return str(self.report_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAutotekaMonitoring(AsyncDomainObject): + """Доменный объект мониторинга Автотеки.""" + + __swagger_domain__ = "autoteka" + __sdk_factory__ = "autoteka_monitoring" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/autoteka/v1/monitoring/bucket/add", + spec="Автотека.json", + operation_id="monitoringBucketAdd", + variant="async", + method_args={"vehicles": "body.data"}, + ) + async def create_monitoring_bucket_add( + self, + *, + vehicles: list[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MonitoringBucketResult: + """Создает monitoring bucket add для мониторинга Автотеки. + + Аргументы: + vehicles: передает автомобили для добавления в мониторинг. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MonitoringBucketResult` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + ADD_MONITORING_BUCKET, + request=MonitoringBucketRequest(vehicles=vehicles), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/monitoring/bucket/delete", + spec="Автотека.json", + operation_id="monitoringBucketDelete", + variant="async", + ) + async def delete_bucket( + self, + *, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MonitoringBucketResult: + """Очищает bucket мониторинга. + + Аргументы: + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MonitoringBucketResult` со статусом операции над bucket мониторинга. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELETE_MONITORING_BUCKET, + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/monitoring/bucket/remove", + spec="Автотека.json", + operation_id="monitoringBucketRemove", + variant="async", + method_args={"vehicles": "body.data"}, + ) + async def remove_bucket( + self, + *, + vehicles: list[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MonitoringBucketResult: + """Удаляет автомобили из bucket мониторинга. + + Аргументы: + vehicles: передает идентификаторы автомобилей для удаления из bucket. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MonitoringBucketResult` со статусом операции над bucket мониторинга. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + REMOVE_MONITORING_BUCKET, + request=MonitoringBucketRequest(vehicles=vehicles), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/monitoring/get-reg-actions", + spec="Автотека.json", + operation_id="monitoringGetRegActions", + variant="async", + ) + async def get_monitoring_reg_actions( + self, + *, + limit: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MonitoringEventsResult: + """Возвращает monitoring reg actions для мониторинга Автотеки. + + Аргументы: + limit: ограничивает размер возвращаемой выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MonitoringEventsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_MONITORING_REG_ACTIONS, + query=MonitoringEventsQuery(limit=limit), + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncAutotekaScoring(AsyncDomainObject): + """Доменный объект скоринга рисков.""" + + __swagger_domain__ = "autoteka" + __sdk_factory__ = "autoteka_scoring" + __sdk_factory_args__ = {"scoring_id": "path.scoring_id"} + + scoring_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/autoteka/v1/scoring/by-vehicle-id", + spec="Автотека.json", + operation_id="scoringByVehicleId", + variant="async", + method_args={"vehicle_id": "body.vehicle_id"}, + ) + async def create_scoring_by_vehicle_id( + self, + *, + vehicle_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaScoringInfo: + """Создает расчет скоринга Автотеки по vehicle_id. + + Аргументы: + vehicle_id: идентифицирует автомобиль в Автотеке. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaScoringInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_SCORING_BY_VEHICLE_ID, + request=VehicleIdRequest(vehicle_id=vehicle_id), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/scoring/{scoring_id}", + spec="Автотека.json", + operation_id="scoringGetById", + variant="async", + ) + async def get_scoring_by_id( + self, + *, + scoring_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaScoringInfo: + """Возвращает расчет скоринга Автотеки по идентификатору. + + Аргументы: + scoring_id: идентифицирует расчет скоринга. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaScoringInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_SCORING_BY_ID, + path_params={"scoring_id": scoring_id or self._require_scoring_id()}, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + def _require_scoring_id(self) -> str: + if self.scoring_id is None: + raise ValidationError("Для операции требуется `scoring_id`.") + return str(self.scoring_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAutotekaValuation(AsyncDomainObject): + """Доменный объект оценки автомобиля.""" + + __swagger_domain__ = "autoteka" + __sdk_factory__ = "autoteka_valuation" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/autoteka/v1/valuation/by-specification", + spec="Автотека.json", + operation_id="valuationBySpecification", + variant="async", + method_args={ + "specification_id": "body.specification.brand.valueId", + "mileage": "body.mileage", + }, + ) + async def get_valuation_by_specification( + self, + *, + specification_id: int, + mileage: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaValuationInfo: + """Возвращает оценку автомобиля Автотеки по спецификации. + + Аргументы: + specification_id: идентифицирует спецификацию автомобиля. + mileage: передает пробег автомобиля. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaValuationInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_VALUATION_BY_SPECIFICATION, + request=ValuationBySpecificationRequest( + specification_id=specification_id, + mileage=mileage, + ), + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + +__all__ = ( + "AsyncAutotekaMonitoring", + "AsyncAutotekaReport", + "AsyncAutotekaScoring", + "AsyncAutotekaValuation", + "AsyncAutotekaVehicle", +) diff --git a/tests/auth/test_async_provider.py b/tests/auth/test_async_provider.py index b97fb60..e292e03 100644 --- a/tests/auth/test_async_provider.py +++ b/tests/auth/test_async_provider.py @@ -1,13 +1,17 @@ from __future__ import annotations +import asyncio import inspect from datetime import UTC, datetime, timedelta +import httpx import pytest from avito.auth.async_provider import AsyncAuthProvider +from avito.auth.async_token_client import AsyncTokenClient from avito.auth.models import AccessToken, TokenResponse from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings @pytest.mark.asyncio @@ -31,3 +35,46 @@ async def fetcher(settings: AuthSettings) -> TokenResponse: provider.invalidate_token() assert await provider.get_access_token() == "token" + + +@pytest.mark.asyncio +async def test_autoteka_concurrent_first_touch_single_token_request() -> None: + token_requests = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal token_requests + assert request.url.path == "/autoteka/token" + token_requests += 1 + await asyncio.sleep(0) + return httpx.Response( + 200, + json={"access_token": "autoteka-token", "expires_in": 3600, "token_type": "Bearer"}, + ) + + settings = AuthSettings( + client_id="main-client-id", + client_secret="main-client-secret", + autoteka_client_id="autoteka-client-id", + autoteka_client_secret="autoteka-client-secret", + autoteka_scope="autoteka:read", + ) + sdk_settings = AvitoSettings(auth=settings) + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + base_url="https://api.avito.ru", + ) + provider = AsyncAuthProvider( + settings, + autoteka_token_client=AsyncTokenClient( + settings, + token_url=settings.autoteka_token_url, + client=http_client, + sdk_settings=sdk_settings, + ), + ) + + tokens = await asyncio.gather(*(provider.get_autoteka_access_token() for _ in range(20))) + + assert tokens == ["autoteka-token"] * 20 + assert token_requests == 1 + await provider.aclose() diff --git a/tests/contracts/test_async_swagger_contracts.py b/tests/contracts/test_async_swagger_contracts.py index a6473ab..10379f9 100644 --- a/tests/contracts/test_async_swagger_contracts.py +++ b/tests/contracts/test_async_swagger_contracts.py @@ -12,6 +12,11 @@ def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: "AsyncAccount", "AsyncAccountHierarchy", "AsyncAlternateTokenClient", + "AsyncAutotekaMonitoring", + "AsyncAutotekaReport", + "AsyncAutotekaScoring", + "AsyncAutotekaValuation", + "AsyncAutotekaVehicle", "AsyncCallTrackingCall", "AsyncChat", "AsyncChatMedia", @@ -43,4 +48,4 @@ def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: "AsyncTokenClient", "AsyncTrxPromotion", } - assert len(async_bindings) == 105 + assert len(async_bindings) == 131 diff --git a/tests/domains/autoteka/test_autoteka_async.py b/tests/domains/autoteka/test_autoteka_async.py new file mode 100644 index 0000000..8351b33 --- /dev/null +++ b/tests/domains/autoteka/test_autoteka_async.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import json + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.autoteka import ( + AsyncAutotekaMonitoring, + AsyncAutotekaReport, + AsyncAutotekaScoring, + AsyncAutotekaValuation, + AsyncAutotekaVehicle, +) +from avito.config import AvitoSettings +from avito.core.async_transport import AsyncTransport +from avito.testing import AsyncFakeTransport + + +@pytest.mark.asyncio +async def test_async_autoteka_vehicle_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + payload = json.loads(request.content.decode()) if request.content else None + if path == "/autoteka/v1/catalogs/resolve": + assert payload == {"fieldsValueIds": [{"id": 110000, "valueId": 1}]} + return httpx.Response( + 200, + json={ + "result": { + "fields": [ + { + "id": 110000, + "label": "Марка", + "dataType": "integer", + "values": [{"valueId": 1, "label": "Audi"}], + } + ] + } + }, + ) + if path == "/autoteka/v1/get-leads/": + return httpx.Response( + 200, + json={ + "pagination": {"lastId": 321}, + "result": [ + { + "id": 12, + "subscriptionId": 44, + "payload": {"vin": "VIN-1", "itemId": 901, "brand": "Audi"}, + } + ], + }, + ) + if path == "/autoteka/v1/previews": + return httpx.Response(200, json={"result": {"preview": {"previewId": 77}}}) + if path == "/autoteka/v1/request-preview-by-item-id": + return httpx.Response(200, json={"result": {"preview": {"previewId": 78}}}) + if path == "/autoteka/v1/request-preview-by-regnumber": + return httpx.Response(200, json={"result": {"preview": {"previewId": 79}}}) + if path == "/autoteka/v1/request-preview-by-external-item": + return httpx.Response(200, json={"result": {"preview": {"previewId": 80}}}) + if path == "/autoteka/v1/previews/77": + return httpx.Response( + 200, + json={ + "result": { + "preview": { + "previewId": 77, + "status": "success", + "vin": "VIN-1", + "regNumber": "A123AA77", + } + } + }, + ) + if path == "/autoteka/v1/specifications/by-plate-number": + return httpx.Response(200, json={"result": {"specification": {"specificationId": 501}}}) + if path == "/autoteka/v1/specifications/by-vehicle-id": + return httpx.Response(200, json={"result": {"specification": {"specificationId": 502}}}) + if path == "/autoteka/v1/specifications/specification/501": + return httpx.Response( + 200, + json={ + "result": { + "specification": { + "specificationId": 501, + "status": "success", + "vehicleId": "VIN-1", + } + } + }, + ) + if path == "/autoteka/v1/teasers": + return httpx.Response( + 200, + json={"result": {"teaser": {"teaserId": 601, "status": "processing"}}}, + ) + return httpx.Response( + 200, + json={ + "teaserId": 601, + "status": "success", + "data": {"brand": "Audi", "model": "A4", "year": 2018}, + }, + ) + + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + base_url="https://api.avito.ru", + ) + transport = AsyncTransport(AvitoSettings(), client=http_client) + vehicle = AsyncAutotekaVehicle(transport, vehicle_id="77") + + assert (await vehicle.resolve_catalog(brand_id=1)).items[0].values[0].label == "Audi" + assert (await vehicle.get_leads(subscription_id=44, limit=1)).last_id == 321 + assert (await vehicle.create_preview_by_vin(vin="VIN-1")).preview_id == "77" + assert (await vehicle.create_preview_by_item_id(item_id=901)).preview_id == "78" + assert (await vehicle.create_preview_by_reg_number(reg_number="A123AA77")).preview_id == "79" + assert ( + await vehicle.create_preview_by_external_item(item_id="ext-1", site="cars.example") + ).preview_id == "80" + assert (await vehicle.get_preview()).vehicle_id == "VIN-1" + assert ( + await vehicle.create_specification_by_plate_number(plate_number="A123AA77") + ).specification_id == "501" + assert ( + await vehicle.create_specification_by_vehicle_id(vehicle_id="VIN-1") + ).specification_id == "502" + assert (await vehicle.get_specification_by_id(specification_id="501")).status == "success" + assert (await vehicle.create_teaser(vehicle_id="VIN-1")).teaser_id == "601" + assert (await vehicle.get_teaser(teaser_id="601")).brand == "Audi" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_autoteka_report_monitoring_scoring_and_valuation_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/autoteka/v1/packages/active_package": + return httpx.Response( + 200, + json={ + "result": { + "package": { + "createdTime": "2026-04-01", + "expireTime": "2026-05-01", + "reportsCnt": 100, + "reportsCntRemain": 77, + } + } + }, + ) + if path == "/autoteka/v1/reports": + return httpx.Response( + 200, + json={"result": {"report": {"reportId": 701, "status": "processing"}}}, + ) + if path == "/autoteka/v1/reports-by-vehicle-id": + return httpx.Response( + 200, + json={"result": {"report": {"reportId": 702, "status": "processing"}}}, + ) + if path == "/autoteka/v1/reports/list/": + return httpx.Response( + 200, + json={ + "result": [ + {"reportId": 701, "vin": "VIN-1", "createdAt": "2026-04-18 12:00:00"} + ] + }, + ) + if path == "/autoteka/v1/reports/701": + return httpx.Response( + 200, + json={ + "result": { + "report": { + "reportId": 701, + "status": "success", + "webLink": "https://autoteka/web/701", + "pdfLink": "https://autoteka/pdf/701", + "data": {"vin": "VIN-1"}, + } + } + }, + ) + if path == "/autoteka/v1/sync/create-by-regnumber": + return httpx.Response( + 200, + json={ + "result": { + "report": {"reportId": 703, "status": "success", "data": {"vin": "VIN-1"}} + } + }, + ) + if path == "/autoteka/v1/sync/create-by-vin": + return httpx.Response( + 200, + json={ + "result": { + "report": {"reportId": 704, "status": "success", "data": {"vin": "VIN-1"}} + } + }, + ) + if path == "/autoteka/v1/monitoring/bucket/add": + return httpx.Response( + 200, + json={ + "result": { + "isOk": True, + "invalidVehicles": [{"vehicleID": "bad-vin", "description": "invalid"}], + } + }, + ) + if path == "/autoteka/v1/monitoring/bucket/delete": + return httpx.Response(200, json={"result": {"isOk": True}}) + if path == "/autoteka/v1/monitoring/bucket/remove": + return httpx.Response(200, json={"result": {"isOk": True, "invalidVehicles": []}}) + if path == "/autoteka/v1/monitoring/get-reg-actions/": + return httpx.Response( + 200, + json={ + "data": [ + { + "vin": "VIN-1", + "brand": "Audi", + "model": "A4", + "year": 2018, + "operationCode": 11, + "operationDateFrom": "2026-04-01T00:00:00+03:00", + } + ], + "pagination": {"hasNext": True, "nextCursor": "cursor-2"}, + }, + ) + if path == "/autoteka/v1/scoring/by-vehicle-id": + return httpx.Response(200, json={"result": {"scoring": {"scoringId": 801}}}) + if path == "/autoteka/v1/scoring/801": + return httpx.Response( + 200, + json={"result": {"risksAssessment": {"scoringId": 801, "isCompleted": True}}}, + ) + return httpx.Response( + 200, + json={ + "result": { + "status": "success", + "vehicleId": "VIN-1", + "brand": "Audi", + "model": "A4", + "year": 2018, + "valuation": {"avgPriceWithCondition": 2100000}, + } + }, + ) + + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + base_url="https://api.avito.ru", + ) + transport = AsyncTransport(AvitoSettings(), client=http_client) + report = AsyncAutotekaReport(transport, report_id="701") + monitoring = AsyncAutotekaMonitoring(transport) + scoring = AsyncAutotekaScoring(transport, scoring_id="801") + valuation = AsyncAutotekaValuation(transport) + + assert (await report.get_active_package()).reports_remaining == 77 + assert (await report.create_report(preview_id=77)).report_id == "701" + assert (await report.create_report_by_vehicle_id(vehicle_id="VIN-1")).report_id == "702" + assert (await report.list_reports()).items[0].vehicle_id == "VIN-1" + assert (await report.get_report()).web_link == "https://autoteka/web/701" + assert ( + await report.create_sync_report_by_reg_number(reg_number="A123AA77") + ).status == "success" + assert (await report.create_sync_report_by_vin(vin="VIN-1")).report_id == "704" + assert ( + await monitoring.create_monitoring_bucket_add(vehicles=["VIN-1", "bad-vin"]) + ).invalid_vehicles[0].vehicle_id == "bad-vin" + assert (await monitoring.delete_bucket()).success is True + assert (await monitoring.remove_bucket(vehicles=["VIN-1"])).success is True + assert (await monitoring.get_monitoring_reg_actions(limit=10)).items[0].operation_code == 11 + assert (await scoring.create_scoring_by_vehicle_id(vehicle_id="VIN-1")).scoring_id == "801" + assert (await scoring.get_scoring_by_id()).is_completed is True + assert ( + await valuation.get_valuation_by_specification(specification_id=501, mileage=30000) + ).avg_price_with_condition == 2100000 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_autoteka_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client(authenticated=True) + + assert isinstance(client.autoteka_vehicle("VIN-1"), AsyncAutotekaVehicle) + assert isinstance(client.autoteka_report("701"), AsyncAutotekaReport) + assert isinstance(client.autoteka_monitoring(), AsyncAutotekaMonitoring) + assert isinstance(client.autoteka_scoring("801"), AsyncAutotekaScoring) + assert isinstance(client.autoteka_valuation(), AsyncAutotekaValuation) + await client.aclose() + + +def test_async_client_autoteka_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(), + client_id="id", + client_secret="secret", + ) + + with pytest.raises(RuntimeError, match="async with"): + client.autoteka_vehicle() From 39cefebcb5790daced493f311e7be5e94ade1127 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 21:30:15 +0300 Subject: [PATCH 21/26] M11 --- CHANGELOG.d/0000-async-ads.md | 2 + avito/ads/__init__.py | 14 + avito/ads/async_domain.py | 1634 +++++++++++++++++ avito/async_client.py | 46 + .../contracts/test_async_swagger_contracts.py | 8 +- tests/domains/ads/test_ads_async.py | 423 +++++ 6 files changed, 2126 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.d/0000-async-ads.md create mode 100644 avito/ads/async_domain.py create mode 100644 tests/domains/ads/test_ads_async.py diff --git a/CHANGELOG.d/0000-async-ads.md b/CHANGELOG.d/0000-async-ads.md new file mode 100644 index 0000000..ad09926 --- /dev/null +++ b/CHANGELOG.d/0000-async-ads.md @@ -0,0 +1,2 @@ +### Added +- Async-поддержка домена ads: AsyncAd, AsyncAdStats, AsyncAdPromotion, AsyncAutoloadProfile, AsyncAutoloadReport, AsyncAutoloadArchive (#0000) diff --git a/avito/ads/__init__.py b/avito/ads/__init__.py index 1318d22..e1dcde9 100644 --- a/avito/ads/__init__.py +++ b/avito/ads/__init__.py @@ -1,5 +1,13 @@ """Пакет ads.""" +from avito.ads.async_domain import ( + AsyncAd, + AsyncAdPromotion, + AsyncAdStats, + AsyncAutoloadArchive, + AsyncAutoloadProfile, + AsyncAutoloadReport, +) from avito.ads.domain import ( Ad, AdPromotion, @@ -57,6 +65,12 @@ "AdsActionStatus", "AdPromotion", "AdStats", + "AsyncAd", + "AsyncAdPromotion", + "AsyncAdStats", + "AsyncAutoloadArchive", + "AsyncAutoloadProfile", + "AsyncAutoloadReport", "AutoloadArchive", "AutoloadAvitoStatus", "AutoloadFieldType", diff --git a/avito/ads/async_domain.py b/avito/ads/async_domain.py new file mode 100644 index 0000000..2860204 --- /dev/null +++ b/avito/ads/async_domain.py @@ -0,0 +1,1634 @@ +"""Async-доменные объекты пакета ads.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from avito.ads.models import ( + AccountSpendings, + AdAnalyticsGroupingInput, + AdsActionResult, + AdSpendingsGroupingInput, + ApplyVasDirectRequest, + ApplyVasPackageRequest, + ApplyVasRequest, + AutoloadFeesResult, + AutoloadFieldsResult, + AutoloadProfileSettings, + AutoloadProfileUpdateRequest, + AutoloadReportDetails, + AutoloadReportItemsResult, + AutoloadReportSummary, + AutoloadTreeResult, + CallsStatsRequest, + CallsStatsResult, + IdMappingResult, + ItemAnalyticsRequest, + ItemAnalyticsResult, + ItemStatsRequest, + ItemStatsResult, + LegacyAutoloadReport, + Listing, + ListingStatus, + SpendingsRequest, + UpdatePriceRequest, + UpdatePriceResult, + UploadByUrlRequest, + UploadResult, + VasPricesRequest, + VasPricesResult, +) +from avito.ads.operations import ( + APPLY_ITEM_VAS, + APPLY_ITEM_VAS_PACKAGE, + APPLY_VAS_DIRECT, + GET_ACCOUNT_SPENDINGS, + GET_AD_IDS_BY_AVITO_IDS, + GET_ARCHIVE_LAST_COMPLETED_REPORT, + GET_ARCHIVE_PROFILE, + GET_ARCHIVE_REPORT, + GET_AUTOLOAD_ITEMS_INFO, + GET_AUTOLOAD_LAST_COMPLETED_REPORT, + GET_AUTOLOAD_NODE_FIELDS, + GET_AUTOLOAD_PROFILE, + GET_AUTOLOAD_REPORT, + GET_AUTOLOAD_REPORT_FEES, + GET_AUTOLOAD_REPORT_ITEMS, + GET_AUTOLOAD_TREE, + GET_AVITO_IDS_BY_AD_IDS, + GET_CALLS_STATS, + GET_ITEM, + GET_ITEM_ANALYTICS, + GET_ITEM_STATS, + GET_VAS_PRICES, + LIST_AUTOLOAD_REPORTS, + LIST_ITEMS, + SAVE_ARCHIVE_PROFILE, + SAVE_AUTOLOAD_PROFILE, + UPDATE_PRICE, + UPLOAD_BY_URL, +) +from avito.core import ( + ApiTimeouts, + AsyncPaginatedList, + AsyncPaginator, + JsonPage, + RetryOverride, + ValidationError, +) +from avito.core.deprecation import deprecated_method +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import ( + DateInput, + serialize_iso_date, + validate_non_empty_string, + validate_string_items, +) +from avito.promotion.models import PromotionActionResult, PromotionStatus + + +def _preview_result( + *, + action: str, + target: dict[str, object], + request_payload: dict[str, object], +) -> PromotionActionResult: + return PromotionActionResult( + action=action, + target=target, + status=PromotionStatus.PREVIEW, + applied=False, + request_payload=request_payload, + details={"validated": True}, + ) + + +StatsDate = DateInput + + +def _serialize_stats_date(value: StatsDate) -> str: + return serialize_iso_date("date", value) + + +def _bounded_total(total: int | None, max_items: int | None) -> int | None: + if max_items is None: + return total + if total is None: + return None + return min(total, max_items) + + +def _has_next_ads_page( + *, + page_item_count: int, + collected_count: int, + page_size: int, + total: int | None, + max_items: int | None, + already_collected: int, +) -> bool: + if page_item_count == 0 or page_size <= 0: + return False + if max_items is not None and already_collected + collected_count >= max_items: + return False + if total is not None: + return already_collected + collected_count < min(total, max_items or total) + return page_item_count >= page_size + + +@dataclass(slots=True, frozen=True) +class AsyncAd(AsyncDomainObject): + """Доменный объект объявления.""" + + __swagger_domain__ = "ads" + __sdk_factory__ = "ad" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/core/v1/accounts/{user_id}/items/{item_id}", + spec="Объявления.json", + operation_id="getItemInfo", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> Listing: + """Получает объявление по `item_id`. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `Listing` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + item_id, user_id = await self._require_ids() + return await self._execute( + GET_ITEM, + path_params={"user_id": user_id, "item_id": item_id}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/core/v1/items", + spec="Объявления.json", + operation_id="getItemsInfo", + variant="async", + ) + async def list( + self, + *, + status: ListingStatus | str | None = None, + limit: int | None = None, + page_size: int | None = None, + offset: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AsyncPaginatedList[Listing]: + """Возвращает объявления аккаунта с ленивой пагинацией. + + Аргументы: + status: фильтрует результат по статусу. + limit: ограничивает размер возвращаемой выборки. + page_size: задает размер страницы для ленивой пагинации. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + Ленивый `AsyncPaginatedList[Listing]`; первая страница загружается при создании, следующие страницы - при итерации. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + user_id = await self._resolve_user_id(self.user_id) + resolved_page_size = page_size or limit + start_offset = offset or 0 + first_page_number = ( + start_offset // resolved_page_size + 1 + if resolved_page_size is not None and resolved_page_size > 0 + else 1 + ) + result = await self._execute( + LIST_ITEMS, + query={ + "user_id": user_id, + "status": status, + "per_page": resolved_page_size, + "page": first_page_number, + }, + timeout=timeout, + retry=retry, + ) + list_result = result + page_size = ( + resolved_page_size + if resolved_page_size and resolved_page_size > 0 + else len(list_result.items) + ) + max_items = limit if limit is not None and limit >= 0 else None + page_offset = start_offset % page_size if page_size > 0 else 0 + available_items = list_result.items[page_offset:] + first_items = available_items[:max_items] if max_items is not None else available_items + first_page = JsonPage( + items=list(first_items), + total=_bounded_total(list_result.total, max_items), + source_total=list_result.total, + page=first_page_number, + per_page=page_size if page_size > 0 else None, + has_next_page=_has_next_ads_page( + page_item_count=len(list_result.items), + collected_count=len(first_items), + page_size=page_size, + total=list_result.total, + max_items=max_items, + already_collected=0, + ), + ) + return AsyncPaginator( + lambda page, cursor: self._fetch_ads_page( + page=page, + user_id=user_id, + status=status, + page_size=page_size, + max_items=max_items, + first_page_number=first_page_number, + ) + ).as_list(start_page=first_page_number, first_page=first_page) + + @swagger_operation( + "POST", + "/core/v1/items/{item_id}/update_price", + spec="Объявления.json", + operation_id="updatePrice", + variant="async", + method_args={"price": "body.price"}, + ) + async def update_price( + self, + *, + price: int | float, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> UpdatePriceResult: + """Обновляет цену текущего объявления. + + Аргументы: + price: новое значение цены. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `UpdatePriceResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + item_id = self._require_item_id() + return await self._execute( + UPDATE_PRICE, + path_params={"item_id": item_id}, + request=UpdatePriceRequest(price=price), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + async def _fetch_ads_page( + self, + *, + page: int | None, + user_id: int | None, + status: ListingStatus | str | None, + page_size: int, + max_items: int | None, + first_page_number: int, + ) -> JsonPage[Listing]: + if page is None: + raise ValidationError("Для операции требуется `page`.") + + already_collected = max(page - first_page_number, 0) * page_size + remaining = max_items - already_collected if max_items is not None else None + if remaining is not None and remaining <= 0: + return JsonPage(items=[], total=max_items, page=page, per_page=page_size) + result = await self._execute( + LIST_ITEMS, + query={ + "user_id": user_id, + "status": status, + "per_page": min(page_size, remaining) if remaining is not None else page_size, + "page": page, + }, + ) + list_result = result + items = list_result.items[:remaining] if remaining is not None else list_result.items + return JsonPage( + items=list(items), + total=_bounded_total(list_result.total, max_items), + source_total=list_result.total, + page=page, + per_page=page_size, + has_next_page=_has_next_ads_page( + page_item_count=len(list_result.items), + collected_count=len(items), + page_size=page_size, + total=list_result.total, + max_items=max_items, + already_collected=already_collected, + ), + ) + + def _require_item_id(self) -> int: + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return int(self.item_id) + + async def _require_ids(self) -> tuple[int, int]: + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return int(self.item_id), await self._resolve_user_id(self.user_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAdStats(AsyncDomainObject): + """Доменный объект статистики объявлений.""" + + __swagger_domain__ = "ads" + __sdk_factory__ = "ad_stats" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/core/v1/accounts/{user_id}/calls/stats", + spec="Объявления.json", + operation_id="postCallsStats", + variant="async", + method_args={ + "date_from": "body.dateFrom", + "date_to": "body.dateTo", + }, + ) + async def get_calls_stats( + self, + *, + date_from: StatsDate, + date_to: StatsDate, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CallsStatsResult: + """Получает статистику звонков. + + Аргументы: + date_from: начальная дата или дата-время периода. + date_to: конечная дата или дата-время периода. + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CallsStatsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + user_id = await self._require_user_id() + resolved_item_ids = item_ids or ([int(self.item_id)] if self.item_id is not None else []) + return await self._execute( + GET_CALLS_STATS, + path_params={"user_id": user_id}, + request=CallsStatsRequest( + item_ids=resolved_item_ids, + date_from=_serialize_stats_date(date_from), + date_to=_serialize_stats_date(date_to), + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/stats/v1/accounts/{user_id}/items", + spec="Объявления.json", + operation_id="itemStatsShallow", + variant="async", + method_args={ + "date_from": "body.dateFrom", + "date_to": "body.dateTo", + }, + ) + async def get_item_stats( + self, + *, + date_from: StatsDate, + date_to: StatsDate, + item_ids: list[int] | None = None, + fields: list[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ItemStatsResult: + """Получает статистику по списку объявлений. + + Аргументы: + date_from: начальная дата или дата-время периода. + date_to: конечная дата или дата-время периода. + item_ids: список идентификаторов объявлений. + fields: список запрошенных полей. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ItemStatsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + user_id = await self._require_user_id() + resolved_item_ids = item_ids or ([int(self.item_id)] if self.item_id is not None else []) + return await self._execute( + GET_ITEM_STATS, + path_params={"user_id": user_id}, + request=ItemStatsRequest( + item_ids=resolved_item_ids, + date_from=_serialize_stats_date(date_from), + date_to=_serialize_stats_date(date_to), + fields=fields or [], + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/stats/v2/accounts/{user_id}/items", + spec="Объявления.json", + operation_id="itemAnalytics", + variant="async", + method_args={ + "date_from": "body.dateFrom", + "date_to": "body.dateTo", + "metrics": "body.metrics", + "grouping": "body.grouping", + "limit": "body.limit", + "offset": "body.offset", + }, + ) + async def get_item_analytics( + self, + *, + date_from: StatsDate, + date_to: StatsDate, + metrics: list[str], + grouping: AdAnalyticsGroupingInput, + limit: int, + offset: int, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ItemAnalyticsResult: + """Получает аналитику по профилю. + + Аргументы: + date_from: начальная дата или дата-время периода. + date_to: конечная дата или дата-время периода. + metrics: список метрик статистики, которые нужно вернуть. + grouping: группировка статистики или расходов. + limit: максимальное количество элементов в ответе. + offset: смещение выборки. + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ItemAnalyticsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + user_id = await self._require_user_id() + return await self._execute( + GET_ITEM_ANALYTICS, + path_params={"user_id": user_id}, + request=ItemAnalyticsRequest( + date_from=_serialize_stats_date(date_from), + date_to=_serialize_stats_date(date_to), + metrics=metrics, + grouping=grouping, + limit=limit, + offset=offset, + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/stats/v2/accounts/{user_id}/spendings", + spec="Объявления.json", + operation_id="accountSpendings", + variant="async", + method_args={ + "date_from": "body.dateFrom", + "date_to": "body.dateTo", + "spending_types": "body.spendingTypes", + "grouping": "body.grouping", + }, + ) + async def get_account_spendings( + self, + *, + date_from: StatsDate, + date_to: StatsDate, + spending_types: list[str], + grouping: AdSpendingsGroupingInput, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AccountSpendings: + """Получает статистику расходов профиля. + + Аргументы: + date_from: начальная дата или дата-время периода. + date_to: конечная дата или дата-время периода. + spending_types: типы расходов, включаемые в отчет. + grouping: группировка статистики или расходов. + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AccountSpendings` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + user_id = await self._require_user_id() + resolved_item_ids = item_ids or ([int(self.item_id)] if self.item_id is not None else []) + return await self._execute( + GET_ACCOUNT_SPENDINGS, + path_params={"user_id": user_id}, + request=SpendingsRequest( + date_from=_serialize_stats_date(date_from), + date_to=_serialize_stats_date(date_to), + spending_types=spending_types, + grouping=grouping, + item_ids=resolved_item_ids, + ), + timeout=timeout, + retry=retry, + ) + + async def _require_user_id(self) -> int: + return await self._resolve_user_id(self.user_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAdPromotion(AsyncDomainObject): + """Доменный объект продвижения объявления.""" + + __swagger_domain__ = "ads" + __sdk_factory__ = "ad_promotion" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/core/v1/accounts/{user_id}/vas/prices", + spec="Объявления.json", + operation_id="vasPrices", + variant="async", + method_args={"item_ids": "body.item_ids"}, + ) + async def get_vas_prices( + self, + *, + item_ids: list[int], + location_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VasPricesResult: + """Получает цены продвижения и доступные услуги. + + Аргументы: + item_ids: список идентификаторов объявлений. + location_id: идентификатор локации для расчета доступности или цены услуги. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VasPricesResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + user_id = await self._require_user_id() + return await self._execute( + GET_VAS_PRICES, + path_params={"user_id": user_id}, + request=VasPricesRequest(item_ids=item_ids, location_id=location_id), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "PUT", + "/core/v1/accounts/{user_id}/items/{item_id}/vas", + spec="Объявления.json", + operation_id="putItemVas", + variant="async", + method_args={"vas_id": "body.vas_id"}, + ) + async def apply_vas( + self, + *, + vas_id: str, + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Применяет дополнительные услуги к объявлению. + + Аргументы: + vas_id: идентификатор VAS-услуги. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + item_id, user_id = await self._require_ids() + validate_non_empty_string("vas_id", vas_id) + request_payload = ApplyVasRequest(vas_id=vas_id).to_payload() + target: dict[str, object] = {"item_id": item_id, "user_id": user_id} + if dry_run: + return _preview_result( + action="apply_vas", + target=target, + request_payload=request_payload, + ) + payload = await self._execute( + APPLY_ITEM_VAS, + path_params={"user_id": user_id, "item_id": item_id}, + request=ApplyVasRequest(vas_id=vas_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="apply_vas", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "PUT", + "/core/v2/accounts/{user_id}/items/{item_id}/vas_packages", + spec="Объявления.json", + operation_id="putItemVasPackageV2", + variant="async", + method_args={"package_code": "body.package_id"}, + ) + async def apply_vas_package( + self, + *, + package_code: str, + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Применяет пакет дополнительных услуг. + + Аргументы: + package_code: код пакета продвижения. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + item_id, user_id = await self._require_ids() + validate_non_empty_string("package_code", package_code) + request_payload = ApplyVasPackageRequest(package_code=package_code).to_payload() + target: dict[str, object] = {"item_id": item_id, "user_id": user_id} + if dry_run: + return _preview_result( + action="apply_vas_package", + target=target, + request_payload=request_payload, + ) + payload = await self._execute( + APPLY_ITEM_VAS_PACKAGE, + path_params={"user_id": user_id, "item_id": item_id}, + request=ApplyVasPackageRequest(package_code=package_code), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="apply_vas_package", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "PUT", + "/core/v2/items/{item_id}/vas", + spec="Объявления.json", + operation_id="applyVas", + variant="async", + method_args={"slugs": "body.slugs"}, + ) + async def apply_vas_direct( + self, + *, + slugs: list[str], + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Применяет услуги продвижения через прямой v2 endpoint. + + Аргументы: + slugs: slug-идентификаторы узлов дерева категорий. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + item_id = self._require_item_id() + validate_string_items("slugs", slugs) + request_payload = ApplyVasDirectRequest(slugs=slugs).to_payload() + target: dict[str, object] = {"item_id": item_id} + if dry_run: + return _preview_result( + action="apply_vas_direct", + target=target, + request_payload=request_payload, + ) + payload = await self._execute( + APPLY_VAS_DIRECT, + path_params={"item_id": item_id}, + request=ApplyVasDirectRequest(slugs=slugs), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="apply_vas_direct", + target=target, + request_payload=request_payload, + ) + + def _require_item_id(self) -> int: + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return int(self.item_id) + + async def _require_user_id(self) -> int: + return await self._resolve_user_id(self.user_id) + + async def _require_ids(self) -> tuple[int, int]: + return self._require_item_id(), await self._require_user_id() + + +@dataclass(slots=True, frozen=True) +class AsyncAutoloadProfile(AsyncDomainObject): + """Доменный объект профиля автозагрузки.""" + + __swagger_domain__ = "ads" + __sdk_factory__ = "autoload_profile" + __sdk_factory_args__ = {"user_id": "path.user_id"} + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/autoload/v2/profile", + spec="Автозагрузка.json", + operation_id="getProfileV2", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadProfileSettings: + """Получает профиль автозагрузки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadProfileSettings` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_AUTOLOAD_PROFILE, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/autoload/v2/profile", + spec="Автозагрузка.json", + operation_id="createOrUpdateProfileV2", + variant="async", + method_args={ + "is_enabled": "body.autoload_enabled", + "feed_url": "body.feeds_data", + "report_email": "body.report_email", + "schedule_rate": "body.schedule[].rate", + }, + ) + async def save( + self, + *, + is_enabled: bool, + feed_url: str, + report_email: str, + schedule_rate: int, + schedule_weekdays: list[int] | None = None, + schedule_time_slots: list[int] | None = None, + feed_name: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AdsActionResult: + """Сохраняет профиль автозагрузки. + + Аргументы: + is_enabled: включает или отключает профиль автозагрузки. + feed_url: URL фида автозагрузки. + report_email: email для отправки отчетов автозагрузки. + schedule_rate: ставка расписания продвижения. + schedule_weekdays: дни недели для расписания; если не передано, используется полный недельный набор. + schedule_time_slots: временные интервалы расписания; если не передано, используется первый слот. + feed_name: имя фида автозагрузки. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AdsActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SAVE_AUTOLOAD_PROFILE, + request=AutoloadProfileUpdateRequest( + is_enabled=is_enabled, + report_email=report_email, + schedule_rate=schedule_rate, + schedule_weekdays=schedule_weekdays or [0, 1, 2, 3, 4, 5, 6], + schedule_time_slots=schedule_time_slots or [0], + feed_name=feed_name, + feed_url=feed_url, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoload/v1/upload", + spec="Автозагрузка.json", + operation_id="upload", + variant="async", + method_args={"url": "constant.url"}, + ) + async def upload_by_url( + self, + *, + url: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> UploadResult: + """Загружает файл по ссылке. + + Аргументы: + url: URL источника данных. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `UploadResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPLOAD_BY_URL, + request=UploadByUrlRequest(url=url), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v1/user-docs/tree", + spec="Автозагрузка.json", + operation_id="userDocsTree", + variant="async", + ) + async def get_tree( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadTreeResult: + """Получает дерево категорий. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadTreeResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_AUTOLOAD_TREE, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/autoload/v1/user-docs/node/{node_slug}/fields", + spec="Автозагрузка.json", + operation_id="userDocsNodeFields", + variant="async", + method_args={"node_slug": "path.node_slug"}, + ) + async def get_node_fields( + self, + *, + node_slug: str, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutoloadFieldsResult: + """Получает поля категории. + + Аргументы: + node_slug: slug узла дерева категорий. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadFieldsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AUTOLOAD_NODE_FIELDS, + path_params={"node_slug": node_slug}, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncAutoloadReport(AsyncDomainObject): + """Доменный объект отчета автозагрузки.""" + + __swagger_domain__ = "ads" + __sdk_factory__ = "autoload_report" + __sdk_factory_args__ = {"report_id": "path.report_id"} + + report_id: int | str | None = None + + @swagger_operation( + "GET", + "/autoload/v3/reports/{report_id}", + spec="Автозагрузка.json", + operation_id="getReportByIdV3", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadReportDetails: + """Получает конкретный отчет v3. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadReportDetails` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + report_id = self._require_report_id() + return await self._execute( + GET_AUTOLOAD_REPORT, + path_params={"report_id": report_id}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v2/reports", + spec="Автозагрузка.json", + operation_id="getReportsV2", + variant="async", + ) + async def list( + self, + *, + limit: int | None = None, + offset: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AsyncPaginatedList[AutoloadReportSummary]: + """Возвращает отчеты Автозагрузки с ленивой пагинацией. + + Аргументы: + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + Ленивый `AsyncPaginatedList[AutoloadReportSummary]`; первая страница загружается при создании, следующие страницы - при итерации. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + page_size = limit or 25 + base_offset = offset or 0 + + async def fetch_page( + page: int | None, _cursor: str | None + ) -> JsonPage[AutoloadReportSummary]: + current_page = page or 1 + current_offset = base_offset + (current_page - 1) * page_size + result = await self._execute( + LIST_AUTOLOAD_REPORTS, + query={"limit": page_size, "offset": current_offset}, + timeout=timeout, + retry=retry, + ) + reports = result + return JsonPage( + items=reports.items, + total=reports.total, + page=current_page, + per_page=page_size, + ) + + return AsyncPaginator(fetch_page).as_list(first_page=await fetch_page(1, None)) + + @swagger_operation( + "GET", + "/autoload/v3/reports/last_completed_report", + spec="Автозагрузка.json", + operation_id="getLastCompletedReportV3", + variant="async", + ) + async def get_last_completed( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadReportDetails: + """Получает последний завершенный отчет. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadReportDetails` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_AUTOLOAD_LAST_COMPLETED_REPORT, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/autoload/v2/reports/{report_id}/items", + spec="Автозагрузка.json", + operation_id="getReportItemsById", + variant="async", + ) + async def get_items( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadReportItemsResult: + """Возвращает позиции выбранного отчета Автозагрузки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadReportItemsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + report_id = self._require_report_id() + return await self._execute( + GET_AUTOLOAD_REPORT_ITEMS, + path_params={"report_id": report_id}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v2/reports/{report_id}/items/fees", + spec="Автозагрузка.json", + operation_id="getReportItemsFeesById", + variant="async", + ) + async def get_fees( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadFeesResult: + """Получает списания по объявлениям отчета. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadFeesResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + report_id = self._require_report_id() + return await self._execute( + GET_AUTOLOAD_REPORT_FEES, + path_params={"report_id": report_id}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v2/items/ad_ids", + spec="Автозагрузка.json", + operation_id="getAdIdsByAvitoIds", + variant="async", + method_args={"avito_ids": "query.query"}, + ) + async def get_ad_ids_by_avito_ids( + self, + *, + avito_ids: Sequence[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> IdMappingResult: + """Получает ad ids по avito ids. + + Аргументы: + avito_ids: список идентификаторов объявлений на Avito. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `IdMappingResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AD_IDS_BY_AVITO_IDS, + query={"query": ",".join(str(item) for item in avito_ids)}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v2/items/avito_ids", + spec="Автозагрузка.json", + operation_id="getAvitoIdsByAdIds", + variant="async", + method_args={"ad_ids": "query.query"}, + ) + async def get_avito_ids_by_ad_ids( + self, + *, + ad_ids: Sequence[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> IdMappingResult: + """Получает avito ids по ad ids. + + Аргументы: + ad_ids: список внешних идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `IdMappingResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AVITO_IDS_BY_AD_IDS, + query={"query": ",".join(str(item) for item in ad_ids)}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v2/reports/items", + spec="Автозагрузка.json", + operation_id="getAutoloadItemsInfoV2", + variant="async", + method_args={"item_ids": "query.query"}, + ) + async def get_items_info( + self, + *, + item_ids: Sequence[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutoloadReportItemsResult: + """Получает информацию по объявлениям автозагрузки. + + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadReportItemsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AUTOLOAD_ITEMS_INFO, + query={"query": ",".join(str(item) for item in item_ids)}, + timeout=timeout, + retry=retry, + ) + + def _require_report_id(self) -> int: + if self.report_id is None: + raise ValidationError("Для операции требуется `report_id`.") + return int(self.report_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAutoloadArchive(AsyncDomainObject): + """Доменный объект архивных операций автозагрузки.""" + + __swagger_domain__ = "ads" + __sdk_factory__ = "autoload_archive" + __sdk_factory_args__ = {"report_id": "path.report_id"} + + report_id: int | str | None = None + + @swagger_operation( + "GET", + "/autoload/v1/profile", + spec="Автозагрузка.json", + operation_id="getProfile", + variant="async", + deprecated=True, + legacy=True, + ) + @deprecated_method( + symbol="AsyncAutoloadArchive.get_profile", + replacement="autoload_profile().get", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def get_profile( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadProfileSettings: + """Получает архивный профиль автозагрузки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadProfileSettings` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_ARCHIVE_PROFILE, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/autoload/v1/profile", + spec="Автозагрузка.json", + operation_id="createOrUpdateProfile", + variant="async", + deprecated=True, + legacy=True, + method_args={ + "is_enabled": "body.autoload_enabled", + "upload_url": "body.upload_url", + "report_email": "body.report_email", + "schedule_rate": "body.schedule[].rate", + }, + ) + @deprecated_method( + symbol="AsyncAutoloadArchive.save_profile", + replacement="autoload_profile().save", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def save_profile( + self, + *, + is_enabled: bool, + upload_url: str, + report_email: str, + schedule_rate: int, + schedule_weekdays: list[int] | None = None, + schedule_time_slots: list[int] | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AdsActionResult: + """Сохраняет архивный профиль автозагрузки. + + Аргументы: + is_enabled: включает или отключает профиль автозагрузки. + upload_url: URL фида автозагрузки. + report_email: email для отправки отчетов автозагрузки. + schedule_rate: ставка расписания продвижения. + schedule_weekdays: дни недели для расписания; если не передано, используется полный недельный набор. + schedule_time_slots: временные интервалы расписания; если не передано, используется первый слот. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AdsActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SAVE_ARCHIVE_PROFILE, + request=AutoloadProfileUpdateRequest( + is_enabled=is_enabled, + report_email=report_email, + schedule_rate=schedule_rate, + schedule_weekdays=schedule_weekdays or [0, 1, 2, 3, 4, 5, 6], + schedule_time_slots=schedule_time_slots or [0], + upload_url=upload_url, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v2/reports/last_completed_report", + spec="Автозагрузка.json", + operation_id="getLastCompletedReport", + variant="async", + deprecated=True, + legacy=True, + ) + @deprecated_method( + symbol="AsyncAutoloadArchive.get_last_completed_report", + replacement="autoload_report().get_last_completed", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def get_last_completed_report( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> LegacyAutoloadReport: + """Получает архивную статистику по последней выгрузке. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `LegacyAutoloadReport` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_ARCHIVE_LAST_COMPLETED_REPORT, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/autoload/v2/reports/{report_id}", + spec="Автозагрузка.json", + operation_id="getReportByIdV2", + variant="async", + deprecated=True, + legacy=True, + ) + @deprecated_method( + symbol="AsyncAutoloadArchive.get_report", + replacement="autoload_report().get", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def get_report( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> LegacyAutoloadReport: + """Получает архивную статистику по конкретной выгрузке. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `LegacyAutoloadReport` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + report_id = self._require_report_id() + return await self._execute( + GET_ARCHIVE_REPORT, + path_params={"report_id": report_id}, + timeout=timeout, + retry=retry, + ) + + def _require_report_id(self) -> int: + if self.report_id is None: + raise ValidationError("Для операции требуется `report_id`.") + return int(self.report_id) + + +__all__ = ( + "AsyncAd", + "AsyncAdPromotion", + "AsyncAdStats", + "AsyncAutoloadArchive", + "AsyncAutoloadProfile", + "AsyncAutoloadReport", +) diff --git a/avito/async_client.py b/avito/async_client.py index de7251a..7998216 100644 --- a/avito/async_client.py +++ b/avito/async_client.py @@ -7,6 +7,14 @@ import httpx from avito.accounts import AsyncAccount, AsyncAccountHierarchy +from avito.ads import ( + AsyncAd, + AsyncAdPromotion, + AsyncAdStats, + AsyncAutoloadArchive, + AsyncAutoloadProfile, + AsyncAutoloadReport, +) from avito.auth.async_provider import AsyncAuthProvider from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient from avito.auth.settings import AuthSettings @@ -166,6 +174,44 @@ def account_hierarchy(self, user_id: int | str | None = None) -> AsyncAccountHie return AsyncAccountHierarchy(self._require_transport(), user_id=user_id) + def ad(self, item_id: int | str | None = None, user_id: int | str | None = None) -> AsyncAd: + """Создает async-доменный объект объявления.""" + + return AsyncAd(self._require_transport(), item_id=item_id, user_id=user_id) + + def ad_stats( + self, item_id: int | str | None = None, user_id: int | str | None = None + ) -> AsyncAdStats: + """Создает async-доменный объект статистики объявления.""" + + return AsyncAdStats(self._require_transport(), item_id=item_id, user_id=user_id) + + def ad_promotion( + self, item_id: int | str | None = None, user_id: int | str | None = None + ) -> AsyncAdPromotion: + """Создает async-доменный объект продвижения объявления.""" + + return AsyncAdPromotion(self._require_transport(), item_id=item_id, user_id=user_id) + + def autoload_profile(self, user_id: int | str | None = None) -> AsyncAutoloadProfile: + """Создает async-доменный объект профиля автозагрузки.""" + + return AsyncAutoloadProfile(self._require_transport(), user_id=user_id) + + def autoload_report( + self, report_id: int | str | None = None + ) -> AsyncAutoloadReport: + """Создает async-доменный объект отчета автозагрузки.""" + + return AsyncAutoloadReport(self._require_transport(), report_id=report_id) + + def autoload_archive( + self, report_id: int | str | None = None + ) -> AsyncAutoloadArchive: + """Создает async-доменный объект архивных операций автозагрузки.""" + + return AsyncAutoloadArchive(self._require_transport(), report_id=report_id) + def cpa_lead(self) -> AsyncCpaLead: """Создает async-доменный объект CPA-лида.""" diff --git a/tests/contracts/test_async_swagger_contracts.py b/tests/contracts/test_async_swagger_contracts.py index 10379f9..8ac1e38 100644 --- a/tests/contracts/test_async_swagger_contracts.py +++ b/tests/contracts/test_async_swagger_contracts.py @@ -11,12 +11,18 @@ def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: assert {binding.class_name for binding in async_bindings} == { "AsyncAccount", "AsyncAccountHierarchy", + "AsyncAd", + "AsyncAdPromotion", + "AsyncAdStats", "AsyncAlternateTokenClient", "AsyncAutotekaMonitoring", "AsyncAutotekaReport", "AsyncAutotekaScoring", "AsyncAutotekaValuation", "AsyncAutotekaVehicle", + "AsyncAutoloadArchive", + "AsyncAutoloadProfile", + "AsyncAutoloadReport", "AsyncCallTrackingCall", "AsyncChat", "AsyncChatMedia", @@ -48,4 +54,4 @@ def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: "AsyncTokenClient", "AsyncTrxPromotion", } - assert len(async_bindings) == 131 + assert len(async_bindings) == 159 diff --git a/tests/domains/ads/test_ads_async.py b/tests/domains/ads/test_ads_async.py new file mode 100644 index 0000000..a403020 --- /dev/null +++ b/tests/domains/ads/test_ads_async.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +import logging +from datetime import date, datetime + +import httpx +import pytest + +from avito.ads import ( + AsyncAd, + AsyncAdPromotion, + AsyncAdStats, + AsyncAutoloadArchive, + AsyncAutoloadProfile, + AsyncAutoloadReport, +) +from avito.ads.models import AdAnalyticsGrouping, AdSpendingsGrouping, ListingStatus +from avito.core import AsyncPaginatedList, ValidationError +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_ads_list_uses_lazy_pagination_with_first_page_reuse() -> None: + seen_pages: list[str] = [] + + def handler(request: RecordedRequest) -> httpx.Response: + assert request.params["user_id"] == "7" + assert request.params["status"] == "active" + assert request.params["per_page"] == "2" + page = request.params["page"] + seen_pages.append(page) + page_items = { + "1": [{"id": 101, "title": "Смартфон"}, {"id": 102, "title": "Ноутбук"}], + "2": [{"id": 103, "title": "Планшет"}, {"id": 104, "title": "Наушники"}], + "3": [{"id": 105, "title": "Камера"}], + } + return httpx.Response(200, json={"items": page_items[page], "total": 5}) + + fake = AsyncFakeTransport().add("GET", "/core/v1/items", handler) + transport = fake.build() + ad = AsyncAd(transport, user_id=7) + + items = await ad.list(status="active", page_size=2) + + assert isinstance(items, AsyncPaginatedList) + assert seen_pages == ["1"] + assert items.loaded_count == 2 + materialized = await items.materialize() + assert [item.title for item in materialized] == [ + "Смартфон", + "Ноутбук", + "Планшет", + "Наушники", + "Камера", + ] + assert seen_pages == ["1", "2", "3"] + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ads_list_limit_is_total_cap_not_page_size() -> None: + seen_limits: list[str] = [] + seen_pages: list[str] = [] + + def handler(request: RecordedRequest) -> httpx.Response: + seen_limits.append(request.params["per_page"]) + page = request.params["page"] + seen_pages.append(page) + page_items = { + "1": [{"id": 101}, {"id": 102}], + "2": [{"id": 103}], + } + return httpx.Response(200, json={"items": page_items[page], "total": 5}) + + fake = AsyncFakeTransport().add("GET", "/core/v1/items", handler) + transport = fake.build() + ad = AsyncAd(transport, user_id=7) + + items = await ad.list(limit=3, page_size=2) + + assert [item.item_id for item in await items.materialize()] == [101, 102, 103] + assert seen_limits == ["2", "1"] + assert seen_pages == ["1", "2"] + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ads_domain_covers_item_stats_spendings_and_promotion() -> None: + def update_price(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"price": 1500} + return httpx.Response(200, json={"item_id": 101, "price": 1500, "status": "updated"}) + + def apply_vas(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"vas_id": "xl"} + return httpx.Response(200, json={"success": True, "status": "applied"}) + + fake = ( + AsyncFakeTransport() + .add_json( + "GET", + "/core/v1/accounts/7/items/101/", + {"id": 101, "user_id": 7, "title": "Смартфон", "price": 1000, "status": "active"}, + ) + .add("POST", "/core/v1/items/101/update_price", update_price) + .add_json( + "POST", + "/stats/v1/accounts/7/items", + {"items": [{"item_id": 101, "views": 45, "contacts": 5, "favorites": 2}]}, + ) + .add_json( + "POST", + "/core/v1/accounts/7/calls/stats/", + {"items": [{"item_id": 101, "calls": 3, "answered_calls": 2, "missed_calls": 1}]}, + ) + .add_json( + "POST", + "/stats/v2/accounts/7/spendings", + {"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}]}, + ) + .add("PUT", "/core/v1/accounts/7/items/101/vas", apply_vas) + ) + transport = fake.build() + ad = AsyncAd(transport, item_id=101, user_id=7) + stats = AsyncAdStats(transport, item_id=101, user_id=7) + promotion = AsyncAdPromotion(transport, item_id=101, user_id=7) + + item = await ad.get() + updated = await ad.update_price(price=1500) + item_stats = await stats.get_item_stats(date_from="2026-04-01", date_to="2026-04-02") + calls = await stats.get_calls_stats(date_from="2026-04-01", date_to="2026-04-02") + spendings = await stats.get_account_spendings( + date_from="2026-04-01", + date_to="2026-04-02", + spending_types=["promotion"], + grouping=AdSpendingsGrouping.DAY, + ) + applied = await promotion.apply_vas(vas_id="xl") + + assert item.title == "Смартфон" + assert updated.status == "updated" + assert item_stats.items[0].views == 45 + assert calls.items[0].answered_calls == 2 + assert spendings.total == 77.5 + assert applied.status == "applied" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ad_stats_accept_datetime_date_and_iso_string_filters() -> None: + fake = AsyncFakeTransport().add_json( + "POST", + "/stats/v2/accounts/7/items", + {"items": [{"item_id": 101, "views": 10}]}, + ) + transport = fake.build() + stats = AsyncAdStats(transport, item_id=101, user_id=7) + started_at = datetime.fromisoformat("2026-04-18T00:00:00+03:00") + finished_at = date.fromisoformat("2026-04-19") + + await stats.get_item_analytics( + item_ids=[101], + date_from=started_at, + date_to=finished_at, + metrics=["views"], + grouping=AdAnalyticsGrouping.DAY, + limit=100, + offset=0, + ) + + assert fake.last(method="POST", path="/stats/v2/accounts/7/items").json_body == { + "dateFrom": "2026-04-18", + "dateTo": "2026-04-19", + "metrics": ["views"], + "grouping": "day", + "limit": 100, + "offset": 0, + } + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ad_stats_reject_unknown_grouping_before_transport() -> None: + transport = AsyncFakeTransport().build() + stats = AsyncAdStats(transport, item_id=101, user_id=7) + + with pytest.raises(ValidationError, match="grouping"): + await stats.get_item_analytics( + date_from="2026-04-18", + date_to="2026-04-19", + metrics=["views"], + grouping="unknown", + limit=100, + offset=0, + ) + with pytest.raises(ValidationError, match="grouping"): + await stats.get_account_spendings( + date_from="2026-04-18", + date_to="2026-04-19", + spending_types=["promotion"], + grouping="totals", + ) + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ad_mapper_reads_nested_listing_fields() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/core/v1/accounts/7/items/101/", + { + "id": 101, + "userId": 7, + "title": "Смартфон", + "description": "Хорошее состояние", + "price": {"value": 1000}, + "status": {"value": "active"}, + "url": "https://www.avito.ru/item", + "category": {"name": "Телефоны"}, + "location": {"name": "Москва"}, + "publishedAt": "2026-04-18T09:00:00Z", + "updatedAt": "2026-04-19T10:00:00Z", + "isModerated": True, + "visible": True, + }, + ) + transport = fake.build() + + item = await AsyncAd(transport, item_id=101, user_id=7).get() + + assert item.status is ListingStatus.ACTIVE + assert item.price == 1000 + assert item.category == "Телефоны" + assert item.city == "Москва" + assert item.published_at is not None + assert item.updated_at is not None + assert item.is_moderated is True + assert item.is_visible is True + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ads_unknown_enum_maps_to_unknown_and_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/core/v1/accounts/7/items/101/", + { + "id": 101, + "user_id": 7, + "title": "Смартфон", + "price": 1000, + "status": "async-mystery-status", + }, + ) + transport = fake.build() + caplog.set_level(logging.WARNING, logger="avito.core.enums") + ad = AsyncAd(transport, item_id=101, user_id=7) + + first = await ad.get() + second = await ad.get() + + assert first.status is ListingStatus.UNKNOWN + assert second.status is ListingStatus.UNKNOWN + records = [ + record + for record in caplog.records + if getattr(record, "enum", None) == "ads.listing_status" + and getattr(record, "value", None) == "async-mystery-status" + ] + assert len(records) == 1 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_autoload_profile_report_and_archive_map_payloads() -> None: + def save_profile(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "autoload_enabled": True, + "report_email": "report@example.com", + "schedule": [{"rate": 10, "weekdays": [1], "time_slots": [2]}], + "feeds_data": [{"feed_name": "main", "feed_url": "https://example.com/feed.xml"}], + } + assert request.headers["idempotency-key"] == "profile-1" + return httpx.Response(200, json={"success": True, "message": "saved"}) + + fake = ( + AsyncFakeTransport() + .add_json("GET", "/autoload/v2/profile", {"user_id": 7, "is_enabled": True, "url": "x"}) + .add("POST", "/autoload/v2/profile", save_profile) + .add_json("POST", "/autoload/v1/upload", {"success": True, "report_id": 44}) + .add_json("GET", "/autoload/v1/user-docs/tree", {"items": [{"slug": "cars", "title": "Авто"}]}) + .add_json( + "GET", + "/autoload/v1/user-docs/node/cars/fields", + {"fields": [{"slug": "vin", "title": "VIN", "type": "string", "required": True}]}, + ) + .add_json("GET", "/autoload/v3/reports/44", {"id": 44, "status": "finished"}) + .add_json( + "GET", + "/autoload/v3/reports/last_completed_report", + {"id": 45, "status": "finished"}, + ) + .add_json( + "GET", + "/autoload/v2/reports/44/items", + {"items": [{"item_id": 101, "avito_id": 201, "status": "success", "title": "A"}]}, + ) + .add_json( + "GET", + "/autoload/v2/reports/44/items/fees", + {"fees": [{"item_id": 101, "amount": 55.5, "service": "xl"}]}, + ) + .add_json("GET", "/autoload/v2/items/ad_ids", {"items": [{"ad_id": 1, "avito_id": 2}]}) + .add_json("GET", "/autoload/v2/items/avito_ids", {"items": [{"ad_id": 1, "avito_id": 2}]}) + .add_json( + "GET", + "/autoload/v2/reports/items", + {"items": [{"item_id": 101, "avito_id": 201, "status": "success"}]}, + ) + .add_json("GET", "/autoload/v1/profile", {"user_id": 7, "enabled": True}) + .add_json("POST", "/autoload/v1/profile", {"success": True}) + .add_json("GET", "/autoload/v2/reports/last_completed_report", {"id": 46, "status": "finished"}) + .add_json("GET", "/autoload/v2/reports/44", {"id": 44, "status": "finished"}) + ) + transport = fake.build() + profile = AsyncAutoloadProfile(transport, user_id=7) + report = AsyncAutoloadReport(transport, report_id=44) + archive = AsyncAutoloadArchive(transport, report_id=44) + + settings = await profile.get() + saved = await profile.save( + is_enabled=True, + report_email="report@example.com", + schedule_rate=10, + feed_name="main", + feed_url="https://example.com/feed.xml", + schedule_weekdays=[1], + schedule_time_slots=[2], + idempotency_key="profile-1", + ) + upload = await profile.upload_by_url(url="https://example.com/feed.xml") + tree = await profile.get_tree() + fields = await profile.get_node_fields(node_slug="cars") + details = await report.get() + last = await report.get_last_completed() + items = await report.get_items() + fees = await report.get_fees() + ad_ids = await report.get_ad_ids_by_avito_ids(avito_ids=[2]) + avito_ids = await report.get_avito_ids_by_ad_ids(ad_ids=[1]) + info = await report.get_items_info(item_ids=[101]) + with pytest.warns(DeprecationWarning, match="AsyncAutoloadArchive.get_profile"): + archive_profile = await archive.get_profile() + with pytest.warns(DeprecationWarning, match="AsyncAutoloadArchive.save_profile"): + archive_saved = await archive.save_profile( + is_enabled=True, + upload_url="https://example.com/feed.xml", + report_email="report@example.com", + schedule_rate=10, + ) + with pytest.warns( + DeprecationWarning, + match="AsyncAutoloadArchive.get_last_completed_report", + ): + archive_last = await archive.get_last_completed_report() + with pytest.warns(DeprecationWarning, match="AsyncAutoloadArchive.get_report"): + archive_report = await archive.get_report() + + assert settings.user_id == 7 + assert saved.success is True + assert upload.report_id == 44 + assert tree.items[0].slug == "cars" + assert fields.items[0].required is True + assert details.report_id == 44 + assert last.report_id == 45 + assert items.items[0].item_id == 101 + assert fees.total == 55.5 + assert ad_ids.mappings == [(1, 2)] + assert avito_ids.mappings == [(1, 2)] + assert info.items[0].avito_id == 201 + assert archive_profile.user_id == 7 + assert archive_saved.success is True + assert archive_last.report_id == 46 + assert archive_report.report_id == 44 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_autoload_report_list_uses_async_pagination() -> None: + seen_offsets: list[str] = [] + + def handler(request: RecordedRequest) -> httpx.Response: + seen_offsets.append(request.params["offset"]) + reports = { + "5": [{"id": 44, "status": "finished"}], + "6": [{"id": 45, "status": "started"}], + } + return httpx.Response(200, json={"reports": reports[request.params["offset"]], "total": 2}) + + fake = AsyncFakeTransport().add("GET", "/autoload/v2/reports", handler) + transport = fake.build() + + reports = await AsyncAutoloadReport(transport).list(limit=1, offset=5) + + assert isinstance(reports, AsyncPaginatedList) + assert reports.loaded_count == 1 + assert [report.report_id for report in await reports.materialize()] == [44, 45] + assert seen_offsets == ["5", "6"] + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_ads_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client(user_id=7) + + assert isinstance(client.ad(101), AsyncAd) + assert isinstance(client.ad_stats(101), AsyncAdStats) + assert isinstance(client.ad_promotion(101), AsyncAdPromotion) + assert isinstance(client.autoload_profile(), AsyncAutoloadProfile) + assert isinstance(client.autoload_report(44), AsyncAutoloadReport) + assert isinstance(client.autoload_archive(44), AsyncAutoloadArchive) + await client.aclose() From d11185dde5ec1b53d4e22c0d291fbab4e55c7b25 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 21:59:53 +0300 Subject: [PATCH 22/26] Final --- CHANGELOG.d/0000-async-accounts.md | 2 - CHANGELOG.d/0000-async-ads.md | 2 - CHANGELOG.d/0000-async-autoteka.md | 2 - CHANGELOG.d/0000-async-cpa.md | 2 - CHANGELOG.d/0000-async-jobs.md | 2 - CHANGELOG.d/0000-async-messenger.md | 2 - CHANGELOG.d/0000-async-promotion.md | 2 - CHANGELOG.d/0000-async-ratings.md | 2 - CHANGELOG.d/0000-async-realty.md | 2 - CHANGELOG.md | 16 + README.md | 16 +- avito/async_client.py | 453 +++- avito/client.py | 12 +- avito/orders/__init__.py | 14 + avito/orders/async_domain.py | 2274 +++++++++++++++++ avito/testing/__init__.py | 3 +- avito/testing/async_fake_transport.py | 37 +- avito/testing/async_swagger_fake_transport.py | 117 +- docs/site/how-to/.pages | 1 + docs/site/how-to/async.md | 168 ++ docs/site/how-to/index.md | 1 + docs/site/index.md | 4 +- docs/site/reference/client.md | 11 +- docs/site/reference/pagination.md | 6 + docs/site/reference/testing.md | 4 + mkdocs.yml | 2 +- pyproject.toml | 2 +- .../contracts/test_async_swagger_contracts.py | 200 +- tests/domains/orders/test_orders_async.py | 229 ++ tests/test_async_client_aggregators.py | 133 + 30 files changed, 3672 insertions(+), 49 deletions(-) delete mode 100644 CHANGELOG.d/0000-async-accounts.md delete mode 100644 CHANGELOG.d/0000-async-ads.md delete mode 100644 CHANGELOG.d/0000-async-autoteka.md delete mode 100644 CHANGELOG.d/0000-async-cpa.md delete mode 100644 CHANGELOG.d/0000-async-jobs.md delete mode 100644 CHANGELOG.d/0000-async-messenger.md delete mode 100644 CHANGELOG.d/0000-async-promotion.md delete mode 100644 CHANGELOG.d/0000-async-ratings.md delete mode 100644 CHANGELOG.d/0000-async-realty.md create mode 100644 avito/orders/async_domain.py create mode 100644 docs/site/how-to/async.md create mode 100644 tests/domains/orders/test_orders_async.py create mode 100644 tests/test_async_client_aggregators.py diff --git a/CHANGELOG.d/0000-async-accounts.md b/CHANGELOG.d/0000-async-accounts.md deleted file mode 100644 index cfb0fb9..0000000 --- a/CHANGELOG.d/0000-async-accounts.md +++ /dev/null @@ -1,2 +0,0 @@ -### Added -- Async-поддержка домена accounts: AsyncAccount, AsyncAccountHierarchy (#0000) diff --git a/CHANGELOG.d/0000-async-ads.md b/CHANGELOG.d/0000-async-ads.md deleted file mode 100644 index ad09926..0000000 --- a/CHANGELOG.d/0000-async-ads.md +++ /dev/null @@ -1,2 +0,0 @@ -### Added -- Async-поддержка домена ads: AsyncAd, AsyncAdStats, AsyncAdPromotion, AsyncAutoloadProfile, AsyncAutoloadReport, AsyncAutoloadArchive (#0000) diff --git a/CHANGELOG.d/0000-async-autoteka.md b/CHANGELOG.d/0000-async-autoteka.md deleted file mode 100644 index 3afc085..0000000 --- a/CHANGELOG.d/0000-async-autoteka.md +++ /dev/null @@ -1,2 +0,0 @@ -### Added -- Async-поддержка домена autoteka: AsyncAutotekaVehicle, AsyncAutotekaReport, AsyncAutotekaMonitoring, AsyncAutotekaScoring, AsyncAutotekaValuation. diff --git a/CHANGELOG.d/0000-async-cpa.md b/CHANGELOG.d/0000-async-cpa.md deleted file mode 100644 index 6d2624f..0000000 --- a/CHANGELOG.d/0000-async-cpa.md +++ /dev/null @@ -1,2 +0,0 @@ -### Added -- Async-поддержка домена cpa: AsyncCallTrackingCall, AsyncCpaArchive, AsyncCpaCall, AsyncCpaChat, AsyncCpaLead (#0000) diff --git a/CHANGELOG.d/0000-async-jobs.md b/CHANGELOG.d/0000-async-jobs.md deleted file mode 100644 index 42082d2..0000000 --- a/CHANGELOG.d/0000-async-jobs.md +++ /dev/null @@ -1,2 +0,0 @@ -### Added -- Async-поддержка домена jobs: AsyncApplication, AsyncJobDictionary, AsyncJobWebhook, AsyncResume, AsyncVacancy (#0000) diff --git a/CHANGELOG.d/0000-async-messenger.md b/CHANGELOG.d/0000-async-messenger.md deleted file mode 100644 index 66df8c5..0000000 --- a/CHANGELOG.d/0000-async-messenger.md +++ /dev/null @@ -1,2 +0,0 @@ -### Added -- Async-поддержка домена messenger: AsyncChat, AsyncChatMedia, AsyncChatMessage, AsyncChatWebhook, AsyncSpecialOfferCampaign (#0000) diff --git a/CHANGELOG.d/0000-async-promotion.md b/CHANGELOG.d/0000-async-promotion.md deleted file mode 100644 index c70a60f..0000000 --- a/CHANGELOG.d/0000-async-promotion.md +++ /dev/null @@ -1,2 +0,0 @@ -### Added -- Async-поддержка домена promotion: AsyncPromotionOrder, AsyncBbipPromotion, AsyncTrxPromotion, AsyncCpaAuction, AsyncTargetActionPricing, AsyncAutostrategyCampaign. diff --git a/CHANGELOG.d/0000-async-ratings.md b/CHANGELOG.d/0000-async-ratings.md deleted file mode 100644 index c85c46c..0000000 --- a/CHANGELOG.d/0000-async-ratings.md +++ /dev/null @@ -1,2 +0,0 @@ -### Added -- Async-поддержка домена ratings: AsyncRatingProfile, AsyncReview, AsyncReviewAnswer (#0000) diff --git a/CHANGELOG.d/0000-async-realty.md b/CHANGELOG.d/0000-async-realty.md deleted file mode 100644 index c9a500b..0000000 --- a/CHANGELOG.d/0000-async-realty.md +++ /dev/null @@ -1,2 +0,0 @@ -### Added -- Async-поддержка домена realty: AsyncRealtyAnalyticsReport, AsyncRealtyBooking, AsyncRealtyListing, AsyncRealtyPricing (#0000) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7189d1a..02b078d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,27 @@ and this project adheres to Semantic Versioning. ## [Unreleased] +### Added +- Нет изменений. + +## [2.1.0] - 2026-05-08 + ### Added - Фундамент Async API: `AsyncTransport`, `AsyncAuthProvider`, `AsyncOperationExecutor`, `AsyncPaginatedList`, `AsyncAvitoClient` без доменных factory-методов; `RateLimitState` вынесен в shared. - Async-поддержка домена tariffs: `AsyncTariff` (PoC шаблона). +- Async-поддержка домена accounts: AsyncAccount, AsyncAccountHierarchy. +- Async-поддержка домена ads: AsyncAd, AsyncAdStats, AsyncAdPromotion, AsyncAutoloadProfile, AsyncAutoloadReport, AsyncAutoloadArchive. +- Async-поддержка домена autoteka: AsyncAutotekaVehicle, AsyncAutotekaReport, AsyncAutotekaMonitoring, AsyncAutotekaScoring, AsyncAutotekaValuation. +- Async-поддержка домена cpa: AsyncCallTrackingCall, AsyncCpaArchive, AsyncCpaCall, AsyncCpaChat, AsyncCpaLead. +- Async-поддержка домена jobs: AsyncApplication, AsyncJobDictionary, AsyncJobWebhook, AsyncResume, AsyncVacancy. +- Async-поддержка домена messenger: AsyncChat, AsyncChatMedia, AsyncChatMessage, AsyncChatWebhook, AsyncSpecialOfferCampaign. +- Async-поддержка домена orders: AsyncOrder, AsyncOrderLabel, AsyncDeliveryOrder, AsyncSandboxDelivery, AsyncDeliveryTask, AsyncStock. +- Async-поддержка домена promotion: AsyncPromotionOrder, AsyncBbipPromotion, AsyncTrxPromotion, AsyncCpaAuction, AsyncTargetActionPricing, AsyncAutostrategyCampaign. +- Async-поддержка домена ratings: AsyncRatingProfile, AsyncReview, AsyncReviewAnswer. +- Async-поддержка домена realty: AsyncRealtyAnalyticsReport, AsyncRealtyBooking, AsyncRealtyListing, AsyncRealtyPricing. +- Async convenience methods для `AsyncAvitoClient`: `business_summary`, `account_health`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`. - Добавлен `ClientClosedError` для вызовов после `AvitoClient.close()`. ### Deprecated diff --git a/README.md b/README.md index 5c76548..1c7cc29 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ print(ad.title) По умолчанию настройки читаются из переменных окружения с префиксом `AVITO_`. -`avito-py` — синхронный Python SDK для работы с Avito API через единый объектный фасад `AvitoClient`. +`avito-py` — Python SDK для работы с Avito API через единые sync/async фасады +`AvitoClient` и `AsyncAvitoClient`. ## Установка @@ -84,6 +85,19 @@ with AvitoClient(settings) as avito: Все опциональные параметры конструктора — keyword-only. `AvitoClient` иммутабелен: `base_url`, таймауты, retry-политика и `auth` не меняются у живого клиента — вместо этого создаётся новый клиент. +Async-поверхность использует те же доменные методы и модели, но требует `async with`: + +```python +from avito import AsyncAvitoClient + +async with AsyncAvitoClient.from_env() as avito: + profile = await avito.account().get_self() + listings = await (await avito.ad(user_id=123).list(limit=20)).materialize() +``` + +Подробный контракт async lifecycle, ASGI-рецепты и ограничения описаны в +[async how-to](https://p141592.github.io/avito_python_api/how-to/async/). + ### Переменные окружения | Переменная | Обязательная | Описание | diff --git a/avito/async_client.py b/avito/async_client.py index 7998216..f5793cd 100644 --- a/avito/async_client.py +++ b/avito/async_client.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio +from datetime import date, datetime from pathlib import Path import httpx @@ -15,6 +17,7 @@ AsyncAutoloadProfile, AsyncAutoloadReport, ) +from avito.ads.models import CallStats, ListingStats, ListingStatus, SpendingRecord from avito.auth.async_provider import AsyncAuthProvider from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient from avito.auth.settings import AuthSettings @@ -25,9 +28,16 @@ AsyncAutotekaValuation, AsyncAutotekaVehicle, ) +from avito.client import ( + _default_summary_date_range, + _safe_summary_async, + _sum_optional_float, + _sum_optional_int, + _summary_unavailable_section, +) from avito.config import AvitoSettings from avito.core.async_transport import AsyncTransport -from avito.core.exceptions import ClientClosedError +from avito.core.exceptions import AvitoError, ClientClosedError from avito.core.types import TransportDebugInfo from avito.cpa import ( AsyncCallTrackingCall, @@ -50,6 +60,15 @@ AsyncChatWebhook, AsyncSpecialOfferCampaign, ) +from avito.orders import ( + AsyncDeliveryOrder, + AsyncDeliveryTask, + AsyncOrder, + AsyncOrderLabel, + AsyncSandboxDelivery, + AsyncStock, +) +from avito.orders.models import OrderStatus from avito.promotion import ( AsyncAutostrategyCampaign, AsyncBbipPromotion, @@ -58,6 +77,7 @@ AsyncTargetActionPricing, AsyncTrxPromotion, ) +from avito.promotion.models import PromotionOrderServiceStatus, PromotionOrderStatus from avito.ratings import AsyncRatingProfile, AsyncReview, AsyncReviewAnswer from avito.realty import ( AsyncRealtyAnalyticsReport, @@ -65,8 +85,22 @@ AsyncRealtyListing, AsyncRealtyPricing, ) +from avito.summary import ( + AccountHealthSummary, + CapabilityDiscoveryResult, + CapabilityInfo, + ChatSummary, + ListingHealthItem, + ListingHealthSummary, + OrderSummary, + PromotionSummary, + ReviewSummary, + SummaryUnavailableSection, +) from avito.tariffs import AsyncTariff +SummaryDate = date | datetime | str + class AsyncAvitoClient: """Асинхронная публичная точка входа SDK с factory-методами портированных доменов.""" @@ -164,6 +198,388 @@ def debug_info(self) -> TransportDebugInfo: return self._require_transport().debug_info() + async def business_summary( + self, + *, + user_id: int | str | None = None, + listing_limit: int = 50, + listing_page_size: int = 50, + date_from: SummaryDate | None = None, + date_to: SummaryDate | None = None, + ) -> AccountHealthSummary: + """Возвращает итоговую async read-only сводку бизнеса.""" + + return await self.account_health( + user_id=user_id, + listing_limit=listing_limit, + listing_page_size=listing_page_size, + date_from=date_from, + date_to=date_to, + ) + + async def account_health( + self, + *, + user_id: int | str | None = None, + listing_limit: int = 50, + listing_page_size: int = 50, + date_from: SummaryDate | None = None, + date_to: SummaryDate | None = None, + ) -> AccountHealthSummary: + """Возвращает итоговую async read-only health-сводку аккаунта.""" + + resolved_user_id = await self._resolve_user_id(user_id) + async with asyncio.TaskGroup() as task_group: + balance_task = task_group.create_task(self.account(resolved_user_id).get_balance()) + listings_task = task_group.create_task( + self.listing_health( + user_id=resolved_user_id, + limit=listing_limit, + page_size=listing_page_size, + date_from=date_from, + date_to=date_to, + ) + ) + chats_task = task_group.create_task( + _safe_summary_async( + "chats", + lambda: self.chat_summary(user_id=resolved_user_id), + ) + ) + orders_task = task_group.create_task( + _safe_summary_async("orders", self.order_summary) + ) + reviews_task = task_group.create_task( + _safe_summary_async("reviews", self.review_summary) + ) + balance = balance_task.result() + listings = listings_task.result() + item_ids = [item.item_id for item in listings.items if item.item_id is not None] + promotion, promotion_unavailable = await _safe_summary_async( + "promotion", + lambda: self.promotion_summary(item_ids=item_ids), + ) + chats, chats_unavailable = chats_task.result() + orders, orders_unavailable = orders_task.result() + reviews, reviews_unavailable = reviews_task.result() + unavailable_sections = [ + *listings.unavailable_sections, + *chats_unavailable, + *orders_unavailable, + *reviews_unavailable, + *promotion_unavailable, + ] + if chats is not None: + unavailable_sections.extend(chats.unavailable_sections) + if orders is not None: + unavailable_sections.extend(orders.unavailable_sections) + if reviews is not None: + unavailable_sections.extend(reviews.unavailable_sections) + if promotion is not None: + unavailable_sections.extend(promotion.unavailable_sections) + return AccountHealthSummary( + user_id=resolved_user_id, + balance_total=balance.total, + balance_real=balance.real, + balance_bonus=balance.bonus, + listings=listings, + chats=chats, + orders=orders, + reviews=reviews, + promotion=promotion, + unavailable_sections=unavailable_sections, + ) + + async def listing_health( + self, + *, + user_id: int | str | None = None, + limit: int = 50, + page_size: int = 50, + date_from: SummaryDate | None = None, + date_to: SummaryDate | None = None, + ) -> ListingHealthSummary: + """Возвращает async health-сводку объявлений.""" + + resolved_user_id = await self._resolve_user_id(user_id) + listing_collection = await self.ad(user_id=resolved_user_id).list( + limit=limit, + page_size=page_size, + ) + listings = await listing_collection.materialize() + item_ids = [item.item_id for item in listings if item.item_id is not None] + stats_by_item_id: dict[int, ListingStats] = {} + calls_by_item_id: dict[int, CallStats] = {} + spendings_by_item_id: dict[int, SpendingRecord] = {} + unavailable_sections: list[SummaryUnavailableSection] = [] + if item_ids: + stats_date_from, stats_date_to = _default_summary_date_range(date_from, date_to) + async with asyncio.TaskGroup() as task_group: + item_stats_task = task_group.create_task( + self.ad_stats(user_id=resolved_user_id).get_item_stats( + item_ids=item_ids, + date_from=stats_date_from, + date_to=stats_date_to, + ) + ) + calls_stats_task = task_group.create_task( + self.ad_stats(user_id=resolved_user_id).get_calls_stats( + item_ids=item_ids, + date_from=stats_date_from, + date_to=stats_date_to, + ) + ) + spendings_task = task_group.create_task( + _safe_summary_async( + "spendings", + lambda: self.ad_stats(user_id=resolved_user_id).get_account_spendings( + item_ids=item_ids, + date_from=stats_date_from, + date_to=stats_date_to, + spending_types=["promotion", "presence", "commission", "rest"], + grouping="day", + ), + ) + ) + item_stats = item_stats_task.result() + calls_stats = calls_stats_task.result() + spendings, spendings_unavailable = spendings_task.result() + stats_by_item_id = { + stats.item_id: stats for stats in item_stats.items if stats.item_id is not None + } + calls_by_item_id = { + stats.item_id: stats for stats in calls_stats.items if stats.item_id is not None + } + unavailable_sections.extend(spendings_unavailable) + if spendings is not None: + spendings_by_item_id = { + item.item_id: item for item in spendings.items if item.item_id is not None + } + health_items = [ + ListingHealthItem( + item_id=listing.item_id, + title=listing.title, + status=listing.status, + price=listing.price, + url=listing.url, + is_visible=listing.is_visible, + views=stats_by_item_id[listing.item_id].views + if listing.item_id in stats_by_item_id + else None, + contacts=stats_by_item_id[listing.item_id].contacts + if listing.item_id in stats_by_item_id + else None, + favorites=stats_by_item_id[listing.item_id].favorites + if listing.item_id in stats_by_item_id + else None, + calls=calls_by_item_id[listing.item_id].calls + if listing.item_id in calls_by_item_id + else None, + spendings=spendings_by_item_id[listing.item_id].amount + if listing.item_id in spendings_by_item_id + else None, + ) + for listing in listings + ] + loaded_listings = len(health_items) + total_listings = listing_collection.source_total + listing_limit = limit if limit >= 0 else None + expected_loaded = ( + min(total_listings, listing_limit) + if total_listings is not None and listing_limit is not None + else total_listings + ) + return ListingHealthSummary( + user_id=resolved_user_id, + items=health_items, + loaded_listings=loaded_listings, + total_listings=total_listings, + listing_limit=listing_limit, + is_complete=expected_loaded is not None and loaded_listings >= expected_loaded, + visible_listings=sum(1 for item in health_items if item.is_visible is True), + active_listings=sum(1 for item in health_items if item.status is ListingStatus.ACTIVE), + total_views=_sum_optional_int(item.views for item in health_items), + total_contacts=_sum_optional_int(item.contacts for item in health_items), + total_favorites=_sum_optional_int(item.favorites for item in health_items), + total_calls=_sum_optional_int(item.calls for item in health_items), + total_spendings=_sum_optional_float(item.spendings for item in health_items), + unavailable_sections=unavailable_sections, + ) + + async def chat_summary(self, *, user_id: int | str | None = None) -> ChatSummary: + """Возвращает итоговую async read-only сводку по чатам.""" + + resolved_user_id = await self._resolve_user_id(user_id) + result = await self.chat(user_id=resolved_user_id).list() + unread_counts = [item.unread_count or 0 for item in result.items] + return ChatSummary( + user_id=resolved_user_id, + total_chats=result.total if result.total is not None else len(result.items), + unread_chats=sum(1 for count in unread_counts if count > 0), + unread_messages=sum(unread_counts), + ) + + async def order_summary(self) -> OrderSummary: + """Возвращает итоговую async read-only сводку по заказам.""" + + result = await self.order().list() + return OrderSummary( + total_orders=result.total if result.total is not None else len(result.items), + active_orders=sum( + 1 + for item in result.items + if item.status is not None and item.status is not OrderStatus.UNKNOWN + ), + ) + + async def review_summary(self) -> ReviewSummary: + """Возвращает итоговую async read-only сводку по отзывам.""" + + reviews_error: AvitoError | None = None + try: + reviews = await self.review().list() + except AvitoError as error: + reviews = None + reviews_error = error + rating = await self.rating_profile().get() + scores = [item.score for item in reviews.items if item.score is not None] if reviews else [] + average_score = sum(scores) / len(scores) if scores else None + unavailable_sections = ( + [_summary_unavailable_section("reviews", reviews_error)] + if reviews_error is not None + else [] + ) + return ReviewSummary( + total_reviews=( + reviews.total + if reviews is not None and reviews.total is not None + else rating.reviews_count + if reviews is None + else len(reviews.items) + ), + average_score=average_score if reviews is not None else rating.score, + unanswered_reviews=( + sum(1 for item in reviews.items if item.can_answer is True) + if reviews is not None + else None + ), + rating_score=rating.score, + unavailable_sections=unavailable_sections, + ) + + async def promotion_summary(self, *, item_ids: list[int] | None = None) -> PromotionSummary: + """Возвращает итоговую async read-only сводку по продвижению.""" + + if item_ids: + async with asyncio.TaskGroup() as task_group: + orders_task = task_group.create_task( + self.promotion_order().list_orders(item_ids=item_ids) + ) + services_task = task_group.create_task( + self.promotion_order().list_services(item_ids=item_ids) + ) + orders = orders_task.result() + services = services_task.result() + else: + orders = await self.promotion_order().list_orders(item_ids=item_ids) + services = None + service_items = services.items if services is not None else [] + return PromotionSummary( + total_orders=len(orders.items), + active_orders=sum( + 1 + for item in orders.items + if item.status + in { + PromotionOrderStatus.INITIALIZED, + PromotionOrderStatus.WAITING, + PromotionOrderStatus.IN_PROCESS, + PromotionOrderStatus.PROCESSED, + PromotionOrderStatus.APPLIED, + PromotionOrderStatus.AUTO, + PromotionOrderStatus.CREATED, + PromotionOrderStatus.MANUAL, + PromotionOrderStatus.PARTIAL, + } + ), + total_services=len(service_items), + available_services=sum( + 1 + for item in service_items + if item.status + in { + PromotionOrderServiceStatus.ACTIVE, + PromotionOrderServiceStatus.AVAILABLE, + } + ), + ) + + def capabilities(self) -> CapabilityDiscoveryResult: + """Возвращает справочник возможностей SDK без сетевых probe-запросов.""" + + has_user_id = self.debug_info().user_id is not None + configured_reasons = ["Настроены OAuth client_id и client_secret."] + user_id_reasons = ( + ["Настроен user_id или его можно получить через профиль."] + if has_user_id + else [ + "Для части операций SDK получит user_id через профиль или потребует явный аргумент." + ] + ) + return CapabilityDiscoveryResult( + items=[ + CapabilityInfo( + operation="account_health", + factory_method="account_health", + is_available=True, + reasons=configured_reasons + user_id_reasons, + possible_error_codes=[400, 401, 403, 429], + ), + CapabilityInfo( + operation="listing_health", + factory_method="listing_health", + is_available=True, + reasons=user_id_reasons + + [ + "400 возможен при неверном фильтре, 403 при недоступном аккаунте, 429 при лимите." + ], + possible_error_codes=[400, 403, 429], + ), + CapabilityInfo( + operation="chat_summary", + factory_method="chat_summary", + is_available=True, + reasons=user_id_reasons + + ["403 возможен без доступа к мессенджеру, 429 при лимите запросов."], + possible_error_codes=[400, 403, 429], + ), + CapabilityInfo( + operation="order_summary", + factory_method="order_summary", + is_available=True, + reasons=["Операция использует read-only список заказов."], + possible_error_codes=[400, 403, 429], + ), + CapabilityInfo( + operation="review_summary", + factory_method="review_summary", + is_available=True, + reasons=["Операция использует список отзывов и рейтинг профиля."], + possible_error_codes=[400, 403, 429], + ), + CapabilityInfo( + operation="promotion_summary", + factory_method="promotion_summary", + is_available=True, + reasons=[ + "Сводка заявок доступна без item_ids; сводка услуг требует item_ids.", + "403 возможен без доступа к продвижению, 429 при лимите запросов.", + ], + possible_error_codes=[400, 403, 429], + ), + ] + ) + def account(self, user_id: int | str | None = None) -> AsyncAccount: """Создает async-доменный объект аккаунта.""" @@ -398,6 +814,36 @@ def autostrategy_campaign( return AsyncAutostrategyCampaign(self._require_transport(), campaign_id=campaign_id) + def order(self) -> AsyncOrder: + """Создает async-доменный объект заказа.""" + + return AsyncOrder(self._require_transport()) + + def order_label(self, task_id: int | str | None = None) -> AsyncOrderLabel: + """Создает async-доменный объект этикетки заказа.""" + + return AsyncOrderLabel(self._require_transport(), task_id=task_id) + + def delivery_order(self) -> AsyncDeliveryOrder: + """Создает async-доменный объект доставки.""" + + return AsyncDeliveryOrder(self._require_transport()) + + def sandbox_delivery(self) -> AsyncSandboxDelivery: + """Создает async-доменный объект песочницы доставки.""" + + return AsyncSandboxDelivery(self._require_transport()) + + def delivery_task(self, task_id: int | str | None = None) -> AsyncDeliveryTask: + """Создает async-доменный объект задачи доставки.""" + + return AsyncDeliveryTask(self._require_transport(), task_id=task_id) + + def stock(self) -> AsyncStock: + """Создает async-доменный объект остатков.""" + + return AsyncStock(self._require_transport()) + def autoteka_vehicle( self, vehicle_id: int | str | None = None, @@ -488,5 +934,10 @@ def _require_transport(self) -> AsyncTransport: raise RuntimeError("AsyncAvitoClient не инициализирован: используйте 'async with'.") return self._transport + async def _resolve_user_id(self, user_id: int | str | None = None) -> int: + return await AsyncAccount(self._require_transport(), user_id=user_id)._resolve_user_id( + user_id + ) + __all__ = ("AsyncAvitoClient",) diff --git a/avito/client.py b/avito/client.py index 35133a7..97922db 100644 --- a/avito/client.py +++ b/avito/client.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Awaitable, Callable, Iterable from datetime import date, datetime from pathlib import Path from types import TracebackType @@ -98,6 +98,16 @@ def _safe_summary[SummaryT]( return None, [_summary_unavailable_section(section, error)] +async def _safe_summary_async[SummaryT]( + section: str, + factory: Callable[[], Awaitable[SummaryT]], +) -> tuple[SummaryT | None, list[SummaryUnavailableSection]]: + try: + return await factory(), [] + except AvitoError as error: + return None, [_summary_unavailable_section(section, error)] + + class AvitoClient: """Единственная публичная точка входа SDK с фабриками доменных объектов. diff --git a/avito/orders/__init__.py b/avito/orders/__init__.py index c023173..b584338 100644 --- a/avito/orders/__init__.py +++ b/avito/orders/__init__.py @@ -1,5 +1,13 @@ """Пакет orders.""" +from avito.orders.async_domain import ( + AsyncDeliveryOrder, + AsyncDeliveryTask, + AsyncOrder, + AsyncOrderLabel, + AsyncSandboxDelivery, + AsyncStock, +) from avito.orders.domain import ( DeliveryOrder, DeliveryTask, @@ -91,6 +99,12 @@ ) __all__ = ( + "AsyncDeliveryOrder", + "AsyncDeliveryTask", + "AsyncOrder", + "AsyncOrderLabel", + "AsyncSandboxDelivery", + "AsyncStock", "CourierRangesResult", "CustomAreaScheduleEntry", "CustomAreaScheduleRequest", diff --git a/avito/orders/async_domain.py b/avito/orders/async_domain.py new file mode 100644 index 0000000..023800d --- /dev/null +++ b/avito/orders/async_domain.py @@ -0,0 +1,2274 @@ +"""Async-доменные объекты пакета orders.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import DateInput, serialize_iso_datetime +from avito.orders.models import ( + AddSortingCentersRequest, + AddTariffV2Request, + AddTerminalsRequest, + CancelParcelRequest, + CancelSandboxParcelOptions, + CancelSandboxParcelRequest, + ChangeParcelApplication, + ChangeParcelOptions, + ChangeParcelRequest, + CourierRangesResult, + CustomAreaScheduleEntry, + CustomAreaScheduleRequest, + DeliveryAnnouncementRequest, + DeliveryAnnouncementTrackRequest, + DeliveryCancelAnnouncementRequest, + DeliveryDirection, + DeliveryEntityResult, + DeliveryParcelIdsRequest, + DeliveryParcelRequest, + DeliveryParcelResultRequest, + DeliverySandboxAnnouncementRequest, + DeliverySortingCentersResult, + DeliveryTariffZone, + DeliveryTaskInfo, + DeliveryTermsZone, + DeliveryTrackingOptions, + DeliveryTrackingRequest, + GetChangeParcelInfoRequest, + GetRegisteredParcelIdRequest, + GetSandboxParcelInfoRequest, + LabelPdfResult, + LabelTaskResult, + OrderAcceptReturnRequest, + OrderActionResult, + OrderApplyTransitionRequest, + OrderCncDetailsRequest, + OrderConfirmationCodeRequest, + OrderCourierRangeRequest, + OrderDeliveryProperties, + OrderLabelsRequest, + OrderMarkingsRequest, + OrdersResult, + OrderTrackingNumberRequest, + OrderTransition, + ProhibitOrderAcceptanceRequest, + RealAddress, + SandboxAnnouncementPackage, + SandboxAnnouncementParticipant, + SandboxArea, + SandboxAreasRequest, + SandboxCancelAnnouncementOptions, + SandboxCancelAnnouncementRequest, + SandboxConfirmationCodeRequest, + SandboxCreateAnnouncementOptions, + SandboxCreateAnnouncementRequest, + SandboxGetAnnouncementEventRequest, + SandboxParcelRequest, + SetOrderPropertiesRequest, + SetOrderRealAddressRequest, + SortingCenterUpload, + StockInfoRequest, + StockInfoResult, + StockUpdateEntry, + StockUpdateRequest, + StockUpdateResult, + TaggedSortingCenter, + TaggedSortingCentersRequest, + TerminalUpload, + TrackingAvitoEventType, + TrackingAvitoStatus, + UpdateTermsRequest, +) +from avito.orders.operations import ( + ACCEPT_RETURN_ORDER, + APPLY_TRANSITION, + CHECK_CONFIRMATION_CODE, + CREATE_LABELS, + CREATE_LABELS_EXTENDED, + DELIVERY_CANCEL_ANNOUNCEMENT, + DELIVERY_CHANGE_PARCEL_RESULT, + DELIVERY_CREATE_ANNOUNCEMENT, + DELIVERY_CREATE_PARCEL, + DELIVERY_UPDATE_CHANGE_PARCELS, + DOWNLOAD_LABEL, + GET_COURIER_DELIVERY_RANGE, + GET_DELIVERY_TASK, + GET_STOCK_INFO, + LIST_ORDERS, + SANDBOX_ADD_AREAS, + SANDBOX_ADD_SORTING_CENTER, + SANDBOX_ADD_TAGS_TO_SORTING_CENTER, + SANDBOX_ADD_TARIFF, + SANDBOX_ADD_TERMINALS, + SANDBOX_CANCEL_PARCEL, + SANDBOX_CANCEL_SANDBOX_ANNOUNCEMENT, + SANDBOX_CANCEL_SANDBOX_PARCEL, + SANDBOX_CHANGE_SANDBOX_PARCEL, + SANDBOX_CHECK_CONFIRMATION_CODE, + SANDBOX_CREATE_ANNOUNCEMENT, + SANDBOX_CREATE_PARCEL, + SANDBOX_CREATE_SANDBOX_ANNOUNCEMENT, + SANDBOX_GET_ANNOUNCEMENT_EVENT, + SANDBOX_GET_CHANGE_PARCEL_INFO, + SANDBOX_GET_PARCEL_INFO, + SANDBOX_GET_REGISTERED_PARCEL_ID, + SANDBOX_LIST_SORTING_CENTER, + SANDBOX_PROHIBIT_ORDER_ACCEPTANCE, + SANDBOX_SET_ORDER_PROPERTIES, + SANDBOX_SET_ORDER_REAL_ADDRESS, + SANDBOX_TRACK_ANNOUNCEMENT, + SANDBOX_TRACKING, + SANDBOX_UPDATE_CUSTOM_AREA_SCHEDULE, + SANDBOX_UPDATE_TERMS, + SET_CNC_DETAILS, + SET_COURIER_DELIVERY_RANGE, + SET_TRACKING_NUMBER, + UPDATE_MARKINGS, + UPDATE_STOCKS, +) + + +@dataclass(slots=True, frozen=True) +class AsyncOrder(AsyncDomainObject): + """Доменный объект заказа.""" + + __swagger_domain__ = "orders" + __sdk_factory__ = "order" + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/order-management/1/orders", + spec="Управлениезаказами.json", + operation_id="getOrders", + variant="async", + ) + async def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> OrdersResult: + """Возвращает список заказов. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrdersResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(LIST_ORDERS, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/order-management/1/markings", + spec="Управлениезаказами.json", + operation_id="markings", + variant="async", + method_args={"order_id": "body.markings", "codes": "body.markings"}, + ) + async def update_markings( + self, + *, + order_id: str, + codes: Sequence[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Обновляет коды маркировки заказа. + + Аргументы: + order_id: идентифицирует заказ. + codes: передает коды маркировки заказа. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_MARKINGS, + request=OrderMarkingsRequest(order_id=order_id, codes=list(codes)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/order/acceptReturnOrder", + spec="Управлениезаказами.json", + operation_id="acceptReturnOrder", + variant="async", + method_args={"order_id": "body.order_id", "postal_office_id": "body.terminal_number"}, + ) + async def accept_return_order( + self, + *, + order_id: str, + postal_office_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Подтверждает return order для заказов. + + Аргументы: + order_id: идентифицирует заказ. + postal_office_id: идентифицирует почтовое отделение для возврата. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + ACCEPT_RETURN_ORDER, + request=OrderAcceptReturnRequest( + order_id=order_id, + postal_office_id=postal_office_id, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/order/applyTransition", + spec="Управлениезаказами.json", + operation_id="applyTransition", + variant="async", + method_args={"order_id": "body.order_id", "transition": "body.transition"}, + ) + async def apply( + self, + *, + order_id: str, + transition: OrderTransition | str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Применяет действие к заказов. + + Аргументы: + order_id: идентифицирует заказ. + transition: задает переход статуса заказа. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + APPLY_TRANSITION, + request=OrderApplyTransitionRequest(order_id=order_id, transition=transition), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/order/checkConfirmationCode", + spec="Управлениезаказами.json", + operation_id="checkConfirmationCode", + variant="async", + method_args={"order_id": "body.parcel_id", "code": "body.confirm_code"}, + ) + async def check_confirmation_code( + self, + *, + order_id: str, + code: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Проверяет confirmation code для заказов. + + Аргументы: + order_id: идентифицирует заказ. + code: передает код подтверждения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CHECK_CONFIRMATION_CODE, + request=OrderConfirmationCodeRequest(order_id=order_id, code=code), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/order/cncSetDetails", + spec="Управлениезаказами.json", + operation_id="cncSetDetails", + variant="async", + method_args={"order_id": "body.id", "pickup_point_id": "body.marketplace_id"}, + ) + async def set_cnc_details( + self, + *, + order_id: str, + pickup_point_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Устанавливает параметры click-and-collect для заказа. + + Аргументы: + order_id: идентифицирует заказ. + pickup_point_id: идентифицирует пункт выдачи click-and-collect. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SET_CNC_DETAILS, + request=OrderCncDetailsRequest(order_id=order_id, pickup_point_id=pickup_point_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/order-management/1/order/getCourierDeliveryRange", + spec="Управлениезаказами.json", + operation_id="getCourierDeliveryRange", + variant="async", + ) + async def get_courier_delivery_range( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> CourierRangesResult: + """Возвращает courier delivery range для заказов. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CourierRangesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_COURIER_DELIVERY_RANGE, + query={"orderId": "order-1"}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/order/setCourierDeliveryRange", + spec="Управлениезаказами.json", + operation_id="setCourierDeliveryRange", + variant="async", + method_args={"order_id": "body.order_id", "interval_id": "body.interval_type"}, + ) + async def set_courier_delivery_range( + self, + *, + order_id: str, + interval_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Устанавливает интервал курьерской доставки заказа. + + Аргументы: + order_id: идентифицирует заказ. + interval_id: идентифицирует интервал курьерской доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SET_COURIER_DELIVERY_RANGE, + request=OrderCourierRangeRequest(order_id=order_id, interval_id=interval_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/order/setTrackingNumber", + spec="Управлениезаказами.json", + operation_id="setOrderTrackingNumber", + variant="async", + method_args={"order_id": "body.order_id", "tracking_number": "body.tracking_number"}, + ) + async def update_tracking_number( + self, + *, + order_id: str, + tracking_number: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Обновляет трек-номер заказа. + + Аргументы: + order_id: идентифицирует заказ. + tracking_number: передает трек-номер отправления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SET_TRACKING_NUMBER, + request=OrderTrackingNumberRequest( + order_id=order_id, + tracking_number=tracking_number, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncOrderLabel(AsyncDomainObject): + """Доменный объект генерации и загрузки этикеток.""" + + __swagger_domain__ = "orders" + __sdk_factory__ = "order_label" + __sdk_factory_args__ = {"task_id": "path.task_id"} + + task_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/order-management/1/orders/labels", + spec="Управлениезаказами.json", + operation_id="generateLabels", + variant="async", + method_args={"order_ids": "body.order_ids"}, + ) + async def create( + self, + *, + order_ids: Sequence[str], + extended: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> LabelTaskResult: + """Создает задачу генерации ярлыков заказов. + + Аргументы: + order_ids: передает идентификаторы заказов. + extended: запрашивает расширенный вариант результата, если поддерживается API. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `LabelTaskResult` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + if extended: + return await self.create_extended( + order_ids=order_ids, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return await self._execute( + CREATE_LABELS, + request=OrderLabelsRequest(order_ids=list(order_ids)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/orders/labels/extended", + spec="Управлениезаказами.json", + operation_id="generateLabelsExtended", + variant="async", + method_args={"order_ids": "body.order_ids"}, + ) + async def create_extended( + self, + *, + order_ids: Sequence[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> LabelTaskResult: + """Запускает генерацию расширенных этикеток и возвращает типизированную SDK-модель. + + Аргументы: + order_ids: передает идентификаторы заказов для генерации этикеток. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `LabelTaskResult` с идентификатором задачи генерации расширенных этикеток. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_LABELS_EXTENDED, + request=OrderLabelsRequest(order_ids=list(order_ids)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/order-management/1/orders/labels/{taskID}/download", + spec="Управлениезаказами.json", + operation_id="downloadLabel", + variant="async", + ) + async def download( + self, + *, + task_id: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> LabelPdfResult: + """Скачивает PDF с ярлыками заказов. + + Аргументы: + task_id: идентифицирует асинхронную задачу. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `LabelPdfResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_task_id = task_id or self._require_task_id() + binary = await self._execute( + DOWNLOAD_LABEL, + path_params={"taskID": resolved_task_id}, + timeout=timeout, + retry=retry, + ) + return LabelPdfResult(binary=binary) + + def _require_task_id(self) -> str: + if self.task_id is None: + raise ValidationError("Для операции требуется `task_id`.") + return str(self.task_id) + + +@dataclass(slots=True, frozen=True) +class AsyncDeliveryOrder(AsyncDomainObject): + """Доменный объект production API доставки.""" + + __swagger_domain__ = "orders" + __sdk_factory__ = "delivery_order" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/createAnnouncement", + spec="Доставка.json", + operation_id="CreateAnnouncement3PL", + variant="async", + method_args={"order_id": "body.announcement_id"}, + ) + async def create_announcement( + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Создает объявление доставки для заказа. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELIVERY_CREATE_ANNOUNCEMENT, + request=DeliveryAnnouncementRequest(order_id=order_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cancelAnnouncement", + spec="Доставка.json", + operation_id="CancelAnnouncement3PL", + variant="async", + method_args={"order_id": "body.announcement_id"}, + ) + async def delete( + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Удаляет сущность доставки заказа. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELIVERY_CANCEL_ANNOUNCEMENT, + request=DeliveryCancelAnnouncementRequest(order_id=order_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/createParcel", + spec="Доставка.json", + operation_id="createParcel", + variant="async", + method_args={"order_id": "body.order_id", "parcel_id": "body.parcel_id"}, + ) + async def create( + self, + *, + order_id: str, + parcel_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Создает сущность доставки заказа. + + Аргументы: + order_id: идентифицирует заказ. + parcel_id: идентифицирует отправление. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELIVERY_CREATE_PARCEL, + request=DeliveryParcelRequest(order_id=order_id, parcel_id=parcel_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/sandbox/changeParcels", + spec="Доставка.json", + operation_id="ChangeParcels", + variant="async", + method_args={"parcel_ids": "body.applications"}, + ) + async def update_change_parcels( + self, + *, + parcel_ids: Sequence[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Обновляет отправления для изменения доставки. + + Аргументы: + parcel_ids: передает идентификаторы отправлений. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELIVERY_UPDATE_CHANGE_PARCELS, + request=DeliveryParcelIdsRequest(parcel_ids=list(parcel_ids)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery/order/changeParcelResult", + spec="Доставка.json", + operation_id="ChangeParcelResult", + variant="async", + method_args={"parcel_id": "body.id", "result": "body.status"}, + ) + async def create_change_parcel_result( + self, + *, + parcel_id: str, + result: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Создает результат изменения отправления доставки. + + Аргументы: + parcel_id: идентифицирует отправление. + result: передает результат обработки изменения отправления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELIVERY_CHANGE_PARCEL_RESULT, + request=DeliveryParcelResultRequest(parcel_id=parcel_id, result=result), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncSandboxDelivery(AsyncDomainObject): + """Доменный объект sandbox API доставки.""" + + __swagger_domain__ = "orders" + __sdk_factory__ = "sandbox_delivery" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/delivery-sandbox/announcements/create", + spec="Доставка.json", + operation_id="CreateAnnouncement", + variant="async", + method_args={"order_id": "body.announcement_id"}, + ) + async def create_announcement( + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Создает announcement для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CREATE_ANNOUNCEMENT, + request=DeliverySandboxAnnouncementRequest(order_id=order_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/announcements/track", + spec="Доставка.json", + operation_id="TrackAnnouncement", + variant="async", + method_args={"order_id": "body.announcement_id"}, + ) + async def track_announcement( + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Передает tracking-событие для announcement для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_TRACK_ANNOUNCEMENT, + request=DeliveryAnnouncementTrackRequest(order_id=order_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/areas/custom-schedule", + spec="Доставка.json", + operation_id="customAreaSchedule", + variant="async", + method_args={"items": "body"}, + ) + async def update_custom_area_schedule( + self, + *, + items: Sequence[CustomAreaScheduleEntry], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Обновляет custom area schedule для sandbox-доставки. + + Аргументы: + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_UPDATE_CUSTOM_AREA_SCHEDULE, + request=CustomAreaScheduleRequest(items=list(items)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/cancelParcel", + spec="Доставка.json", + operation_id="cancelParcel", + variant="async", + method_args={"parcel_id": "body.parcel_id", "actor": "body.actor"}, + ) + async def cancel_parcel( + self, + *, + parcel_id: str, + actor: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Отменяет parcel для sandbox-доставки. + + Аргументы: + parcel_id: идентифицирует отправление. + actor: задает участника, от имени которого выполняется отмена. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CANCEL_PARCEL, + request=CancelParcelRequest(parcel_id=parcel_id, actor=actor), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/order/checkConfirmationCode", + spec="Доставка.json", + operation_id="checkConfirmationCode", + variant="async", + method_args={"parcel_id": "body.parcel_id", "confirm_code": "body.confirm_code"}, + ) + async def check_confirmation_code( + self, + *, + parcel_id: str, + confirm_code: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Проверяет confirmation code для sandbox-доставки. + + Аргументы: + parcel_id: идентифицирует отправление. + confirm_code: передает код подтверждения sandbox-доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CHECK_CONFIRMATION_CODE, + request=SandboxConfirmationCodeRequest( + parcel_id=parcel_id, + confirm_code=confirm_code, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/order/properties", + spec="Доставка.json", + operation_id="setOrderProperties", + variant="async", + method_args={"order_id": "body.order_id", "properties": "body.properties"}, + ) + async def set_order_properties( + self, + *, + order_id: str, + properties: OrderDeliveryProperties, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Устанавливает order properties для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + properties: передает свойства заказа доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_SET_ORDER_PROPERTIES, + request=SetOrderPropertiesRequest(order_id=order_id, properties=properties), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/order/realAddress", + spec="Доставка.json", + operation_id="setOrderRealAddress", + variant="async", + method_args={"order_id": "body.order_id", "address": "body.address"}, + ) + async def set_order_real_address( + self, + *, + order_id: str, + address: RealAddress, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Устанавливает order real address для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + address: передает фактический адрес заказа. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_SET_ORDER_REAL_ADDRESS, + request=SetOrderRealAddressRequest(order_id=order_id, address=address), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/order/tracking", + spec="Доставка.json", + operation_id="tracking", + variant="async", + method_args={ + "order_id": "body.order_id", + "avito_status": "body.avito_status", + "avito_event_type": "body.avito_event_type", + "provider_event_code": "body.provider_event_code", + "date": "body.date", + "location": "body.location", + }, + ) + async def tracking( + self, + *, + order_id: str, + avito_status: TrackingAvitoStatus | str, + avito_event_type: TrackingAvitoEventType | str, + provider_event_code: str, + date: DateInput, + location: str, + comment: str | None = None, + options: DeliveryTrackingOptions | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Выполняет действие `tracking` для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + avito_status: передает статус события Авито. + avito_event_type: передает тип события Авито. + provider_event_code: передает код события провайдера. + date: задает дату события. + location: передает местоположение события. + comment: передает комментарий к операции. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_TRACKING, + request=DeliveryTrackingRequest( + order_id=order_id, + avito_status=avito_status, + avito_event_type=avito_event_type, + provider_event_code=provider_event_code, + date=serialize_iso_datetime("date", date), + location=location, + comment=comment, + options=options, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/prohibitOrderAcceptance", + spec="Доставка.json", + operation_id="prohibitOrderAcceptance", + variant="async", + method_args={"order_id": "body.order_id"}, + ) + async def prohibit_order_acceptance( + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Запрещает прием order acceptance для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_PROHIBIT_ORDER_ACCEPTANCE, + request=ProhibitOrderAcceptanceRequest(order_id=order_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/delivery-sandbox/sorting-center", + spec="Доставка.json", + operation_id="GetSortingCenter", + variant="async", + ) + async def list_sorting_center( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> DeliverySortingCentersResult: + """Возвращает список sorting center для sandbox-доставки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliverySortingCentersResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_LIST_SORTING_CENTER, + query={"deliveryProviders": "pochta"}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/sorting-center", + spec="Доставка.json", + operation_id="AddSortingCenter", + variant="async", + method_args={"items": "body"}, + ) + async def add_sorting_center( + self, + *, + items: Sequence[SortingCenterUpload], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Добавляет sorting center для sandbox-доставки. + + Аргументы: + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_ADD_SORTING_CENTER, + request=AddSortingCentersRequest(items=list(items)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/{tariff_id}/areas", + spec="Доставка.json", + operation_id="AddAreasSandbox", + variant="async", + method_args={"tariff_id": "path.tariff_id", "areas": "body"}, + ) + async def add_areas( + self, + *, + tariff_id: str, + areas: Sequence[SandboxArea], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Добавляет areas для sandbox-доставки. + + Аргументы: + tariff_id: идентифицирует тариф доставки. + areas: передает зоны доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_ADD_AREAS, + path_params={"tariff_id": tariff_id}, + request=SandboxAreasRequest(areas=list(areas)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/{tariff_id}/tagged-sorting-centers", + spec="Доставка.json", + operation_id="AddTagsToSortingCenter", + variant="async", + method_args={"tariff_id": "path.tariff_id", "items": "body"}, + ) + async def add_tags_to_sorting_center( + self, + *, + tariff_id: str, + items: Sequence[TaggedSortingCenter], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Добавляет tags to sorting center для sandbox-доставки. + + Аргументы: + tariff_id: идентифицирует тариф доставки. + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_ADD_TAGS_TO_SORTING_CENTER, + path_params={"tariff_id": tariff_id}, + request=TaggedSortingCentersRequest(items=list(items)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/{tariff_id}/terminals", + spec="Доставка.json", + operation_id="AddTerminalsSandbox", + variant="async", + method_args={"tariff_id": "path.tariff_id", "items": "body"}, + ) + async def add_terminals( + self, + *, + tariff_id: str, + items: Sequence[TerminalUpload], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Добавляет terminals для sandbox-доставки. + + Аргументы: + tariff_id: идентифицирует тариф доставки. + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_ADD_TERMINALS, + path_params={"tariff_id": tariff_id}, + request=AddTerminalsRequest(items=list(items)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/{tariff_id}/terms", + spec="Доставка.json", + operation_id="UpdateTerms", + variant="async", + method_args={"tariff_id": "path.tariff_id", "items": "body"}, + ) + async def update_terms( + self, + *, + tariff_id: str, + items: Sequence[DeliveryTermsZone], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Обновляет terms для sandbox-доставки. + + Аргументы: + tariff_id: идентифицирует тариф доставки. + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_UPDATE_TERMS, + path_params={"tariff_id": tariff_id}, + request=UpdateTermsRequest(items=list(items)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/tariffsV2", + spec="Доставка.json", + operation_id="AddTariffSandboxV2", + variant="async", + method_args={ + "name": "body.name", + "delivery_provider_tariff_id": "body.delivery_provider_tariff_id", + "directions": "body.directions", + "tariff_zones": "body.tariff_zones", + "terms_zones": "body.terms_zones", + }, + ) + async def add_tariff( + self, + *, + name: str, + delivery_provider_tariff_id: str, + directions: Sequence[DeliveryDirection], + tariff_zones: Sequence[DeliveryTariffZone], + terms_zones: Sequence[DeliveryTermsZone], + tariff_type: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Добавляет tariff для sandbox-доставки. + + Аргументы: + name: передает название сущности. + delivery_provider_tariff_id: идентифицирует тариф провайдера доставки. + directions: передает направления доставки. + tariff_zones: передает тарифные зоны. + terms_zones: передает зоны условий доставки. + tariff_type: задает тип тарифа. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_ADD_TARIFF, + request=AddTariffV2Request( + name=name, + delivery_provider_tariff_id=delivery_provider_tariff_id, + directions=list(directions), + tariff_zones=list(tariff_zones), + terms_zones=list(terms_zones), + tariff_type=tariff_type, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v2/createParcel", + spec="Доставка.json", + operation_id="CreateSandboxParcelV2", + variant="async", + method_args={"order_id": "body.items", "parcel_id": "body.items"}, + ) + async def create_parcel( + self, + *, + order_id: str, + parcel_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Создает parcel для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + parcel_id: идентифицирует отправление. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CREATE_PARCEL, + request=SandboxParcelRequest(order_id=order_id, parcel_id=parcel_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/cancelAnnouncement", + spec="Доставка.json", + operation_id="v1cancelAnnouncement", + variant="async", + method_args={ + "announcement_id": "body.announcement_id", + "date": "body.date", + "options": "body.options", + }, + ) + async def cancel_sandbox_announcement( + self, + *, + announcement_id: str, + date: DateInput, + options: SandboxCancelAnnouncementOptions, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Отменяет sandbox announcement для sandbox-доставки. + + Аргументы: + announcement_id: идентифицирует sandbox-объявление доставки. + date: задает дату события. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CANCEL_SANDBOX_ANNOUNCEMENT, + request=SandboxCancelAnnouncementRequest( + announcement_id=announcement_id, + date=serialize_iso_datetime("date", date), + options=options, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/cancelParcel", + spec="Доставка.json", + operation_id="v1CancelParcel", + variant="async", + method_args={"parcel_id": "body.parcel_id"}, + ) + async def cancel_sandbox_parcel( + self, + *, + parcel_id: str, + options: CancelSandboxParcelOptions | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Отменяет sandbox parcel для sandbox-доставки. + + Аргументы: + parcel_id: идентифицирует отправление. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CANCEL_SANDBOX_PARCEL, + request=CancelSandboxParcelRequest(parcel_id=parcel_id, options=options), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/changeParcel", + spec="Доставка.json", + operation_id="v1changeParcel", + variant="async", + method_args={"type": "body.type", "parcel_id": "body.parcel_id"}, + ) + async def change_sandbox_parcel( + self, + *, + type: str, + parcel_id: str, + application: ChangeParcelApplication | None = None, + options: ChangeParcelOptions | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Изменяет sandbox parcel для sandbox-доставки. + + Аргументы: + type: передает значение `type` в upstream API. + parcel_id: идентифицирует отправление. + application: передает значение `application` в upstream API. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CHANGE_SANDBOX_PARCEL, + request=ChangeParcelRequest( + type=type, + parcel_id=parcel_id, + application=application, + options=options, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/createAnnouncement", + spec="Доставка.json", + operation_id="v1createAnnouncement", + variant="async", + method_args={ + "announcement_id": "body.announcement_id", + "barcode": "body.barcode", + "sender": "body.sender", + "receiver": "body.receiver", + "announcement_type": "body.announcement_type", + "date": "body.date", + "packages": "body.packages", + "options": "body.options", + }, + ) + async def create_sandbox_announcement( + self, + *, + announcement_id: str, + barcode: str, + sender: SandboxAnnouncementParticipant, + receiver: SandboxAnnouncementParticipant, + announcement_type: str, + date: DateInput, + packages: Sequence[SandboxAnnouncementPackage], + options: SandboxCreateAnnouncementOptions, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Создает sandbox announcement для sandbox-доставки. + + Аргументы: + announcement_id: идентифицирует sandbox-объявление доставки. + barcode: передает штрихкод отправления. + sender: передает данные отправителя. + receiver: передает данные получателя. + announcement_type: задает тип sandbox-объявления доставки. + date: задает дату события. + packages: передает грузовые места отправления. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CREATE_SANDBOX_ANNOUNCEMENT, + request=SandboxCreateAnnouncementRequest( + announcement_id=announcement_id, + barcode=barcode, + sender=sender, + receiver=receiver, + announcement_type=announcement_type, + date=serialize_iso_datetime("date", date), + packages=list(packages), + options=options, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/getAnnouncementEvent", + spec="Доставка.json", + operation_id="v1getAnnouncementEvent", + variant="async", + method_args={"announcement_id": "body.announcement_id"}, + ) + async def get_sandbox_announcement_event( + self, + *, + announcement_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Возвращает sandbox announcement event для sandbox-доставки. + + Аргументы: + announcement_id: идентифицирует sandbox-объявление доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_GET_ANNOUNCEMENT_EVENT, + request=SandboxGetAnnouncementEventRequest(announcement_id=announcement_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/getChangeParcelInfo", + spec="Доставка.json", + operation_id="v1getChangeParcelInfo", + variant="async", + method_args={"application_id": "body.application_id"}, + ) + async def get_sandbox_change_parcel_info( + self, + *, + application_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Возвращает sandbox change parcel info для sandbox-доставки. + + Аргументы: + application_id: идентифицирует заявку на изменение отправления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_GET_CHANGE_PARCEL_INFO, + request=GetChangeParcelInfoRequest(application_id=application_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/getParcelInfo", + spec="Доставка.json", + operation_id="v1getParcelInfo", + variant="async", + method_args={"parcel_id": "body.parcel_id"}, + ) + async def get_sandbox_parcel_info( + self, + *, + parcel_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Возвращает sandbox parcel info для sandbox-доставки. + + Аргументы: + parcel_id: идентифицирует отправление. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_GET_PARCEL_INFO, + request=GetSandboxParcelInfoRequest(parcel_id=parcel_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/getRegisteredParcelID", + spec="Доставка.json", + operation_id="v1getRegisteredParcelID", + variant="async", + method_args={"order_id": "body.order_id"}, + ) + async def get_sandbox_registered_parcel_id( + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Возвращает sandbox registered parcel id для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_GET_REGISTERED_PARCEL_ID, + request=GetRegisteredParcelIdRequest(order_id=order_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncDeliveryTask(AsyncDomainObject): + """Доменный объект задачи доставки.""" + + __swagger_domain__ = "orders" + __sdk_factory__ = "delivery_task" + __sdk_factory_args__ = {"task_id": "path.task_id"} + + task_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/delivery-sandbox/tasks/{task_id}", + spec="Доставка.json", + operation_id="GetTask", + variant="async", + ) + async def get( + self, + *, + task_id: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryTaskInfo: + """Возвращает задач доставки. + + Аргументы: + task_id: идентифицирует асинхронную задачу. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryTaskInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_task_id = task_id or self._require_task_id() + return await self._execute( + GET_DELIVERY_TASK, + path_params={"task_id": resolved_task_id}, + timeout=timeout, + retry=retry, + ) + + def _require_task_id(self) -> str: + if self.task_id is None: + raise ValidationError("Для операции требуется `task_id`.") + return str(self.task_id) + + +@dataclass(slots=True, frozen=True) +class AsyncStock(AsyncDomainObject): + """Доменный объект управления остатками.""" + + __swagger_domain__ = "orders" + __sdk_factory__ = "stock" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/stock-management/1/info", + spec="Управлениеостатками.json", + variant="async", + method_args={"item_ids": "body.item_ids"}, + ) + async def get( + self, + *, + item_ids: Sequence[int], + strong_consistency: bool | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> StockInfoResult: + """Возвращает остатков товаров. + + Аргументы: + item_ids: передает идентификаторы объявлений или товаров. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `StockInfoResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_STOCK_INFO, + request=StockInfoRequest( + item_ids=list(item_ids), + strong_consistency=strong_consistency, + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "PUT", + "/stock-management/1/stocks", + spec="Управлениеостатками.json", + variant="async", + method_args={"stocks": "body.stocks"}, + ) + async def update( + self, + *, + stocks: Sequence[StockUpdateEntry], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> StockUpdateResult: + """Обновляет остатки товаров. + + Аргументы: + stocks: передает остатки товаров для обновления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `StockUpdateResult` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_STOCKS, + request=StockUpdateRequest(stocks=list(stocks)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +__all__ = ( + "AsyncDeliveryOrder", + "AsyncDeliveryTask", + "AsyncOrder", + "AsyncOrderLabel", + "AsyncSandboxDelivery", + "AsyncStock", +) diff --git a/avito/testing/__init__.py b/avito/testing/__init__.py index 6731f9e..a76e870 100644 --- a/avito/testing/__init__.py +++ b/avito/testing/__init__.py @@ -1,6 +1,6 @@ """Публичные тестовые утилиты SDK.""" -from avito.testing.async_fake_transport import AsyncFakeTransport +from avito.testing.async_fake_transport import AsyncFakeTransport, FanoutPeakRecorder from avito.testing.async_swagger_fake_transport import AsyncSwaggerFakeTransport from avito.testing.fake_transport import ( FakeResponse, @@ -21,6 +21,7 @@ "FakeTransport", "AsyncFakeTransport", "AsyncSwaggerFakeTransport", + "FanoutPeakRecorder", "FakeResponse", "JsonValue", "RecordedRequest", diff --git a/avito/testing/async_fake_transport.py b/avito/testing/async_fake_transport.py index 7b2a2c3..4563fda 100644 --- a/avito/testing/async_fake_transport.py +++ b/avito/testing/async_fake_transport.py @@ -24,11 +24,17 @@ class AsyncFakeTransport: """Deterministic async fake transport for SDK contract tests.""" - def __init__(self, *, base_url: str = "https://api.avito.ru") -> None: + def __init__( + self, + *, + base_url: str = "https://api.avito.ru", + fanout_recorder: FanoutPeakRecorder | None = None, + ) -> None: self.base_url = base_url.rstrip("/") self.requests: list[RecordedRequest] = [] self._routes: dict[tuple[str, str], deque[RouteResponder]] = {} self._handle_lock = asyncio.Lock() + self._fanout_recorder = fanout_recorder def add(self, method: str, path: str, *responses: RouteResponder) -> AsyncFakeTransport: """Регистрирует один или несколько ответов для HTTP-маршрута.""" @@ -174,6 +180,15 @@ def _build_parts( return settings, auth_provider, http_client async def _handle(self, request: httpx.Request) -> httpx.Response: + if self._fanout_recorder is not None: + await self._fanout_recorder.enter() + try: + return await self._handle_recorded(request) + finally: + if self._fanout_recorder is not None: + await self._fanout_recorder.exit() + + async def _handle_recorded(self, request: httpx.Request) -> httpx.Response: async with self._handle_lock: recorded = RecordedRequest( method=request.method.upper(), @@ -208,4 +223,22 @@ def _decode_json(request: httpx.Request) -> JsonValue: return None -__all__ = ("AsyncFakeTransport",) +class FanoutPeakRecorder: + """Считает пик одновременно выполняющихся async fake-запросов.""" + + def __init__(self) -> None: + self._lock = asyncio.Lock() + self._active = 0 + self.peak = 0 + + async def enter(self) -> None: + async with self._lock: + self._active += 1 + self.peak = max(self.peak, self._active) + + async def exit(self) -> None: + async with self._lock: + self._active -= 1 + + +__all__ = ("AsyncFakeTransport", "FanoutPeakRecorder") diff --git a/avito/testing/async_swagger_fake_transport.py b/avito/testing/async_swagger_fake_transport.py index c23fdb6..d09a43d 100644 --- a/avito/testing/async_swagger_fake_transport.py +++ b/avito/testing/async_swagger_fake_transport.py @@ -2,12 +2,18 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping +import httpx + +from avito.async_client import AsyncAvitoClient +from avito.auth import AuthSettings +from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient +from avito.core.swagger_discovery import DiscoveredSwaggerBinding from avito.core.swagger_registry import SwaggerOperation, SwaggerRegistry from avito.testing.async_fake_transport import AsyncFakeTransport -from avito.testing.fake_transport import JsonValue -from avito.testing.swagger_fake_transport import SwaggerRoute, success_payload +from avito.testing.fake_transport import JsonValue, RecordedRequest +from avito.testing.swagger_fake_transport import SwaggerFakeTransport, SwaggerRoute, success_payload class AsyncSwaggerFakeTransport(AsyncFakeTransport): @@ -21,7 +27,8 @@ def __init__( ) -> None: super().__init__(base_url=base_url) self.registry = registry - self._swagger_routes: dict[str, SwaggerRoute] = {} + self._sync_helper = SwaggerFakeTransport(registry=registry, base_url=base_url) + self._swagger_routes: dict[str, SwaggerRoute] = self._sync_helper._swagger_routes def add_operation( self, @@ -33,14 +40,12 @@ def add_operation( ) -> AsyncSwaggerFakeTransport: """Register response for one Swagger operation key.""" - operation = self.operation(operation_key) - self._swagger_routes[operation.key] = SwaggerRoute( - operation=operation, - payload=payload, + self._sync_helper.add_operation( + operation_key, + payload, status_code=status_code, - headers=dict(headers or {}), + headers=headers, ) - self.add_json(operation.method, operation.path, payload, status_code=status_code) return self def add_success_operation( @@ -64,10 +69,94 @@ def add_success_operation( def operation(self, operation_key: str) -> SwaggerOperation: """Return operation by key or raise an assertion error.""" - for operation in self.registry.operations: - if operation.key == operation_key: - return operation - raise AssertionError(f"Swagger operation not found: {operation_key}") + return self._sync_helper.operation(operation_key) + + async def invoke_binding( + self, + binding: DiscoveredSwaggerBinding, + *, + client: AsyncAvitoClient | None = None, + ) -> object: + """Build and invoke async SDK call from discovered Swagger binding metadata.""" + + if binding.operation_key is None: + raise AssertionError(f"Привязка Swagger неоднозначна: {binding.sdk_method}") + if binding.domain == "auth": + target = self._build_auth_target(binding) + method = getattr(target, binding.method_name) + return await method(**self._build_arguments(binding.method_args, method)) + sdk_client = client or self.as_client(user_id=7) + target = self._build_target(sdk_client, binding) + method = getattr(target, binding.method_name) + return await method(**self._build_arguments(binding.method_args, method)) + + async def _handle_recorded(self, request: httpx.Request) -> httpx.Response: + async with self._handle_lock: + recorded = RecordedRequest( + method=request.method.upper(), + path=request.url.path, + params=dict(request.url.params), + headers=dict(request.headers), + json_body=self._decode_json(request), + content=request.content, + ) + self.requests.append(recorded) + + route = self._match_route(recorded) + self._validate_request(route.operation, recorded) + response = httpx.Response( + route.status_code, + json=route.payload, + headers=dict(route.headers), + ) + response.request = request + return response + + def _build_target( + self, + client: AsyncAvitoClient, + binding: DiscoveredSwaggerBinding, + ) -> object: + if binding.factory is None: + raise AssertionError(f"Binding не содержит AsyncAvitoClient factory: {binding.sdk_method}") + factory = getattr(client, binding.factory) + return factory(**self._build_arguments(binding.factory_args, factory)) + + def _build_auth_target(self, binding: DiscoveredSwaggerBinding) -> object: + settings = AuthSettings( + client_id="fake-client-id", + client_secret="fake-client-secret", + refresh_token="fake-refresh-token", + scope="fake-scope", + token_url=binding.path, + alternate_token_url=binding.path, + autoteka_token_url="/token", + autoteka_client_id="fake-autoteka-client-id", + autoteka_client_secret="fake-autoteka-client-secret", + autoteka_scope="autoteka:read", + ) + client = httpx.AsyncClient( + transport=httpx.MockTransport(self._handle), + base_url=self.base_url, + ) + if binding.class_name == "AsyncAlternateTokenClient": + return AsyncAlternateTokenClient(settings=settings, client=client) + if binding.class_name == "AsyncTokenClient": + return AsyncTokenClient(settings=settings, client=client) + raise AssertionError(f"Неподдерживаемый async auth binding: {binding.sdk_method}") + + def _build_arguments( + self, + mapping: Mapping[str, str], + callable_object: Callable[..., object], + ) -> dict[str, object]: + return self._sync_helper._build_arguments(mapping, callable_object) + + def _match_route(self, request: RecordedRequest) -> SwaggerRoute: + return self._sync_helper._match_route(request) + + def _validate_request(self, operation: SwaggerOperation, request: RecordedRequest) -> None: + self._sync_helper._validate_request(operation, request) __all__ = ("AsyncSwaggerFakeTransport",) diff --git a/docs/site/how-to/.pages b/docs/site/how-to/.pages index 74c0354..537691a 100644 --- a/docs/site/how-to/.pages +++ b/docs/site/how-to/.pages @@ -1,6 +1,7 @@ nav: - index.md - auth-and-config.md + - async.md - account-profile.md - ad-listing-and-stats.md - promotion-dry-run.md diff --git a/docs/site/how-to/async.md b/docs/site/how-to/async.md new file mode 100644 index 0000000..c2c1a01 --- /dev/null +++ b/docs/site/how-to/async.md @@ -0,0 +1,168 @@ +# Async API + +`AsyncAvitoClient` повторяет доменную поверхность `AvitoClient`, но все сетевые +методы вызываются через `await`. Клиент обязательно открывается через `async with`: +в этот момент создаются `httpx.AsyncClient`, async locks и transport. + +```python +from avito import AsyncAvitoClient + + +async def load_profile() -> str | None: + async with AsyncAvitoClient.from_env() as avito: + profile = await avito.account().get_self() + return profile.name +``` + +## Переписать sync-вызов на async + +Sync: + +```python +from avito import AvitoClient + +with AvitoClient.from_env() as avito: + orders = avito.order().list() + label = avito.order_label(task_id=42).download() +``` + +Async: + +```python +from avito import AsyncAvitoClient + +async with AsyncAvitoClient.from_env() as avito: + orders = await avito.order().list() + label = await avito.order_label(task_id=42).download() +``` + +Для пагинации sync `PaginatedList` и async `AsyncPaginatedList` отличаются: +async-контейнер не является `list`, поэтому используйте `async for` или +`await materialize()`. + +```python +async with AsyncAvitoClient.from_env() as avito: + page = await avito.ad(user_id=123).list(limit=100) + items = await page.materialize() +``` + +## Тестирование без HTTP + +```python +from avito.testing import AsyncFakeTransport + + +async def test_orders_summary() -> None: + fake = ( + AsyncFakeTransport() + .add_json("GET", "/order-management/1/orders", {"orders": []}) + ) + client = fake.as_client(user_id=123) + + summary = await client.order_summary() + + assert summary.total_orders == 0 + await client.aclose() +``` + +## Ограничения + +- `AsyncPaginatedList` не поддерживает list API и конкурентную итерацию одного + экземпляра. +- Бинарные ответы, включая PDF-этикетки заказов, загружаются целиком в память. + Streaming API в версии 2.1.0 нет. +- Один `AsyncAvitoClient` нельзя переносить между event loop. Создавайте клиент в + том loop, где он будет использоваться. + +## Использование под ASGI (FastAPI / aiohttp / Starlette) + +### FastAPI lifespan + +Создавайте клиент в lifespan, храните его в `app.state` и закрывайте на shutdown. + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import Depends, FastAPI, Request + +from avito import AsyncAvitoClient + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + async with AsyncAvitoClient.from_env() as avito: + app.state.avito = avito + yield + + +app = FastAPI(lifespan=lifespan) + + +def get_avito(request: Request) -> AsyncAvitoClient: + return request.app.state.avito + + +@app.get("/orders/summary") +async def orders_summary(avito: AsyncAvitoClient = Depends(get_avito)) -> dict[str, object]: + summary = await avito.order_summary() + return summary.to_dict() +``` + +### aiohttp cleanup_ctx + +```python +from collections.abc import AsyncIterator + +from aiohttp import web + +from avito import AsyncAvitoClient + +avito_key = web.AppKey("avito", AsyncAvitoClient) + + +async def avito_client_ctx(app: web.Application) -> AsyncIterator[None]: + async with AsyncAvitoClient.from_env() as avito: + app[avito_key] = avito + yield + + +async def orders_summary(request: web.Request) -> web.Response: + summary = await request.app[avito_key].order_summary() + return web.json_response(summary.to_dict()) + + +app = web.Application() +app.cleanup_ctx.append(avito_client_ctx) +app.router.add_get("/orders/summary", orders_summary) +``` + +### Per-worker isolation + +Под Gunicorn/Uvicorn создавайте один `AsyncAvitoClient` на worker process. Не +создавайте клиент в master process до fork и не передавайте его между процессами: +у каждого worker свой event loop, connection pool и набор async locks. + +### Запрещённый паттерн + +```python +from avito import AsyncAvitoClient + +avito = AsyncAvitoClient.from_env() + + +async def handler() -> dict[str, object]: + await avito.__aenter__() # ❌ loop-bound ресурсы создаются в request handler + return (await avito.order_summary()).to_dict() +``` + +Такой код привязывает внутренний `httpx.AsyncClient` к первому loop, который +коснулся handler. В тестах, background scheduler или другом worker loop это +приведёт к cross-loop ошибкам и утечкам соединений. + +### Background tasks + +`asyncio.create_task()` и FastAPI `BackgroundTasks`, которые исполняются в том же +event loop, могут использовать app-level клиент из lifespan. Для process pool, +отдельного worker или внешнего scheduler создавайте отдельный `AsyncAvitoClient` +внутри этого процесса и его loop. diff --git a/docs/site/how-to/index.md b/docs/site/how-to/index.md index 2c835b4..7c1a595 100644 --- a/docs/site/how-to/index.md +++ b/docs/site/how-to/index.md @@ -5,6 +5,7 @@ How-to раздел собирает рецепты для конкретных | Рецепт | Задача | |---|---| | [Авторизация и конфигурация](auth-and-config.md) | Создать клиент через env, явные ключи или `AvitoSettings` | +| [Async API](async.md) | Использовать `AsyncAvitoClient`, ASGI lifespan и async fake transport | | [Профиль, баланс и иерархия аккаунта](account-profile.md) | Получить профиль, баланс, историю операций и данные сотрудников | | [Объявления, статистика и продвижение](ad-listing-and-stats.md) | Найти объявления, открыть карточку, прочитать статистику и подготовить VAS | | [Продвижение с dry-run](promotion-dry-run.md) | Проверить payload write-операции без сетевого вызова | diff --git a/docs/site/index.md b/docs/site/index.md index 6a0a174..c9bfcb9 100644 --- a/docs/site/index.md +++ b/docs/site/index.md @@ -7,7 +7,7 @@ hide:
-**`avito-py`** — синхронный Python SDK для работы с Avito API через единый объектный фасад `AvitoClient`. +**`avito-py`** — Python SDK для работы с Avito API через sync/async фасады `AvitoClient` и `AsyncAvitoClient`. Скрывает transport, OAuth и retry-логику. Возвращает типизированные `dataclass`-модели. Покрывает 204 операции Avito API. ```bash @@ -39,7 +39,7 @@ pip install avito-py --- - Пошаговые рецепты: авторизация, мессенджер, заказы, пагинация, тестирование и другие. + Пошаговые рецепты: авторизация, async lifecycle, мессенджер, заказы, пагинация, тестирование и другие. [:octicons-arrow-right-24: How-to рецепты](how-to/index.md) diff --git a/docs/site/reference/client.md b/docs/site/reference/client.md index 90f37a3..8a7ee64 100644 --- a/docs/site/reference/client.md +++ b/docs/site/reference/client.md @@ -1,12 +1,17 @@ -# AvitoClient +# AvitoClient и AsyncAvitoClient `AvitoClient` — единственная публичная точка входа SDK. Он владеет конфигурацией, auth-provider и transport-слоем, а наружу отдаёт только доменные объекты. +`AsyncAvitoClient` предоставляет тот же фасад для async-кода. Он создаёт +loop-bound ресурсы в `async with`, закрывается через `aclose()` и возвращает +async-доменные объекты. + ## Контракт - `AvitoClient.from_env()` — основной путь для конфигурации из окружения. +- `AsyncAvitoClient.from_env()` — async-аналог; использовать только через `async with`. - `AvitoClient(client_id=..., client_secret=...)` — короткий явный путь для OAuth credentials. - `AvitoClient(AvitoSettings(...))` — полный путь для расширенной конфигурации. - Клиент поддерживает context manager и закрывает внутренние HTTP-клиенты в `close()`. @@ -17,6 +22,10 @@ ::: avito.AvitoClient +::: avito.AsyncAvitoClient + ## Безопасная диагностика ::: avito.AvitoClient.debug_info + +::: avito.AsyncAvitoClient.debug_info diff --git a/docs/site/reference/pagination.md b/docs/site/reference/pagination.md index 7052780..b9dd1b0 100644 --- a/docs/site/reference/pagination.md +++ b/docs/site/reference/pagination.md @@ -5,3 +5,9 @@ итерации. `materialize()` загружает все страницы и возвращает обычный список. ::: avito.PaginatedList + +`AsyncPaginatedList[T]` — async-аналог. Он не наследуется от `list`, читается через +`async for` или `await materialize()` и не поддерживает конкурентную итерацию одного +экземпляра из нескольких coroutine. + +::: avito.AsyncPaginatedList diff --git a/docs/site/reference/testing.md b/docs/site/reference/testing.md index 6335d05..66b7b6f 100644 --- a/docs/site/reference/testing.md +++ b/docs/site/reference/testing.md @@ -14,6 +14,10 @@ - `route_sequence()` задаёт последовательность ответов для retry и stateful-сценариев. - `FakeTransport.as_client()` создаёт полностью инициализированный `AvitoClient` поверх fake transport без реального HTTP. +- `AsyncFakeTransport.as_client()` создаёт полностью инициализированный + `AsyncAvitoClient` поверх `httpx.AsyncClient` и `httpx.MockTransport`. +- `FanoutPeakRecorder` измеряет пик одновременно выполняющихся async fake-запросов + в consumer-side тестах агрегаторов. - `RecordedRequest` позволяет проверять method, path, query params, headers и JSON body. Пользовательские тесты должны работать через публичные утилиты `avito.testing`, diff --git a/mkdocs.yml b/mkdocs.yml index 206d6e9..6b019cf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: avito-py -site_description: Синхронный Python SDK для Avito API +site_description: Sync/async Python SDK для Avito API site_url: https://p141592.github.io/avito_python_api/ repo_url: https://github.com/p141592/avito_python_api repo_name: p141592/avito_python_api diff --git a/pyproject.toml b/pyproject.toml index 5de4142..b5539c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "avito-py" -version = "2.0.0" +version = "2.1.0" description = "SDK для разработки инструментов на базе Avito API" authors = ["Nikolay Baryshnikov "] packages=[ diff --git a/tests/contracts/test_async_swagger_contracts.py b/tests/contracts/test_async_swagger_contracts.py index 8ac1e38..6312005 100644 --- a/tests/contracts/test_async_swagger_contracts.py +++ b/tests/contracts/test_async_swagger_contracts.py @@ -1,14 +1,84 @@ from __future__ import annotations +import warnings +from collections.abc import Iterator + +import pytest + +from avito.core.deprecation import _WARNED_SYMBOLS +from avito.core.exceptions import ( + AuthenticationError, + AuthorizationError, + ConflictError, + RateLimitError, + UpstreamApiError, + ValidationError, +) from avito.core.swagger_discovery import discover_swagger_bindings -from avito.core.swagger_registry import load_swagger_registry +from avito.core.swagger_registry import SwaggerOperation, load_swagger_registry +from avito.testing import ( + AsyncSwaggerFakeTransport, + error_payload, + generate_schema_value, + validate_schema_value, +) +_REGISTRY = load_swagger_registry() +_DISCOVERY = discover_swagger_bindings(registry=_REGISTRY) +_BINDINGS = tuple(binding for binding in _DISCOVERY.bindings if binding.variant == "async") +_BINDING_OPERATION_BY_KEY = {operation.key: operation for operation in _REGISTRY.operations} -def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: - discovery = discover_swagger_bindings(registry=load_swagger_registry()) - async_bindings = [binding for binding in discovery.bindings if binding.variant == "async"] - assert {binding.class_name for binding in async_bindings} == { +def _binding_id(binding: object) -> str: + operation_key = getattr(binding, "operation_key", None) + sdk_method = getattr(binding, "sdk_method", repr(binding)) + return operation_key or sdk_method + + +def _expected_exception_type(status_code: int, domain: str) -> type[Exception]: + if domain == "auth": + return AuthenticationError + if status_code == 400: + return ValidationError + if status_code == 401: + return AuthenticationError + if status_code == 403: + return AuthorizationError + if status_code == 409: + return ConflictError + if status_code == 422: + return ValidationError + if status_code == 429: + return RateLimitError + return UpstreamApiError + + +def _error_status_cases() -> tuple[tuple[SwaggerOperation, object, int, type[Exception]], ...]: + cases: list[tuple[SwaggerOperation, object, int, type[Exception]]] = [] + binding_by_operation = {binding.operation_key: binding for binding in _BINDINGS} + for operation in _REGISTRY.operations: + binding = binding_by_operation[operation.key] + for response in operation.error_responses: + if response.status_code.isdigit(): + status_code = int(response.status_code) + cases.append( + ( + operation, + binding, + status_code, + _expected_exception_type(status_code, binding.domain), + ) + ) + return tuple(cases) + + +def _error_status_id(case: tuple[SwaggerOperation, object, int, type[Exception]]) -> str: + operation, _binding, status_code, _expected_error = case + return f"{operation.key} {status_code}" + + +def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: + assert {binding.class_name for binding in _BINDINGS} == { "AsyncAccount", "AsyncAccountHierarchy", "AsyncAd", @@ -33,6 +103,8 @@ def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: "AsyncCpaCall", "AsyncCpaChat", "AsyncCpaLead", + "AsyncDeliveryOrder", + "AsyncDeliveryTask", "AsyncAutostrategyCampaign", "AsyncBbipPromotion", "AsyncApplication", @@ -45,13 +117,129 @@ def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: "AsyncRealtyBooking", "AsyncRealtyListing", "AsyncRealtyPricing", + "AsyncOrder", + "AsyncOrderLabel", "AsyncReview", "AsyncReviewAnswer", + "AsyncSandboxDelivery", "AsyncSpecialOfferCampaign", + "AsyncStock", "AsyncPromotionOrder", "AsyncTariff", "AsyncTargetActionPricing", "AsyncTokenClient", "AsyncTrxPromotion", } - assert len(async_bindings) == 159 + assert len(_BINDINGS) == 204 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("binding", _BINDINGS, ids=_binding_id) +async def test_async_swagger_fake_transport_invokes_every_discovered_binding(binding: object) -> None: + fake = AsyncSwaggerFakeTransport(registry=_REGISTRY) + fake.add_success_operation(binding.operation_key or "") + + warning_context: Iterator[object] + if binding.deprecated: + _WARNED_SYMBOLS.clear() + warning_context = pytest.warns(DeprecationWarning) + else: + warning_context = warnings.catch_warnings() + with warning_context: + if not binding.deprecated: + warnings.simplefilter("ignore", DeprecationWarning) + await fake.invoke_binding(binding) + + assert fake.count() >= 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("binding", _BINDINGS, ids=_binding_id) +async def test_async_swagger_fake_transport_request_body_matches_swagger_schema( + binding: object, +) -> None: + if binding.operation_key is None: + pytest.fail(f"{binding.sdk_method}: binding без operation_key") + operation = _BINDING_OPERATION_BY_KEY[binding.operation_key] + if ( + operation.request_body is None + or "application/json" not in operation.request_body.content_types + ): + return + + fake = AsyncSwaggerFakeTransport(registry=_REGISTRY) + fake.add_success_operation(binding.operation_key) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + await fake.invoke_binding(binding) + + request = fake.last() + if request.json_body is None: + assert not operation.request_body.required + return + assert operation.request_body.schema is not None + validate_schema_value( + request.json_body, + operation.request_body.schema, + path=f"{operation.key}.requestBody", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("binding", _BINDINGS, ids=_binding_id) +async def test_async_swagger_success_response_models_accept_swagger_schema_payload( + binding: object, +) -> None: + if binding.operation_key is None: + pytest.fail(f"{binding.sdk_method}: binding без operation_key") + operation = _BINDING_OPERATION_BY_KEY[binding.operation_key] + response = next( + ( + item + for item in operation.success_responses + if "application/json" in item.content_types and item.schema is not None + ), + None, + ) + if response is None: + return + + payload = generate_schema_value(response.schema) + validate_schema_value(payload, response.schema, path=f"{operation.key}.{response.status_code}") + fake = AsyncSwaggerFakeTransport(registry=_REGISTRY) + fake.add_operation(operation.key, payload, status_code=int(response.status_code)) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + result = await fake.invoke_binding(binding) + + assert not isinstance(result, dict) + + +def test_async_swagger_error_contract_coverage_matches_numeric_error_responses() -> None: + cases = _error_status_cases() + expected_count = sum( + 1 + for operation in _REGISTRY.operations + for response in operation.error_responses + if response.status_code.isdigit() + ) + + assert len(cases) == expected_count == 639 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", _error_status_cases(), ids=_error_status_id) +async def test_async_swagger_fake_transport_maps_every_declared_error_status( + case: tuple[SwaggerOperation, object, int, type[Exception]], +) -> None: + operation, binding, status_code, expected_error = case + fake = AsyncSwaggerFakeTransport(registry=_REGISTRY) + fake.add_operation(operation.key, error_payload(status_code), status_code=status_code) + + with pytest.raises(expected_error) as exc_info: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + await fake.invoke_binding(binding) + + assert exc_info.value.args[0] == f"Ошибка {status_code}" diff --git a/tests/domains/orders/test_orders_async.py b/tests/domains/orders/test_orders_async.py new file mode 100644 index 0000000..e41d75f --- /dev/null +++ b/tests/domains/orders/test_orders_async.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.core import ValidationError +from avito.orders import ( + AsyncDeliveryOrder, + AsyncDeliveryTask, + AsyncOrder, + AsyncOrderLabel, + AsyncSandboxDelivery, + AsyncStock, + OrderTransition, +) +from avito.orders.models import StockUpdateEntry +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_order_management_flows() -> None: + def update_markings(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"markings": [{"orderId": "ord-1", "markings": ["abc"]}]} + assert request.headers["idempotency-key"] == "markings-key" + return httpx.Response( + 200, + json={"result": {"success": True, "orderId": "ord-1", "status": "marked"}}, + ) + + fake = ( + AsyncFakeTransport() + .add_json( + "GET", + "/order-management/1/orders", + { + "orders": [ + {"id": "ord-1", "status": "new", "buyerInfo": {"fullName": "Иван"}} + ], + "total": 1, + }, + ) + .add("POST", "/order-management/1/markings", update_markings) + .add_json( + "POST", + "/order-management/1/order/applyTransition", + {"result": {"success": True, "orderId": "ord-1", "status": "confirmed"}}, + ) + .add_json( + "POST", + "/order-management/1/order/checkConfirmationCode", + {"result": {"success": True, "orderId": "ord-1", "status": "code-valid"}}, + ) + .add_json( + "GET", + "/order-management/1/order/getCourierDeliveryRange", + { + "result": { + "address": "Москва", + "timeIntervals": [ + { + "id": "int-1", + "date": "2026-04-18", + "startAt": "10:00", + "endAt": "12:00", + } + ], + } + }, + ) + .add_json( + "POST", + "/order-management/1/order/setCourierDeliveryRange", + {"result": {"success": True, "status": "range-set"}}, + ) + .add_json( + "POST", + "/order-management/1/order/setTrackingNumber", + {"result": {"success": True, "status": "tracking-set"}}, + ) + .add_json( + "POST", + "/order-management/1/order/acceptReturnOrder", + {"result": {"success": True, "status": "return-accepted"}}, + ) + ) + transport = fake.build() + order = AsyncOrder(transport) + + assert (await order.list()).items[0].buyer_name == "Иван" + assert ( + await order.update_markings( + order_id="ord-1", + codes=["abc"], + idempotency_key="markings-key", + ) + ).status == "marked" + assert (await order.apply(order_id="ord-1", transition=OrderTransition.CONFIRM)).status == "confirmed" + assert (await order.check_confirmation_code(order_id="ord-1", code="1234")).status == "code-valid" + assert (await order.get_courier_delivery_range()).items[0].interval_id == "int-1" + assert (await order.set_courier_delivery_range(order_id="ord-1", interval_id="int-1")).status == "range-set" + assert (await order.update_tracking_number(order_id="ord-1", tracking_number="TRK-1")).status == "tracking-set" + assert (await order.accept_return_order(order_id="ord-1", postal_office_id="ops-1")).status == "return-accepted" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_labels_delivery_and_stock_flows() -> None: + pdf_bytes = b"%PDF-1.4 fake" + + def create_announcement(request: RecordedRequest) -> httpx.Response: + assert request.json_body is not None + assert request.json_body["announcementID"] == "ord-1" + assert "packages" in request.json_body + return httpx.Response(200, json={"data": {"taskId": 11, "status": "announcement-created"}}) + + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/order-management/1/orders/labels", + {"result": {"taskId": 42, "status": "created"}}, + ) + .add( + "GET", + "/order-management/1/orders/labels/42/download", + httpx.Response( + 200, + content=pdf_bytes, + headers={ + "content-type": "application/pdf", + "content-disposition": 'attachment; filename="label-42.pdf"', + }, + ), + ) + .add("POST", "/createAnnouncement", create_announcement) + .add_json("POST", "/createParcel", {"data": {"parcelId": "par-1", "status": "parcel-created"}}) + .add_json("POST", "/cancelAnnouncement", {"data": {"status": "announcement-cancelled"}}) + .add_json( + "POST", + "/delivery/order/changeParcelResult", + {"data": {"status": "callback-accepted"}}, + ) + .add_json("POST", "/sandbox/changeParcels", {"data": {"status": "parcels-updated"}}) + .add_json("GET", "/delivery-sandbox/tasks/51", {"data": {"taskId": 51, "status": "done"}}) + .add_json( + "POST", + "/stock-management/1/info", + { + "stocks": [ + { + "item_id": 123321, + "quantity": 5, + "is_multiple": True, + "is_unlimited": False, + "is_out_of_stock": False, + } + ] + }, + ) + .add_json( + "PUT", + "/stock-management/1/stocks", + {"stocks": [{"item_id": 123321, "external_id": "AB123456", "success": True, "errors": []}]}, + ) + ) + transport = fake.build() + label = AsyncOrderLabel(transport, task_id="42") + delivery = AsyncDeliveryOrder(transport) + task = AsyncDeliveryTask(transport, task_id="51") + stock = AsyncStock(transport) + + assert (await label.create(order_ids=["ord-1"])).task_id == "42" + assert (await label.download()).binary.content == pdf_bytes + assert (await delivery.create_announcement(order_id="ord-1")).task_id == "11" + assert (await delivery.create(order_id="ord-1", parcel_id="par-1")).parcel_id == "par-1" + assert (await delivery.delete(order_id="ord-1")).status == "announcement-cancelled" + assert (await delivery.create_change_parcel_result(parcel_id="par-1", result="ok")).status == "callback-accepted" + assert (await delivery.update_change_parcels(parcel_ids=["par-1"])).status == "parcels-updated" + assert (await task.get()).status == "done" + assert (await stock.get(item_ids=[123321])).items[0].quantity == 5 + assert (await stock.update(stocks=[StockUpdateEntry(item_id=123321, quantity=7)])).items[0].success is True + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_sandbox_delivery_rejects_invalid_event_dates_before_transport() -> None: + fake = AsyncFakeTransport() + transport = fake.build() + delivery = AsyncSandboxDelivery(transport) + + with pytest.raises(ValidationError, match="date"): + await delivery.tracking( + order_id="ord-1", + avito_status="CONFIRMED", + avito_event_type="", + provider_event_code="accepted", + date="not-a-date", + location="Москва", + ) + + assert fake.count() == 0 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_order_apply_rejects_unknown_transition_before_transport() -> None: + fake = AsyncFakeTransport() + transport = fake.build() + order = AsyncOrder(transport) + + with pytest.raises(ValidationError, match="transition"): + await order.apply(order_id="ord-1", transition="unknown") + + assert fake.count() == 0 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_orders_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client() + + assert isinstance(client.order(), AsyncOrder) + assert isinstance(client.order_label(task_id="42"), AsyncOrderLabel) + assert isinstance(client.delivery_order(), AsyncDeliveryOrder) + assert isinstance(client.sandbox_delivery(), AsyncSandboxDelivery) + assert isinstance(client.delivery_task(task_id="51"), AsyncDeliveryTask) + assert isinstance(client.stock(), AsyncStock) + await client.aclose() diff --git a/tests/test_async_client_aggregators.py b/tests/test_async_client_aggregators.py new file mode 100644 index 0000000..7e751be --- /dev/null +++ b/tests/test_async_client_aggregators.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import pytest + +from avito.summary import AccountHealthSummary, ListingHealthSummary, PromotionSummary +from avito.testing import AsyncFakeTransport, FanoutPeakRecorder + + +def _summary_fake(*, recorder: FanoutPeakRecorder) -> AsyncFakeTransport: + return ( + AsyncFakeTransport(fanout_recorder=recorder) + .add_json( + "GET", + "/core/v1/accounts/7/balance/", + {"user_id": 7, "real": 100, "bonus": 25, "total": 125}, + ) + .add_json( + "GET", + "/core/v1/items", + {"items": [{"id": 101, "title": "Смартфон", "status": "active"}], "total": 1}, + ) + .add_json( + "POST", + "/stats/v1/accounts/7/items", + {"items": [{"item_id": 101, "views": 10}]}, + ) + .add_json( + "POST", + "/core/v1/accounts/7/calls/stats/", + {"items": [{"item_id": 101, "calls": 2}]}, + ) + .add_json( + "POST", + "/stats/v2/accounts/7/spendings", + {"items": [{"item_id": 101, "amount": 15.5}]}, + ) + .add_json( + "GET", + "/messenger/v2/accounts/7/chats", + {"chats": [{"id": "c1", "unreadCount": 4}, {"id": "c2", "unreadCount": 0}]}, + ) + .add_json( + "GET", + "/order-management/1/orders", + {"orders": [{"id": "o1", "status": "new"}, {"id": "o2", "status": "unknown"}]}, + ) + .add_json( + "GET", + "/ratings/v1/reviews", + { + "total": 2, + "reviews": [ + {"id": 1, "score": 5, "canAnswer": True}, + {"id": 2, "score": 3, "canAnswer": False}, + ], + }, + ) + .add_json("GET", "/ratings/v1/info", {"isEnabled": True, "rating": {"score": 4.5}}) + .add_json( + "POST", + "/promotion/v1/items/services/orders/get", + {"orders": [{"orderId": "p1", "status": "applied"}]}, + ) + .add_json( + "POST", + "/promotion/v1/items/services/get", + {"services": [{"itemId": 101, "status": "available"}]}, + ) + ) + + +@pytest.mark.asyncio +async def test_account_health_fanout_does_not_exceed_six() -> None: + recorder = FanoutPeakRecorder() + client = _summary_fake(recorder=recorder).as_client(user_id=7) + + summary = await client.account_health() + + assert isinstance(summary, AccountHealthSummary) + assert summary.balance_total == 125 + assert recorder.peak <= 6 + await client.aclose() + + +@pytest.mark.asyncio +async def test_listing_health_fanout_does_not_exceed_three() -> None: + recorder = FanoutPeakRecorder() + client = _summary_fake(recorder=recorder).as_client(user_id=7) + + summary = await client.listing_health() + + assert isinstance(summary, ListingHealthSummary) + assert summary.total_views == 10 + assert recorder.peak <= 3 + await client.aclose() + + +@pytest.mark.asyncio +async def test_review_summary_is_sequential() -> None: + recorder = FanoutPeakRecorder() + client = _summary_fake(recorder=recorder).as_client(user_id=7) + + summary = await client.review_summary() + + assert summary.average_score == 4 + assert recorder.peak <= 1 + await client.aclose() + + +@pytest.mark.asyncio +async def test_promotion_summary_with_items_fanout_does_not_exceed_two() -> None: + recorder = FanoutPeakRecorder() + client = _summary_fake(recorder=recorder).as_client(user_id=7) + + summary = await client.promotion_summary(item_ids=[101]) + + assert isinstance(summary, PromotionSummary) + assert summary.available_services == 1 + assert recorder.peak <= 2 + await client.aclose() + + +@pytest.mark.asyncio +async def test_business_summary_delegates_to_account_health_fanout() -> None: + recorder = FanoutPeakRecorder() + client = _summary_fake(recorder=recorder).as_client(user_id=7) + + summary = await client.business_summary() + + assert isinstance(summary, AccountHealthSummary) + assert summary.orders is not None + assert recorder.peak <= 6 + await client.aclose() From 517127da3b7a5d10adafdd73c55013e9350fb88a Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 22:15:59 +0300 Subject: [PATCH 23/26] Final --- avito/accounts/async_domain.py | 4 +++ avito/ads/async_domain.py | 14 +++++++++ avito/async_client.py | 9 ++++++ avito/auth/_cache.py | 4 +++ avito/auth/async_provider.py | 10 +++++- avito/auth/async_token_client.py | 1 + avito/auth/provider.py | 16 +++++++++- avito/autoteka/async_domain.py | 4 +++ avito/client.py | 12 +++++++ avito/core/_async_rate_limit.py | 1 + avito/core/_transport_shared.py | 22 +++++++++++++ avito/core/async_pagination.py | 6 ++++ avito/core/async_transport.py | 5 +++ avito/core/deprecation.py | 5 +++ avito/core/domain.py | 1 + avito/core/operations.py | 8 +++++ avito/core/rate_limit.py | 2 ++ avito/core/swagger.py | 5 +++ avito/core/swagger_discovery.py | 13 ++++++++ avito/core/swagger_linter.py | 31 +++++++++++++++++++ avito/core/swagger_report.py | 8 +++++ avito/core/transport.py | 23 ++++++++++++++ avito/cpa/async_domain.py | 3 ++ avito/jobs/async_domain.py | 3 ++ avito/messenger/async_domain.py | 7 +++++ avito/orders/async_domain.py | 2 ++ avito/promotion/async_domain.py | 6 ++++ avito/ratings/async_domain.py | 1 + avito/realty/async_domain.py | 6 ++++ avito/testing/async_fake_transport.py | 8 +++++ avito/testing/async_swagger_fake_transport.py | 7 +++++ 31 files changed, 245 insertions(+), 2 deletions(-) diff --git a/avito/accounts/async_domain.py b/avito/accounts/async_domain.py index 3bd82bc..dd8fa4f 100644 --- a/avito/accounts/async_domain.py +++ b/avito/accounts/async_domain.py @@ -42,6 +42,7 @@ def _serialize_datetime(value: datetime) -> str: + """Serialize datetime.""" return value.isoformat() @@ -159,6 +160,7 @@ async def get_operations_history( """ async def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[OperationRecord]: + """Fetch one page of results.""" result = await self._execute( GET_OPERATIONS_HISTORY, request=OperationsHistoryRequest( @@ -176,6 +178,7 @@ async def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[Operatio return AsyncPaginator(fetch_page).as_list(first_page=await fetch_page(1, None)) async def _resolve_account_user_id(self, user_id: int | None) -> int: + """Resolve account user id.""" if user_id is not None or self.user_id is not None: return await self._resolve_user_id(user_id or self.user_id) profile = await self.get_self() @@ -373,6 +376,7 @@ async def list_items_by_employee( """ async def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[EmployeeItem]: + """Fetch one page of results.""" current_page = page or 1 result = await self._execute( LIST_ITEMS_BY_EMPLOYEE, diff --git a/avito/ads/async_domain.py b/avito/ads/async_domain.py index 2860204..dd6987c 100644 --- a/avito/ads/async_domain.py +++ b/avito/ads/async_domain.py @@ -95,6 +95,7 @@ def _preview_result( target: dict[str, object], request_payload: dict[str, object], ) -> PromotionActionResult: + """Build result.""" return PromotionActionResult( action=action, target=target, @@ -109,10 +110,12 @@ def _preview_result( def _serialize_stats_date(value: StatsDate) -> str: + """Serialize stats date.""" return serialize_iso_date("date", value) def _bounded_total(total: int | None, max_items: int | None) -> int | None: + """Return bounded total.""" if max_items is None: return total if total is None: @@ -129,6 +132,7 @@ def _has_next_ads_page( max_items: int | None, already_collected: int, ) -> bool: + """Return whether next ads page.""" if page_item_count == 0 or page_size <= 0: return False if max_items is not None and already_collected + collected_count >= max_items: @@ -331,6 +335,7 @@ async def _fetch_ads_page( max_items: int | None, first_page_number: int, ) -> JsonPage[Listing]: + """Fetch ads page.""" if page is None: raise ValidationError("Для операции требуется `page`.") @@ -366,11 +371,13 @@ async def _fetch_ads_page( ) def _require_item_id(self) -> int: + """Validate required item id.""" if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") return int(self.item_id) async def _require_ids(self) -> tuple[int, int]: + """Validate required ids.""" if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") return int(self.item_id), await self._resolve_user_id(self.user_id) @@ -625,6 +632,7 @@ async def get_account_spendings( ) async def _require_user_id(self) -> int: + """Validate required user id.""" return await self._resolve_user_id(self.user_id) @@ -872,14 +880,17 @@ async def apply_vas_direct( ) def _require_item_id(self) -> int: + """Validate required item id.""" if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") return int(self.item_id) async def _require_user_id(self) -> int: + """Validate required user id.""" return await self._resolve_user_id(self.user_id) async def _require_ids(self) -> tuple[int, int]: + """Validate required ids.""" return self._require_item_id(), await self._require_user_id() @@ -1184,6 +1195,7 @@ async def list( async def fetch_page( page: int | None, _cursor: str | None ) -> JsonPage[AutoloadReportSummary]: + """Fetch one page of results.""" current_page = page or 1 current_offset = base_offset + (current_page - 1) * page_size result = await self._execute( @@ -1416,6 +1428,7 @@ async def get_items_info( ) def _require_report_id(self) -> int: + """Validate required report id.""" if self.report_id is None: raise ValidationError("Для операции требуется `report_id`.") return int(self.report_id) @@ -1619,6 +1632,7 @@ async def get_report( ) def _require_report_id(self) -> int: + """Validate required report id.""" if self.report_id is None: raise ValidationError("Для операции требуется `report_id`.") return int(self.report_id) diff --git a/avito/async_client.py b/avito/async_client.py index f5793cd..b01caa9 100644 --- a/avito/async_client.py +++ b/avito/async_client.py @@ -113,6 +113,7 @@ def __init__( client_secret: str | None = None, http_client: httpx.AsyncClient | None = None, ) -> None: + """Initialize AsyncAvitoClient.""" if client_id is not None or client_secret is not None: auth = AuthSettings(client_id=client_id, client_secret=client_secret) settings = AvitoSettings(auth=auth) @@ -137,6 +138,7 @@ def _from_transport( transport: AsyncTransport, auth_provider: AsyncAuthProvider, ) -> AsyncAvitoClient: + """Run the from transport helper.""" client = cls.__new__(cls) client._closed = False client._entered = True @@ -147,6 +149,7 @@ def _from_transport( return client async def __aenter__(self) -> AsyncAvitoClient: + """Enter the async context manager.""" self._ensure_open() if self._entered: return self @@ -164,6 +167,7 @@ async def __aenter__(self) -> AsyncAvitoClient: raise async def __aexit__(self, *exc: object) -> None: + """Exit the async context manager.""" await self.aclose() @property @@ -893,6 +897,7 @@ async def aclose(self) -> None: await auth_provider.aclose() def _build_auth_provider(self) -> AsyncAuthProvider: + """Build auth provider.""" token_client = AsyncTokenClient( self.settings.auth, client=self._external_http_client, @@ -917,10 +922,12 @@ def _build_auth_provider(self) -> AsyncAuthProvider: ) def _ensure_open(self) -> None: + """Ensure open.""" if self._closed: raise ClientClosedError("Клиент закрыт; создайте новый AsyncAvitoClient.") def _ensure_ready(self) -> None: + """Ensure ready.""" self._ensure_open() if not self._entered: raise RuntimeError( @@ -929,12 +936,14 @@ def _ensure_ready(self) -> None: ) def _require_transport(self) -> AsyncTransport: + """Validate required transport.""" self._ensure_ready() if self._transport is None: raise RuntimeError("AsyncAvitoClient не инициализирован: используйте 'async with'.") return self._transport async def _resolve_user_id(self, user_id: int | str | None = None) -> int: + """Resolve user id.""" return await AsyncAccount(self._require_transport(), user_id=user_id)._resolve_user_id( user_id ) diff --git a/avito/auth/_cache.py b/avito/auth/_cache.py index 59b0733..27c6061 100644 --- a/avito/auth/_cache.py +++ b/avito/auth/_cache.py @@ -18,17 +18,21 @@ class TokenCache: autoteka_access_token: AccessToken | None = None def access_is_fresh(self, now: datetime) -> bool: + """Return whether the cached access token is still fresh.""" return self.access_token is not None and not self.access_token.is_expired(now) def autoteka_is_fresh(self, now: datetime) -> bool: + """Return whether the cached Autoteka token is still fresh.""" return self.autoteka_access_token is not None and not self.autoteka_access_token.is_expired( now ) def reset_access(self) -> None: + """Clear the cached access token.""" self.access_token = None def reset_autoteka(self) -> None: + """Clear the cached Autoteka token.""" self.autoteka_access_token = None diff --git a/avito/auth/async_provider.py b/avito/auth/async_provider.py index 3d27530..b3b56c6 100644 --- a/avito/auth/async_provider.py +++ b/avito/auth/async_provider.py @@ -22,7 +22,9 @@ class AsyncTokenFetcher(Protocol): """Контракт async-получения нового access token из внешнего источника.""" - async def __call__(self, settings: AuthSettings) -> TokenResponse: ... + async def __call__(self, settings: AuthSettings) -> TokenResponse: + """Fetch a token payload.""" + ... @dataclass(slots=True) @@ -116,6 +118,7 @@ def alternate_token_flow(self) -> AsyncAlternateTokenClient: return self._get_alternate_token_client() async def _fetch_token_response(self) -> TokenResponse: + """Fetch token response.""" if self.token_fetcher is not None: token_response = await self.token_fetcher(self.settings) if isinstance(token_response, AccessToken): @@ -148,6 +151,7 @@ async def _fetch_token_response(self) -> TokenResponse: ) def _get_token_client(self) -> AsyncTokenClient: + """Return token client.""" if self.token_client is None: self.token_client = AsyncTokenClient(self.settings) if self.token_client is None: @@ -155,6 +159,7 @@ def _get_token_client(self) -> AsyncTokenClient: return self.token_client def _get_alternate_token_client(self) -> AsyncAlternateTokenClient: + """Return alternate token client.""" if self.alternate_token_client is None: self.alternate_token_client = AsyncAlternateTokenClient(self.settings) if self.alternate_token_client is None: @@ -162,6 +167,7 @@ def _get_alternate_token_client(self) -> AsyncAlternateTokenClient: return self.alternate_token_client def _get_autoteka_token_client(self) -> AsyncTokenClient: + """Return autoteka token client.""" if self.autoteka_token_client is None: self.autoteka_token_client = AsyncTokenClient( self.settings, @@ -172,11 +178,13 @@ def _get_autoteka_token_client(self) -> AsyncTokenClient: return self.autoteka_token_client def _require_client_id(self) -> str: + """Validate required client id.""" if self.settings.client_id is None: raise AuthenticationError("Для OAuth flow не задан `client_id`.") return self.settings.client_id def _require_client_secret(self) -> str: + """Validate required client secret.""" if self.settings.client_secret is None: raise AuthenticationError("Для OAuth flow не задан `client_secret`.") return self.settings.client_secret diff --git a/avito/auth/async_token_client.py b/avito/auth/async_token_client.py index 7443537..4f8e0f5 100644 --- a/avito/auth/async_token_client.py +++ b/avito/auth/async_token_client.py @@ -86,6 +86,7 @@ async def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResp return await self._request_token(payload) async def _request_token(self, payload: dict[str, str]) -> TokenResponse: + """Run the request token helper.""" from avito.core.async_transport import AsyncTransport transport = AsyncTransport( diff --git a/avito/auth/provider.py b/avito/auth/provider.py index 0534362..985b8a3 100644 --- a/avito/auth/provider.py +++ b/avito/auth/provider.py @@ -38,7 +38,9 @@ class TokenFetcher(Protocol): """Контракт получения нового access token из внешнего источника.""" - def __call__(self, settings: AuthSettings) -> TokenResponse: ... + def __call__(self, settings: AuthSettings) -> TokenResponse: + """Fetch a token payload.""" + ... @dataclass(slots=True) @@ -60,6 +62,7 @@ def _access_token(self) -> AccessToken | None: @_access_token.setter def _access_token(self, value: AccessToken | None) -> None: + """Run the access token helper.""" self._cache.access_token = value @property @@ -70,6 +73,7 @@ def _refresh_token(self) -> str | None: @_refresh_token.setter def _refresh_token(self, value: str | None) -> None: + """Run the refresh token helper.""" self._cache.refresh_token = value @property @@ -80,6 +84,7 @@ def _autoteka_access_token(self) -> AccessToken | None: @_autoteka_access_token.setter def _autoteka_access_token(self, value: AccessToken | None) -> None: + """Run the autoteka access token helper.""" self._cache.autoteka_access_token = value def get_access_token(self) -> str: @@ -145,6 +150,7 @@ def alternate_token_flow(self) -> AlternateTokenClient: return self._get_alternate_token_client() def _fetch_token_response(self) -> TokenResponse: + """Fetch token response.""" if self.token_fetcher is not None: token_response = self.token_fetcher(self.settings) if isinstance(token_response, AccessToken): @@ -183,6 +189,7 @@ def _update_tokens( refresh_token: str | None | object = _UNSET, autoteka_access_token: AccessToken | None | object = _UNSET, ) -> None: + """Run the update tokens helper.""" if access_token is not _UNSET: self._access_token = access_token if isinstance(access_token, AccessToken) else None if refresh_token is not _UNSET: @@ -194,6 +201,7 @@ def _update_tokens( ) def _get_token_client(self) -> TokenClient: + """Return token client.""" if self.token_client is None: self.token_client = TokenClient(self.settings) token_client = self.token_client @@ -202,6 +210,7 @@ def _get_token_client(self) -> TokenClient: return token_client def _get_alternate_token_client(self) -> AlternateTokenClient: + """Return alternate token client.""" if self.alternate_token_client is None: self.alternate_token_client = AlternateTokenClient(self.settings) alternate_token_client = self.alternate_token_client @@ -210,6 +219,7 @@ def _get_alternate_token_client(self) -> AlternateTokenClient: return alternate_token_client def _get_autoteka_token_client(self) -> TokenClient: + """Return autoteka token client.""" if self.autoteka_token_client is None: self.autoteka_token_client = TokenClient( self.settings, @@ -221,11 +231,13 @@ def _get_autoteka_token_client(self) -> TokenClient: return autoteka_token_client def _require_client_id(self) -> str: + """Validate required client id.""" if self.settings.client_id is None: raise AuthenticationError("Для OAuth flow не задан `client_id`.") return self.settings.client_id def _require_client_secret(self) -> str: + """Validate required client secret.""" if self.settings.client_secret is None: raise AuthenticationError("Для OAuth flow не задан `client_secret`.") return self.settings.client_secret @@ -299,6 +311,7 @@ def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResponse: return self._request_token(payload) def _request_token(self, payload: dict[str, str]) -> TokenResponse: + """Run the request token helper.""" transport = self._build_transport() try: response = transport.request( @@ -342,6 +355,7 @@ def _request_token(self, payload: dict[str, str]) -> TokenResponse: return _map_token_response(payload_object) def _build_transport(self) -> Transport: + """Build transport.""" return Transport( self.sdk_settings or AvitoSettings(auth=self.settings), auth_provider=None, diff --git a/avito/autoteka/async_domain.py b/avito/autoteka/async_domain.py index 28889b1..accb2fe 100644 --- a/avito/autoteka/async_domain.py +++ b/avito/autoteka/async_domain.py @@ -66,6 +66,7 @@ async def _autoteka_headers(transport: AsyncTransport) -> dict[str, str]: + """Run the autoteka headers helper.""" auth_provider = transport.auth_provider if auth_provider is None: return {} @@ -598,6 +599,7 @@ async def get_teaser( ) def _require_vehicle_id(self, field_name: str) -> str: + """Validate required vehicle id.""" if self.vehicle_id is None: raise ValidationError(f"Для операции требуется `{field_name}`.") return str(self.vehicle_id) @@ -896,6 +898,7 @@ async def create_sync_report_by_vin( ) def _require_report_id(self) -> str: + """Validate required report id.""" if self.report_id is None: raise ValidationError("Для операции требуется `report_id`.") return str(self.report_id) @@ -1174,6 +1177,7 @@ async def get_scoring_by_id( ) def _require_scoring_id(self) -> str: + """Validate required scoring id.""" if self.scoring_id is None: raise ValidationError("Для операции требуется `scoring_id`.") return str(self.scoring_id) diff --git a/avito/client.py b/avito/client.py index 97922db..7860087 100644 --- a/avito/client.py +++ b/avito/client.py @@ -58,6 +58,7 @@ def _default_summary_date_range( date_from: SummaryDate | None, date_to: SummaryDate | None, ) -> tuple[SummaryDate, SummaryDate]: + """Run the default summary date range helper.""" if date_from is not None and date_to is not None: return date_from, date_to today = date.today().isoformat() @@ -65,6 +66,7 @@ def _default_summary_date_range( def _sum_optional_int(values: Iterable[int | None]) -> int | None: + """Run the sum optional int helper.""" resolved = [value for value in values if value is not None] if not resolved: return None @@ -72,6 +74,7 @@ def _sum_optional_int(values: Iterable[int | None]) -> int | None: def _sum_optional_float(values: Iterable[float | None]) -> float | None: + """Run the sum optional float helper.""" resolved = [value for value in values if value is not None] if not resolved: return None @@ -79,6 +82,7 @@ def _sum_optional_float(values: Iterable[float | None]) -> float | None: def _summary_unavailable_section(section: str, error: AvitoError) -> SummaryUnavailableSection: + """Run the summary unavailable section helper.""" return SummaryUnavailableSection( section=section, operation=error.operation, @@ -92,6 +96,7 @@ def _safe_summary[SummaryT]( section: str, factory: Callable[[], SummaryT], ) -> tuple[SummaryT | None, list[SummaryUnavailableSection]]: + """Return a safe summary.""" try: return factory(), [] except AvitoError as error: @@ -102,6 +107,7 @@ async def _safe_summary_async[SummaryT]( section: str, factory: Callable[[], Awaitable[SummaryT]], ) -> tuple[SummaryT | None, list[SummaryUnavailableSection]]: + """Return a safe summary async.""" try: return await factory(), [] except AvitoError as error: @@ -128,6 +134,7 @@ def __init__( client_id: str | None = None, client_secret: str | None = None, ) -> None: + """Initialize AvitoClient.""" if client_id is not None or client_secret is not None: from avito.auth.settings import AuthSettings @@ -152,6 +159,7 @@ def _from_transport( transport: Transport, auth_provider: AuthProvider, ) -> AvitoClient: + """Run the from transport helper.""" client = cls.__new__(cls) client._closed = False client._settings = settings @@ -565,6 +573,7 @@ def __exit__( self.close() def _build_auth_provider(self) -> AuthProvider: + """Build auth provider.""" return AuthProvider( self.settings.auth, token_client=TokenClient(self.settings.auth, sdk_settings=self.settings), @@ -580,14 +589,17 @@ def _build_auth_provider(self) -> AuthProvider: ) def _ensure_open(self) -> None: + """Ensure open.""" if self._closed: raise ClientClosedError("Клиент закрыт; создайте новый AvitoClient.") def _require_transport(self) -> Transport: + """Validate required transport.""" self._ensure_open() return self.transport def _resolve_user_id(self, user_id: int | str | None = None) -> int: + """Resolve user id.""" return Account(self._require_transport(), user_id=user_id)._resolve_user_id(user_id) def account(self, user_id: int | str | None = None) -> Account: diff --git a/avito/core/_async_rate_limit.py b/avito/core/_async_rate_limit.py index a29feec..8c7c0c5 100644 --- a/avito/core/_async_rate_limit.py +++ b/avito/core/_async_rate_limit.py @@ -20,6 +20,7 @@ def __init__( clock: Callable[[], float] = time.monotonic, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, ) -> None: + """Initialize AsyncRateLimiter.""" self._clock = clock self._sleep = sleep self._state = RateLimitState.from_policy(policy, now=clock()) diff --git a/avito/core/_transport_shared.py b/avito/core/_transport_shared.py index d8feda8..89d8456 100644 --- a/avito/core/_transport_shared.py +++ b/avito/core/_transport_shared.py @@ -59,6 +59,7 @@ class RateLimitState: @classmethod def from_policy(cls, policy: RetryPolicy, *, now: float) -> RateLimitState: + """Build rate limit state from retry policy settings.""" capacity = max(policy.rate_limit_burst, 0) return cls( enabled=policy.rate_limit_enabled, @@ -99,6 +100,7 @@ def observe_response(self, *, now: float, headers: Mapping[str, str]) -> None: self.tokens = min(self.tokens, 0.0) def _refill(self, now: float) -> None: + """Refill available rate limit tokens.""" elapsed = max(now - self.updated_at, 0.0) if elapsed > 0.0: self.tokens = min(float(self.capacity), self.tokens + elapsed * self.rate) @@ -117,6 +119,7 @@ def build_httpx_timeout(timeouts: ApiTimeouts) -> httpx.Timeout: def normalize_path(path: str) -> str: + """Normalize path.""" stripped = path.strip() if not stripped: return "/" @@ -131,6 +134,7 @@ def normalize_path(path: str) -> str: def normalize_params(params: Mapping[str, object] | None) -> QueryParams | None: + """Normalize params.""" if params is None: return None normalized: dict[str, QueryParamValue] = {} @@ -145,18 +149,21 @@ def normalize_params(params: Mapping[str, object] | None) -> QueryParams | None: def normalize_query_scalar(value: object) -> QueryScalar: + """Normalize query scalar.""" if isinstance(value, str | int | float | bool): return value return str(value) def normalize_files(files: Mapping[str, object] | None) -> RequestFiles | None: + """Normalize files.""" if files is None: return None return {key: normalize_file_value(value) for key, value in files.items()} def normalize_file_value(value: object) -> FileValue: + """Normalize file value.""" if isinstance(value, bytes | str | BytesIO): return value if isinstance(value, tuple): @@ -189,6 +196,7 @@ def merge_headers( def build_user_agent(user_agent_suffix: str | None) -> str: + """Build user agent.""" try: package_version = importlib_metadata.version("avito-py") except importlib_metadata.PackageNotFoundError: @@ -212,6 +220,7 @@ def decide_transport_retry( is_timeout: bool, idempotency_key: str | None, ) -> RetryDecision: + """Decide transport retry.""" if attempt >= retry_policy.max_attempts: return RetryDecision(False) if not retry_policy.retry_on_transport_error: @@ -239,6 +248,7 @@ def decide_http_retry( response: httpx.Response, idempotency_key: str | None, ) -> RetryDecision: + """Decide http retry.""" if attempt >= retry_policy.max_attempts: return RetryDecision(False) if not is_retryable_request( @@ -273,6 +283,7 @@ def is_retryable_request( context: RequestContext, idempotency_key: str | None, ) -> bool: + """Return whether retryable request.""" if context.retry_disabled: return False normalized_method = method.upper() @@ -289,6 +300,7 @@ def map_http_error( operation: str | None = None, attempt: int | None = None, ) -> Exception: + """Map http error.""" payload = safe_payload(response) message = extract_message(payload) or f"HTTP {response.status_code}" error_code = extract_error_code(payload) @@ -332,6 +344,7 @@ def map_http_error( def safe_payload(response: httpx.Response) -> object: + """Return a safe payload.""" content_type = response.headers.get("content-type", "") if "application/json" in content_type: try: @@ -342,6 +355,7 @@ def safe_payload(response: httpx.Response) -> object: def extract_message(payload: object) -> str | None: + """Extract message.""" if isinstance(payload, dict): for key in ("message", "error_description", "error", "detail"): value = payload.get(key) @@ -353,6 +367,7 @@ def extract_message(payload: object) -> str | None: def extract_error_code(payload: object) -> str | None: + """Extract error code.""" if not isinstance(payload, dict): return None value = payload.get("code") or payload.get("error") @@ -360,6 +375,7 @@ def extract_error_code(payload: object) -> str | None: def extract_error_details(payload: object) -> object | None: + """Extract error details.""" if not isinstance(payload, Mapping): return None for key in ("details", "fields", "errors", "violations"): @@ -370,6 +386,7 @@ def extract_error_details(payload: object) -> object | None: def extract_request_id(headers: Mapping[str, str]) -> str | None: + """Extract request id.""" for key in ("x-request-id", "x-correlation-id", "x-amzn-requestid"): value = headers.get(key) if value: @@ -378,6 +395,7 @@ def extract_request_id(headers: Mapping[str, str]) -> str | None: def get_retry_after_seconds(headers: Mapping[str, str]) -> float: + """Return retry after seconds.""" raw_value = headers.get("retry-after") if raw_value is None: return _MIN_RETRY_AFTER_SECONDS @@ -394,10 +412,12 @@ def get_retry_after_seconds(headers: Mapping[str, str]) -> float: def elapsed_ms(started_at: float) -> int: + """Return elapsed ms.""" return max(int((time.perf_counter() - started_at) * 1000), 0) def safe_endpoint(endpoint: str) -> str: + """Return a safe endpoint.""" parsed = urlsplit(endpoint) if parsed.scheme or parsed.netloc: return parsed.path or "/" @@ -405,6 +425,7 @@ def safe_endpoint(endpoint: str) -> str: def extract_filename(content_disposition: str | None) -> str | None: + """Extract filename.""" if content_disposition is None: return None message = Message() @@ -417,6 +438,7 @@ def extract_filename(content_disposition: str | None) -> str | None: def _get_header(headers: Mapping[str, str], name: str) -> str | None: + """Return header.""" value = headers.get(name) if value is not None: return value diff --git a/avito/core/async_pagination.py b/avito/core/async_pagination.py index 408dfad..5290566 100644 --- a/avito/core/async_pagination.py +++ b/avito/core/async_pagination.py @@ -19,6 +19,7 @@ def __init__( start_page: int = 1, first_page: JsonPage[ItemT] | None = None, ) -> None: + """Initialize AsyncPaginatedList.""" self._fetch_page = fetch_page self._items: list[ItemT] = [] self._known_total: int | None = None @@ -31,6 +32,7 @@ def __init__( self._consume_page(first_page) def __aiter__(self) -> AsyncIterator[ItemT]: + """Run the aiter helper.""" if self._active_iterator: raise RuntimeError( "AsyncPaginatedList уже итерируется; используйте materialize() " @@ -40,6 +42,7 @@ def __aiter__(self) -> AsyncIterator[ItemT]: return self._iterate() async def _iterate(self) -> AsyncIterator[ItemT]: + """Iterate iterate.""" index = 0 try: while True: @@ -91,12 +94,14 @@ def is_materialized(self) -> bool: return self._exhausted async def _load_next_page(self) -> None: + """Load next page.""" if self._exhausted: return page = await self._fetch_page(self._next_page_number, self._next_cursor) self._consume_page(page) def _consume_page(self, page: JsonPage[ItemT]) -> None: + """Consume page.""" self._items.extend(page.items) self._known_total = page.total if page.source_total is not None: @@ -125,6 +130,7 @@ class AsyncPaginator[ItemT]: """Обходит страницы API асинхронно и собирает типизированный результат.""" def __init__(self, fetch_page: AsyncPageFetcher[ItemT]) -> None: + """Initialize AsyncPaginator.""" self._fetch_page = fetch_page async def iter_pages(self, *, start_page: int = 1) -> AsyncIterator[JsonPage[ItemT]]: diff --git a/avito/core/async_transport.py b/avito/core/async_transport.py index de0efda..4db5cc1 100644 --- a/avito/core/async_transport.py +++ b/avito/core/async_transport.py @@ -35,6 +35,7 @@ def __init__( client: httpx.AsyncClient | None = None, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, ) -> None: + """Initialize AsyncTransport.""" self._settings = settings self._auth_provider = auth_provider self._retry_policy = settings.retry_policy @@ -47,9 +48,11 @@ def __init__( self._user_agent = shared.build_user_agent(settings.user_agent_suffix) async def __aenter__(self) -> AsyncTransport: + """Enter the async context manager.""" return self async def __aexit__(self, *exc: object) -> None: + """Exit the async context manager.""" await self.aclose() @property @@ -305,6 +308,7 @@ def _log_retry( status: int | None, decision: RetryDecision, ) -> None: + """Log retry.""" _LOGGER.info( "transport retry", extra={ @@ -319,6 +323,7 @@ def _log_retry( ) def _log_http_exchange(self, **extra: object) -> None: + """Log http exchange.""" _LOGGER.debug( "transport http exchange", extra={**extra, "endpoint": shared.safe_endpoint(str(extra["endpoint"]))}, diff --git a/avito/core/deprecation.py b/avito/core/deprecation.py index 95046cd..4402be8 100644 --- a/avito/core/deprecation.py +++ b/avito/core/deprecation.py @@ -30,6 +30,7 @@ def warn_deprecated_once( removal_version: str, deprecated_since: str, ) -> None: + """Run the warn deprecated once helper.""" if symbol in _WARNED_SYMBOLS: return _WARNED_SYMBOLS.add(symbol) @@ -51,6 +52,7 @@ def deprecated_method( removal_version: str, deprecated_since: str, ) -> Callable[[Callable[P, R]], Callable[P, R]]: + """Run the deprecated method helper.""" metadata = DeprecatedSdkSymbol( symbol=symbol, replacement=replacement, @@ -59,9 +61,11 @@ def deprecated_method( ) def decorate(method: Callable[P, R]) -> Callable[P, R]: + """Run the decorate helper.""" if iscoroutinefunction(method): @wraps(method) async def async_wrapped(*args: P.args, **kwargs: P.kwargs) -> R: + """Run the async wrapped helper.""" warn_deprecated_once( symbol=symbol, replacement=replacement, @@ -76,6 +80,7 @@ async def async_wrapped(*args: P.args, **kwargs: P.kwargs) -> R: @wraps(method) def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: + """Run the wrapped helper.""" warn_deprecated_once( symbol=symbol, replacement=replacement, diff --git a/avito/core/domain.py b/avito/core/domain.py index 4529298..9c9c5d6 100644 --- a/avito/core/domain.py +++ b/avito/core/domain.py @@ -134,6 +134,7 @@ async def _resolve_user_id(self, user_id: int | str | None = None) -> int: def _extract_user_id(payload: object) -> int | None: + """Extract user id.""" if not isinstance(payload, dict): return None for key in ("id", "user_id", "userId"): diff --git a/avito/core/operations.py b/avito/core/operations.py index ef22762..1ea1db1 100644 --- a/avito/core/operations.py +++ b/avito/core/operations.py @@ -156,6 +156,7 @@ class OperationExecutor: """Execute operation specs through the shared transport layer.""" def __init__(self, transport: OperationTransport) -> None: + """Initialize OperationExecutor.""" self._transport = transport def execute( @@ -240,6 +241,7 @@ class AsyncOperationExecutor: """Execute operation specs through the async transport layer.""" def __init__(self, transport: AsyncOperationTransport) -> None: + """Initialize AsyncOperationExecutor.""" self._transport = transport async def execute( @@ -330,6 +332,7 @@ def _serialize_query[SpecResponseT]( spec: OperationSpec[SpecResponseT], query: object | Mapping[str, object] | None, ) -> Mapping[str, object] | None: + """Serialize query.""" if query is None: return None if isinstance(query, RequestModel): @@ -345,6 +348,7 @@ def _serialize_request[SpecResponseT]( spec: OperationSpec[SpecResponseT], request: object | Mapping[str, object] | None, ) -> object | None: + """Serialize request.""" if request is None: return None if isinstance(request, RequestModel): @@ -360,6 +364,7 @@ def _merge_content_type( headers: Mapping[str, str] | None, content_type: str | None, ) -> Mapping[str, str] | None: + """Run the merge content type helper.""" if content_type is None: return headers merged = dict(headers or {}) @@ -377,6 +382,7 @@ def _request_binary[SpecResponseT]( headers: Mapping[str, str] | None, idempotency_key: str | None, ) -> BinaryResponse: + """Run the request binary helper.""" response = transport.request( spec.method, path, @@ -404,6 +410,7 @@ async def _request_binary_async[SpecResponseT]( headers: Mapping[str, str] | None, idempotency_key: str | None, ) -> BinaryResponse: + """Run the request binary async helper.""" response = await transport.request( spec.method, path, @@ -422,6 +429,7 @@ async def _request_binary_async[SpecResponseT]( def _extract_filename(content_disposition: str | None) -> str | None: + """Extract filename.""" if content_disposition is None: return None message = Message() diff --git a/avito/core/rate_limit.py b/avito/core/rate_limit.py index aeced25..93705cb 100644 --- a/avito/core/rate_limit.py +++ b/avito/core/rate_limit.py @@ -20,6 +20,7 @@ def __init__( clock: Callable[[], float] = time.monotonic, sleep: Callable[[float], None] = time.sleep, ) -> None: + """Initialize RateLimiter.""" self._state = RateLimitState.from_policy(policy, now=clock()) self._clock = clock self._sleep = sleep @@ -43,6 +44,7 @@ def observe_response(self, *, headers: Mapping[str, str]) -> None: self._state.observe_response(now=self._clock(), headers=headers) def _reserve_or_delay(self) -> float: + """Run the reserve or delay helper.""" with self._lock: return self._state.compute_delay(self._clock()) diff --git a/avito/core/swagger.py b/avito/core/swagger.py index 5aaa6af..25e5a94 100644 --- a/avito/core/swagger.py +++ b/avito/core/swagger.py @@ -18,12 +18,14 @@ def _freeze_mapping(value: Mapping[str, str] | None) -> Mapping[str, str]: + """Run the freeze mapping helper.""" if value is None: return _EMPTY_MAPPING return MappingProxyType(dict(value)) def _normalize_method(method: str) -> str: + """Normalize method.""" normalized = method.strip().upper() if not normalized: raise ConfigurationError("HTTP-метод Swagger binding не может быть пустым.") @@ -31,6 +33,7 @@ def _normalize_method(method: str) -> str: def _normalize_path(path: str) -> str: + """Normalize path.""" normalized = path.strip() if not normalized.startswith("/"): raise ConfigurationError("Swagger path должен начинаться с `/`.") @@ -58,6 +61,7 @@ class SwaggerOperationBinding: variant: Literal["sync", "async"] = "sync" def __post_init__(self) -> None: + """Run the post init helper.""" if self.variant not in {"sync", "async"}: raise ConfigurationError("Swagger binding variant должен быть `sync` или `async`.") object.__setattr__(self, "method", _normalize_method(self.method)) @@ -95,6 +99,7 @@ def swagger_operation( ) def decorate(func: Callable[P, R]) -> Callable[P, R]: + """Run the decorate helper.""" if hasattr(func, "__swagger_binding__") or hasattr(func, "__swagger_bindings__"): raise ConfigurationError("Несколько Swagger binding-ов на одном SDK method запрещены.") func.__swagger_binding__ = binding # type: ignore[attr-defined] diff --git a/avito/core/swagger_discovery.py b/avito/core/swagger_discovery.py index d9fa85c..0cf71b3 100644 --- a/avito/core/swagger_discovery.py +++ b/avito/core/swagger_discovery.py @@ -47,6 +47,7 @@ class DiscoveredSwaggerBinding: @property def sdk_method(self) -> str: + """Run the sdk method helper.""" return f"{self.module}.{self.class_name}.{self.method_name}" @@ -59,6 +60,7 @@ class SwaggerBindingDiscovery: @property def canonical_map(self) -> Mapping[str, DiscoveredSwaggerBinding]: + """Run the canonical map helper.""" mapped = { binding.operation_key: binding for binding in self.bindings @@ -70,6 +72,7 @@ def canonical_map(self) -> Mapping[str, DiscoveredSwaggerBinding]: def canonical_map_by_variant( self, ) -> Mapping[Literal["sync", "async"], Mapping[str, DiscoveredSwaggerBinding]]: + """Run the canonical map by variant helper.""" mapped: dict[Literal["sync", "async"], dict[str, DiscoveredSwaggerBinding]] = { "sync": {}, "async": {}, @@ -120,6 +123,7 @@ def discover_swagger_bindings( def _iter_domain_modules(package: ModuleType, package_name: str) -> tuple[ModuleType, ...]: + """Run the iter domain modules helper.""" package_paths = getattr(package, "__path__", None) if package_paths is None: return () @@ -140,6 +144,7 @@ def _discover_module_bindings( module: ModuleType, registry: SwaggerRegistry | None, ) -> tuple[tuple[DiscoveredSwaggerBinding, ...], tuple[str, ...]]: + """Run the discover module bindings helper.""" bindings: list[DiscoveredSwaggerBinding] = [] legacy_binding_methods: list[str] = [] for _, cls in inspect.getmembers(module, inspect.isclass): @@ -170,6 +175,7 @@ def _discover_module_bindings( def _is_discoverable_binding_class(cls: type[object]) -> bool: + """Return whether discoverable binding class.""" if issubclass(cls, DomainObject) and cls is not DomainObject: return True if issubclass(cls, AsyncDomainObject) and cls is not AsyncDomainObject: @@ -178,6 +184,7 @@ def _is_discoverable_binding_class(cls: type[object]) -> bool: def _method_binding(func: object) -> SwaggerOperationBinding | None: + """Run the method binding helper.""" raw_binding = getattr(func, "__swagger_binding__", None) if isinstance(raw_binding, SwaggerOperationBinding): return raw_binding @@ -192,6 +199,7 @@ def _build_effective_binding( binding: SwaggerOperationBinding, registry: SwaggerRegistry | None, ) -> DiscoveredSwaggerBinding: + """Build effective binding.""" method = normalize_swagger_method(binding.method) path = normalize_swagger_path(binding.path) spec = binding.spec or _optional_string(getattr(cls, "__swagger_spec__", None)) @@ -225,6 +233,7 @@ def _operation_by_key( operations: tuple[SwaggerOperation, ...], operation_key: str, ) -> SwaggerOperation | None: + """Run the operation by key helper.""" for operation in operations: if operation.key == operation_key: return operation @@ -235,6 +244,7 @@ def _filter_factory_args_for_operation( factory_args: Mapping[str, str], operation: SwaggerOperation | None, ) -> Mapping[str, str]: + """Run the filter factory args for operation helper.""" if operation is None or not factory_args: return factory_args parameter_names = { @@ -257,6 +267,7 @@ def _resolve_spec( method: str, path: str, ) -> str | None: + """Resolve spec.""" matches = [ operation.spec for operation in operations @@ -266,10 +277,12 @@ def _resolve_spec( def _optional_string(value: object) -> str | None: + """Run the optional string helper.""" return value if isinstance(value, str) and value else None def _optional_mapping(value: object) -> Mapping[str, str]: + """Run the optional mapping helper.""" if value is None: return _EMPTY_MAPPING if not isinstance(value, Mapping): diff --git a/avito/core/swagger_linter.py b/avito/core/swagger_linter.py index 71d9c3d..a31365e 100644 --- a/avito/core/swagger_linter.py +++ b/avito/core/swagger_linter.py @@ -106,6 +106,7 @@ def _validate_operation_spec_coverage( registry: SwaggerRegistry, bindings: Sequence[DiscoveredSwaggerBinding], ) -> tuple[SwaggerReportError, ...]: + """Validate operation spec coverage.""" operations_by_key = {operation.key: operation for operation in registry.operations} used_specs: set[int] = set() errors: list[SwaggerReportError] = [] @@ -144,6 +145,7 @@ def _validate_operation_spec_matches_binding( operation: SwaggerOperation, spec: OperationSpec[object], ) -> tuple[SwaggerReportError, ...]: + """Validate operation spec matches binding.""" errors: list[SwaggerReportError] = [] if normalize_swagger_method(spec.method) != operation.method: errors.append( @@ -176,6 +178,7 @@ def _validate_json_body_model_coverage( registry: SwaggerRegistry, bindings: Sequence[DiscoveredSwaggerBinding], ) -> tuple[SwaggerReportError, ...]: + """Validate json body model coverage.""" operations_by_key = {operation.key: operation for operation in registry.operations} errors: list[SwaggerReportError] = [] @@ -205,6 +208,7 @@ def _validate_operation_json_body_models( operation: SwaggerOperation, spec: OperationSpec[object], ) -> tuple[SwaggerReportError, ...]: + """Validate operation json body models.""" errors: list[SwaggerReportError] = [] request_body = operation.request_body if request_body is not None and _has_json_content(request_body.content_types): @@ -275,6 +279,7 @@ def _validate_operation_json_body_models( def _has_json_content(content_types: Sequence[str]) -> bool: + """Return whether json content.""" return any("application/json" in content_type for content_type in content_types) @@ -284,6 +289,7 @@ def _contract_error( code: str, message: str, ) -> SwaggerReportError: + """Run the contract error helper.""" return SwaggerReportError( code=code, message=message, @@ -293,6 +299,7 @@ def _contract_error( def _validate_no_unbound_operation_specs(used_specs: set[int]) -> tuple[SwaggerReportError, ...]: + """Validate no unbound operation specs.""" errors: list[SwaggerReportError] = [] for module_name, spec_name, spec in _iter_api_domain_operation_specs(): if id(spec) in used_specs: @@ -314,6 +321,7 @@ def _validate_no_unbound_operation_specs(used_specs: set[int]) -> tuple[SwaggerR def _validate_legacy_stacked_binding_metadata( discovery: SwaggerBindingDiscovery, ) -> tuple[SwaggerReportError, ...]: + """Validate legacy stacked binding metadata.""" return tuple( SwaggerReportError( code="SWAGGER_BINDING_METHOD_MULTIPLE", @@ -328,6 +336,7 @@ def _validate_legacy_stacked_binding_metadata( def _validate_single_binding_per_sdk_method( bindings: Sequence[DiscoveredSwaggerBinding], ) -> tuple[SwaggerReportError, ...]: + """Validate single binding per sdk method.""" grouped: defaultdict[str, list[DiscoveredSwaggerBinding]] = defaultdict(list) for binding in bindings: grouped[binding.sdk_method].append(binding) @@ -358,6 +367,7 @@ def _validate_complete_bindings( operations: Sequence[SwaggerOperation], bindings: Sequence[DiscoveredSwaggerBinding], ) -> tuple[SwaggerReportError, ...]: + """Validate complete bindings.""" bound_operation_keys = { binding.operation_key for binding in bindings @@ -381,6 +391,7 @@ def _validate_complete_bindings( def _validate_duplicate_bindings( bindings: Sequence[DiscoveredSwaggerBinding], ) -> tuple[SwaggerReportError, ...]: + """Validate duplicate bindings.""" grouped: defaultdict[tuple[str, str], list[DiscoveredSwaggerBinding]] = defaultdict(list) for binding in bindings: if binding.operation_key is not None: @@ -413,6 +424,7 @@ def _resolve_bound_operation( spec_names: set[str], errors: list[SwaggerReportError], ) -> SwaggerOperation | None: + """Resolve bound operation.""" if binding.operation_key is None: errors.append( SwaggerReportError( @@ -458,6 +470,7 @@ def _validate_operation_metadata( operation: SwaggerOperation, sdk_method: Callable[..., object] | None, ) -> tuple[SwaggerReportError, ...]: + """Validate operation metadata.""" errors: list[SwaggerReportError] = [] if binding.operation_id is not None and binding.operation_id != operation.operation_id: errors.append( @@ -523,6 +536,7 @@ def _validate_operation_metadata( def _validate_factory(binding: DiscoveredSwaggerBinding) -> tuple[SwaggerReportError, ...]: + """Validate factory.""" if binding.domain == "auth" and binding.factory is None: return () if binding.factory is None: @@ -566,6 +580,7 @@ def _validate_sdk_method_signature( binding: DiscoveredSwaggerBinding, sdk_method: Callable[..., object] | None, ) -> tuple[SwaggerReportError, ...]: + """Validate sdk method signature.""" if sdk_method is None: return ( SwaggerReportError( @@ -587,6 +602,7 @@ def _validate_sdk_method_signature( def _operation_specs_for_sdk_method( sdk_method: Callable[..., object] | None, ) -> tuple[OperationSpec[object], ...]: + """Run the operation specs for sdk method helper.""" if sdk_method is None: return () unwrapped_method = inspect.unwrap(sdk_method) @@ -611,6 +627,7 @@ def _operation_specs_for_sdk_method( def _iter_api_domain_operation_specs() -> tuple[tuple[str, str, OperationSpec[object]], ...]: + """Run the iter api domain operation specs helper.""" specs: list[tuple[str, str, OperationSpec[object]]] = [] for domain in sorted(_API_DOMAINS): for module in _iter_domain_operation_modules(domain): @@ -621,6 +638,7 @@ def _iter_api_domain_operation_specs() -> tuple[tuple[str, str, OperationSpec[ob def _iter_domain_operation_modules(domain: str) -> tuple[ModuleType, ...]: + """Run the iter domain operation modules helper.""" root_module_name = f"avito.{domain}.operations" module = importlib.import_module(root_module_name) modules: list[ModuleType] = [module] @@ -633,17 +651,20 @@ def _iter_domain_operation_modules(domain: str) -> tuple[ModuleType, ...]: def _is_execute_call(node: ast.Call) -> bool: + """Return whether execute call.""" name = _call_name(node.func) return name in {"self._execute", "_execute"} or name.endswith("._execute") def _name(node: ast.AST) -> str | None: + """Run the name helper.""" if isinstance(node, ast.Name): return node.id return None def _call_name(node: ast.AST) -> str: + """Run the call name helper.""" if isinstance(node, ast.Name): return node.id if isinstance(node, ast.Attribute): @@ -652,6 +673,7 @@ def _call_name(node: ast.AST) -> str: def _attribute_name(node: ast.Attribute) -> str: + """Run the attribute name helper.""" parts = [node.attr] value = node.value while isinstance(value, ast.Attribute): @@ -666,6 +688,7 @@ def _validate_binding_expressions( binding: DiscoveredSwaggerBinding, operation: SwaggerOperation, ) -> tuple[SwaggerReportError, ...]: + """Validate binding expressions.""" errors: list[SwaggerReportError] = [] errors.extend( _validate_expression_mapping( @@ -693,6 +716,7 @@ def _validate_expression_mapping( mapping: Mapping[str, str], subject: str, ) -> tuple[SwaggerReportError, ...]: + """Validate expression mapping.""" errors: list[SwaggerReportError] = [] for argument_name, expression in sorted(mapping.items()): errors.extend( @@ -715,6 +739,7 @@ def _validate_expression( argument_name: str, expression: str, ) -> tuple[SwaggerReportError, ...]: + """Validate expression.""" if expression == "body": if operation.request_body is None: return ( @@ -857,6 +882,7 @@ def _validate_parameter_expression( field_name: str, location: str, ) -> tuple[SwaggerReportError, ...]: + """Validate parameter expression.""" parameter_names = { parameter.name for parameter in operation.parameters if parameter.location == location } @@ -881,6 +907,7 @@ def _expression_error( code: str, message: str, ) -> SwaggerReportError: + """Run the expression error helper.""" return SwaggerReportError( code=code, message=message, @@ -890,6 +917,7 @@ def _expression_error( def _load_sdk_method(binding: DiscoveredSwaggerBinding) -> Callable[..., object] | None: + """Load sdk method.""" module = importlib.import_module(binding.module) cls = getattr(module, binding.class_name, None) method = getattr(cls, binding.method_name, None) @@ -897,6 +925,7 @@ def _load_sdk_method(binding: DiscoveredSwaggerBinding) -> Callable[..., object] def _has_runtime_deprecation(method: Callable[..., object] | None) -> bool: + """Return whether runtime deprecation.""" metadata = getattr(method, "__sdk_deprecation__", None) return isinstance(metadata, DeprecatedSdkSymbol) @@ -909,6 +938,7 @@ def _validate_signature_mapping( subject: str, code_prefix: str, ) -> tuple[SwaggerReportError, ...]: + """Validate signature mapping.""" parameters = _mappable_parameters(signature) parameter_names = set(parameters) errors: list[SwaggerReportError] = [] @@ -946,6 +976,7 @@ def _validate_signature_mapping( def _mappable_parameters( signature: inspect.Signature, ) -> Mapping[str, inspect.Parameter]: + """Run the mappable parameters helper.""" return { name: parameter for name, parameter in signature.parameters.items() diff --git a/avito/core/swagger_report.py b/avito/core/swagger_report.py index ca777d5..4751384 100644 --- a/avito/core/swagger_report.py +++ b/avito/core/swagger_report.py @@ -100,6 +100,7 @@ def build_swagger_binding_report( def _group_bindings_by_operation_key( bindings: Sequence[DiscoveredSwaggerBinding], ) -> Mapping[str, tuple[DiscoveredSwaggerBinding, ...]]: + """Run the group bindings by operation key helper.""" grouped: defaultdict[str, list[DiscoveredSwaggerBinding]] = defaultdict(list) for binding in bindings: if binding.operation_key is None: @@ -116,6 +117,7 @@ def _build_operation_entry( bindings: tuple[DiscoveredSwaggerBinding, ...], async_bindings: tuple[DiscoveredSwaggerBinding, ...] = (), ) -> dict[str, object]: + """Build operation entry.""" if not bindings: status = "unbound" binding_entry: object = None @@ -142,6 +144,7 @@ def _build_operation_entry( def _build_binding_entry(binding: DiscoveredSwaggerBinding) -> dict[str, object]: + """Build binding entry.""" return { "module": binding.module, "class": binding.class_name, @@ -163,6 +166,7 @@ def _build_binding_entry(binding: DiscoveredSwaggerBinding) -> dict[str, object] def _binding_reference(binding: DiscoveredSwaggerBinding) -> dict[str, object]: + """Run the binding reference helper.""" return { "module": binding.module, "class": binding.class_name, @@ -172,6 +176,7 @@ def _binding_reference(binding: DiscoveredSwaggerBinding) -> dict[str, object]: def _variant_binding_entry(bindings: tuple[DiscoveredSwaggerBinding, ...]) -> object: + """Run the variant binding entry helper.""" if not bindings: return None if len(bindings) == 1: @@ -183,6 +188,7 @@ def _variant_summary( operations: tuple[SwaggerOperation, ...], bindings: Sequence[DiscoveredSwaggerBinding], ) -> dict[str, int]: + """Run the variant summary helper.""" groups = _group_bindings_by_operation_key(bindings) bound = sum(1 for operation in operations if len(groups.get(operation.key, ())) == 1) duplicate = sum(1 for operation_bindings in groups.values() if len(operation_bindings) > 1) @@ -197,6 +203,7 @@ def _variant_summary( def _build_registry_error_entry(error: SwaggerValidationError) -> dict[str, object]: + """Build registry error entry.""" return { "code": error.code, "message": error.message, @@ -206,6 +213,7 @@ def _build_registry_error_entry(error: SwaggerValidationError) -> dict[str, obje def _build_report_error_entry(error: SwaggerReportError) -> dict[str, object]: + """Build report error entry.""" return { "code": error.code, "message": error.message, diff --git a/avito/core/transport.py b/avito/core/transport.py index 5370094..cd9fdcf 100644 --- a/avito/core/transport.py +++ b/avito/core/transport.py @@ -76,6 +76,7 @@ def __init__( client: httpx.Client | None = None, sleep: Callable[[float], None] = time.sleep, ) -> None: + """Initialize Transport.""" self._settings = settings self._auth_provider = auth_provider self._retry_policy = settings.retry_policy @@ -365,6 +366,7 @@ def download_binary( ) def _normalize_path(self, path: str) -> str: + """Normalize path.""" stripped = path.strip() if not stripped: return "/" @@ -380,6 +382,7 @@ def _normalize_path(self, path: str) -> str: return normalized def _normalize_params(self, params: Mapping[str, object] | None) -> QueryParams | None: + """Normalize params.""" if params is None: return None normalized: dict[str, QueryParamValue] = {} @@ -393,16 +396,19 @@ def _normalize_params(self, params: Mapping[str, object] | None) -> QueryParams return normalized def _normalize_query_scalar(self, value: object) -> QueryScalar: + """Normalize query scalar.""" if isinstance(value, str | int | float | bool): return value return str(value) def _normalize_files(self, files: Mapping[str, object] | None) -> RequestFiles | None: + """Normalize files.""" if files is None: return None return {key: self._normalize_file_value(value) for key, value in files.items()} def _normalize_file_value(self, value: object) -> FileValue: + """Normalize file value.""" if isinstance(value, bytes | str | BytesIO): return value if isinstance(value, tuple): @@ -417,6 +423,7 @@ def _merge_headers( idempotency_key: str | None, bearer_token: str | None, ) -> dict[str, str]: + """Run the merge headers helper.""" return shared.merge_headers( context=context, headers=headers, @@ -426,6 +433,7 @@ def _merge_headers( ) def _build_user_agent(self) -> str: + """Build user agent.""" try: package_version = importlib_metadata.version("avito-py") except importlib_metadata.PackageNotFoundError: @@ -448,6 +456,7 @@ def _decide_transport_retry( is_timeout: bool, idempotency_key: str | None, ) -> RetryDecision: + """Decide transport retry.""" if attempt >= self._retry_policy.max_attempts: return RetryDecision(False) if not self._retry_policy.retry_on_transport_error: @@ -473,6 +482,7 @@ def _decide_http_retry( response: httpx.Response, idempotency_key: str | None, ) -> RetryDecision: + """Decide http retry.""" if attempt >= self._retry_policy.max_attempts: return RetryDecision(False) if not self._is_retryable_request( @@ -505,6 +515,7 @@ def _is_retryable_request( context: RequestContext, idempotency_key: str | None, ) -> bool: + """Return whether retryable request.""" if context.retry_disabled: return False normalized_method = method.upper() @@ -528,6 +539,7 @@ def _map_http_error( operation: str | None = None, attempt: int | None = None, ) -> Exception: + """Map http error.""" payload = self._safe_payload(response) message = self._extract_message(payload) or f"HTTP {response.status_code}" error_code = self._extract_error_code(payload) @@ -657,6 +669,7 @@ def _map_http_error( ) def _safe_payload(self, response: httpx.Response) -> object: + """Return a safe payload.""" content_type = response.headers.get("content-type", "") if "application/json" in content_type: try: @@ -666,6 +679,7 @@ def _safe_payload(self, response: httpx.Response) -> object: return response.text def _extract_message(self, payload: object) -> str | None: + """Extract message.""" if isinstance(payload, dict): for key in ("message", "error_description", "error", "detail"): value = payload.get(key) @@ -676,12 +690,14 @@ def _extract_message(self, payload: object) -> str | None: return None def _extract_error_code(self, payload: object) -> str | None: + """Extract error code.""" if not isinstance(payload, dict): return None value = payload.get("code") or payload.get("error") return value if isinstance(value, str) else None def _extract_error_details(self, payload: object) -> object | None: + """Extract error details.""" if not isinstance(payload, Mapping): return None for key in ("details", "fields", "errors", "violations"): @@ -691,6 +707,7 @@ def _extract_error_details(self, payload: object) -> object | None: return None def _extract_request_id(self, headers: Mapping[str, str]) -> str | None: + """Extract request id.""" for key in ("x-request-id", "x-correlation-id", "x-amzn-requestid"): value = headers.get(key) if value: @@ -698,6 +715,7 @@ def _extract_request_id(self, headers: Mapping[str, str]) -> str | None: return None def _get_retry_after_seconds(self, headers: Mapping[str, str]) -> float: + """Return retry after seconds.""" raw_value = headers.get("retry-after") if raw_value is None: return _MIN_RETRY_AFTER_SECONDS @@ -722,6 +740,7 @@ def _log_retry( status: int | None, decision: RetryDecision, ) -> None: + """Log retry.""" _LOGGER.info( "transport retry", extra={ @@ -746,6 +765,7 @@ def _log_http_exchange( latency_ms: int, request_id: str | None, ) -> None: + """Log http exchange.""" _LOGGER.debug( "transport http exchange", extra={ @@ -760,15 +780,18 @@ def _log_http_exchange( ) def _elapsed_ms(self, started_at: float) -> int: + """Return elapsed ms.""" return max(int((time.perf_counter() - started_at) * 1000), 0) def _safe_endpoint(self, endpoint: str) -> str: + """Return a safe endpoint.""" parsed = urlsplit(endpoint) if parsed.scheme or parsed.netloc: return parsed.path or "/" return endpoint def _extract_filename(self, content_disposition: str | None) -> str | None: + """Extract filename.""" if content_disposition is None: return None message = Message() diff --git a/avito/cpa/async_domain.py b/avito/cpa/async_domain.py index 1b115e4..1652b5a 100644 --- a/avito/cpa/async_domain.py +++ b/avito/cpa/async_domain.py @@ -365,6 +365,7 @@ async def get_phones_info_from_chats( ) def _require_action_id(self) -> str: + """Validate required action id.""" if self.action_id is None: raise ValidationError("Для операции требуется `action_id`.") return str(self.action_id) @@ -634,6 +635,7 @@ async def get_call_by_id( ) def _require_call_id(self) -> str: + """Validate required call id.""" if self.call_id is None: raise ValidationError("Для операции требуется `call_id`.") return str(self.call_id) @@ -784,6 +786,7 @@ async def download( ) def _require_call_id(self) -> str: + """Validate required call id.""" if self.call_id is None: raise ValidationError("Для операции требуется `call_id`.") return str(self.call_id) diff --git a/avito/jobs/async_domain.py b/avito/jobs/async_domain.py index e342312..19c0ce0 100644 --- a/avito/jobs/async_domain.py +++ b/avito/jobs/async_domain.py @@ -632,6 +632,7 @@ async def update_auto_renewal( ) def _require_vacancy_id(self) -> str: + """Validate required vacancy id.""" if self.vacancy_id is None: raise ValidationError("Для операции требуется идентификатор вакансии.") return str(self.vacancy_id) @@ -975,6 +976,7 @@ async def get_contacts( ) def _require_resume_id(self) -> str: + """Validate required resume id.""" if self.resume_id is None: raise ValidationError("Для операции требуется `resume_id`.") return str(self.resume_id) @@ -1211,6 +1213,7 @@ async def get( ) def _require_dictionary_id(self) -> str: + """Validate required dictionary id.""" if self.dictionary_id is None: raise ValidationError("Для операции требуется `dictionary_id`.") return str(self.dictionary_id) diff --git a/avito/messenger/async_domain.py b/avito/messenger/async_domain.py index 64d41cc..30bd99d 100644 --- a/avito/messenger/async_domain.py +++ b/avito/messenger/async_domain.py @@ -224,11 +224,13 @@ async def blacklist( ) def _require_user_id(self) -> int: + """Validate required user id.""" if self.user_id is None: raise ValidationError("Для операции требуется `user_id`.") return int(self.user_id) def _require_chat_id(self) -> str: + """Validate required chat id.""" if self.chat_id is None: raise ValidationError("Для операции требуется `chat_id`.") return str(self.chat_id) @@ -441,16 +443,19 @@ async def delete( ) def _require_user_id(self) -> int: + """Validate required user id.""" if self.user_id is None: raise ValidationError("Для операции требуется `user_id`.") return int(self.user_id) def _require_chat_id(self) -> str: + """Validate required chat id.""" if self.chat_id is None: raise ValidationError("Для операции требуется `chat_id`.") return str(self.chat_id) def _require_message_id(self) -> str: + """Validate required message id.""" if self.message_id is None: raise ValidationError("Для операции требуется `message_id`.") return str(self.message_id) @@ -677,6 +682,7 @@ async def upload_images( ) def _require_user_id(self) -> int: + """Validate required user id.""" if self.user_id is None: raise ValidationError("Для операции требуется `user_id`.") return int(self.user_id) @@ -901,6 +907,7 @@ async def get_tariff_info( return await self._execute(GET_SPECIAL_OFFER_TARIFF_INFO, timeout=timeout, retry=retry) def _require_campaign_id(self) -> str: + """Validate required campaign id.""" if self.campaign_id is None: raise ValidationError("Для операции требуется `campaign_id`.") return str(self.campaign_id) diff --git a/avito/orders/async_domain.py b/avito/orders/async_domain.py index 023800d..9eeb261 100644 --- a/avito/orders/async_domain.py +++ b/avito/orders/async_domain.py @@ -670,6 +670,7 @@ async def download( return LabelPdfResult(binary=binary) def _require_task_id(self) -> str: + """Validate required task id.""" if self.task_id is None: raise ValidationError("Для операции требуется `task_id`.") return str(self.task_id) @@ -2165,6 +2166,7 @@ async def get( ) def _require_task_id(self) -> str: + """Validate required task id.""" if self.task_id is None: raise ValidationError("Для операции требуется `task_id`.") return str(self.task_id) diff --git a/avito/promotion/async_domain.py b/avito/promotion/async_domain.py index aed7229..735c45a 100644 --- a/avito/promotion/async_domain.py +++ b/avito/promotion/async_domain.py @@ -99,6 +99,7 @@ def _preview_result( target: Mapping[str, object], request_payload: Mapping[str, object], ) -> PromotionActionResult: + """Build result.""" return PromotionActionResult( action=action, target=dict(target), @@ -110,6 +111,7 @@ def _preview_result( def _validate_optional_datetime(name: str, value: datetime | None) -> None: + """Validate optional datetime.""" if value is not None and not isinstance(value, datetime): raise ValidationError(f"`{name}` должен быть datetime.") @@ -432,6 +434,7 @@ async def get_suggests( ) def _resource_item_ids(self) -> list[int]: + """Run the resource item ids helper.""" if self.item_id is None: raise ValidationError("Для операции требуется `item_id` или список `item_ids`.") return [int(self.item_id)] @@ -613,6 +616,7 @@ async def get_commissions( ) def _resource_item_ids(self) -> list[int]: + """Run the resource item ids helper.""" if self.item_id is None: raise ValidationError("Для операции требуется `item_id` или список `item_ids`.") return [int(self.item_id)] @@ -1033,6 +1037,7 @@ async def update_manual( ) def _require_item_id(self) -> int: + """Validate required item id.""" if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") return int(self.item_id) @@ -1446,6 +1451,7 @@ async def get_stat( ) def _require_campaign_id(self) -> int: + """Validate required campaign id.""" if self.campaign_id is None: raise ValidationError("Для операции требуется `campaign_id`.") return int(self.campaign_id) diff --git a/avito/ratings/async_domain.py b/avito/ratings/async_domain.py index c7b930b..09a6a4e 100644 --- a/avito/ratings/async_domain.py +++ b/avito/ratings/async_domain.py @@ -179,6 +179,7 @@ async def delete( ) def _require_answer_id(self) -> str: + """Validate required answer id.""" if self.answer_id is None: raise ValidationError("Для операции требуется `answer_id`.") return str(self.answer_id) diff --git a/avito/realty/async_domain.py b/avito/realty/async_domain.py index bd33c4b..0230515 100644 --- a/avito/realty/async_domain.py +++ b/avito/realty/async_domain.py @@ -138,6 +138,7 @@ async def update_base_params( ) def _require_item_id(self) -> str: + """Validate required item id.""" if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") return str(self.item_id) @@ -266,11 +267,13 @@ async def list_realty_bookings( ) def _require_item_id(self) -> str: + """Validate required item id.""" if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") return str(self.item_id) def _require_user_id(self) -> str: + """Validate required user id.""" if self.user_id is None: raise ValidationError("Для операции требуется `user_id`.") return str(self.user_id) @@ -339,11 +342,13 @@ async def update_realty_prices( ) def _require_item_id(self) -> str: + """Validate required item id.""" if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") return str(self.item_id) def _require_user_id(self) -> str: + """Validate required user id.""" if self.user_id is None: raise ValidationError("Для операции требуется `user_id`.") return str(self.user_id) @@ -447,6 +452,7 @@ async def get_report_for_classified( ) def _require_item_id(self) -> str: + """Validate required item id.""" if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") return str(self.item_id) diff --git a/avito/testing/async_fake_transport.py b/avito/testing/async_fake_transport.py index 4563fda..12f9f99 100644 --- a/avito/testing/async_fake_transport.py +++ b/avito/testing/async_fake_transport.py @@ -30,6 +30,7 @@ def __init__( base_url: str = "https://api.avito.ru", fanout_recorder: FanoutPeakRecorder | None = None, ) -> None: + """Initialize AsyncFakeTransport.""" self.base_url = base_url.rstrip("/") self.requests: list[RecordedRequest] = [] self._routes: dict[tuple[str, str], deque[RouteResponder]] = {} @@ -141,6 +142,7 @@ def _build_parts( authenticated: bool, auth_settings: AuthSettings | None, ) -> tuple[AvitoSettings, AsyncAuthProvider | None, httpx.AsyncClient]: + """Build parts.""" resolved_auth = auth_settings or AuthSettings( client_id="fake-client-id", client_secret="fake-client-secret", @@ -180,6 +182,7 @@ def _build_parts( return settings, auth_provider, http_client async def _handle(self, request: httpx.Request) -> httpx.Response: + """Handle handle.""" if self._fanout_recorder is not None: await self._fanout_recorder.enter() try: @@ -189,6 +192,7 @@ async def _handle(self, request: httpx.Request) -> httpx.Response: await self._fanout_recorder.exit() async def _handle_recorded(self, request: httpx.Request) -> httpx.Response: + """Handle recorded.""" async with self._handle_lock: recorded = RecordedRequest( method=request.method.upper(), @@ -215,6 +219,7 @@ async def _handle_recorded(self, request: httpx.Request) -> httpx.Response: @staticmethod def _decode_json(request: httpx.Request) -> JsonValue: + """Decode json.""" if not request.content: return None try: @@ -227,16 +232,19 @@ class FanoutPeakRecorder: """Считает пик одновременно выполняющихся async fake-запросов.""" def __init__(self) -> None: + """Initialize FanoutPeakRecorder.""" self._lock = asyncio.Lock() self._active = 0 self.peak = 0 async def enter(self) -> None: + """Record fan-out enter event.""" async with self._lock: self._active += 1 self.peak = max(self.peak, self._active) async def exit(self) -> None: + """Record fan-out exit event.""" async with self._lock: self._active -= 1 diff --git a/avito/testing/async_swagger_fake_transport.py b/avito/testing/async_swagger_fake_transport.py index d09a43d..51e94c4 100644 --- a/avito/testing/async_swagger_fake_transport.py +++ b/avito/testing/async_swagger_fake_transport.py @@ -25,6 +25,7 @@ def __init__( registry: SwaggerRegistry, base_url: str = "https://api.avito.ru", ) -> None: + """Initialize AsyncSwaggerFakeTransport.""" super().__init__(base_url=base_url) self.registry = registry self._sync_helper = SwaggerFakeTransport(registry=registry, base_url=base_url) @@ -91,6 +92,7 @@ async def invoke_binding( return await method(**self._build_arguments(binding.method_args, method)) async def _handle_recorded(self, request: httpx.Request) -> httpx.Response: + """Handle recorded.""" async with self._handle_lock: recorded = RecordedRequest( method=request.method.upper(), @@ -117,12 +119,14 @@ def _build_target( client: AsyncAvitoClient, binding: DiscoveredSwaggerBinding, ) -> object: + """Build target.""" if binding.factory is None: raise AssertionError(f"Binding не содержит AsyncAvitoClient factory: {binding.sdk_method}") factory = getattr(client, binding.factory) return factory(**self._build_arguments(binding.factory_args, factory)) def _build_auth_target(self, binding: DiscoveredSwaggerBinding) -> object: + """Build auth target.""" settings = AuthSettings( client_id="fake-client-id", client_secret="fake-client-secret", @@ -150,12 +154,15 @@ def _build_arguments( mapping: Mapping[str, str], callable_object: Callable[..., object], ) -> dict[str, object]: + """Build arguments.""" return self._sync_helper._build_arguments(mapping, callable_object) def _match_route(self, request: RecordedRequest) -> SwaggerRoute: + """Match route.""" return self._sync_helper._match_route(request) def _validate_request(self, operation: SwaggerOperation, request: RecordedRequest) -> None: + """Validate request.""" self._sync_helper._validate_request(operation, request) From 6dd246f342783ee7cbc14b906d65c784b69e4a63 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Fri, 8 May 2026 22:20:11 +0300 Subject: [PATCH 24/26] Fix alert --- poetry.lock | 27 ++++++++++++++------------- pyproject.toml | 4 ++-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/poetry.lock b/poetry.lock index 11647bc..e5b7b67 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1511,20 +1511,20 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.3" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, + {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, + {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1" -packaging = ">=20" +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" @@ -1533,21 +1533,22 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests [[package]] name = "pytest-asyncio" -version = "0.24.0" +version = "1.3.0" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, - {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, + {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, + {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, ] [package.dependencies] -pytest = ">=8.2,<9" +pytest = ">=8.2,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] @@ -1946,4 +1947,4 @@ bracex = ">=2.1.1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "10ea6c90d6302eea53b556e2c4e67d4990bda7fcf9a19b0bdd1df35ac62d4deb" +content-hash = "2700535ead0e54ad8fd11c01b4c2b13c6a2afdcf9936f662984fdfaae89d9ad3" diff --git a/pyproject.toml b/pyproject.toml index f4ed994..742159c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,14 +29,14 @@ python = ">=3.12,<4.0" httpx = "^0.28.1" [tool.poetry.group.dev.dependencies] -pytest = "^8.3.5" +pytest = ">=9.0.3,<10.0.0" coverage = "^7.10.6" mypy = "^1.18.2" ruff = "^0.12.12" respx = "^0.22.0" libcst = "^1.8.6" bowler = "^0.9.0" -pytest-asyncio = "^0.24" +pytest-asyncio = ">=1.3,<2.0" [tool.poetry.group.docs.dependencies] mkdocs-material = "^9.5" From bddc1ab142ce4b01b677d805f0e1798d4afff823 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Sat, 9 May 2026 01:07:09 +0300 Subject: [PATCH 25/26] Fix docs --- .../usability_scorecard.md | 0 .github/workflows/docs.yml | 2 +- Makefile | 2 +- README.md | 82 +++++++++++++++---- docs/site/assets/_gen_reference.py | 2 +- docs/site/explanations/.pages | 1 + docs/site/explanations/index.md | 1 + docs/site/how-to/async.md | 2 +- docs/site/how-to/index.md | 2 +- docs/site/index.md | 14 +++- docs/site/reference/index.md | 3 +- mkdocs.yml | 4 +- pyproject.toml | 4 +- 13 files changed, 92 insertions(+), 27 deletions(-) rename usability_scorecard.md => .ai/usability_scorecard.md (100%) diff --git a/usability_scorecard.md b/.ai/usability_scorecard.md similarity index 100% rename from usability_scorecard.md rename to .ai/usability_scorecard.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0f7eb33..695da25 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -57,7 +57,7 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} with: - args: --root-dir "${{ github.workspace }}/site" --exclude "avito\.ru" --exclude "^https://p141592\.github\.io/avito_python_api/" --retry-wait-time 5 --max-retries 3 --timeout 30 site/ + args: --root-dir "${{ github.workspace }}/site" --exclude "avito\.ru" --exclude "^https://18studio\.github\.io/avito_python_api/" --retry-wait-time 5 --max-retries 3 --timeout 30 site/ deploy: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 0c65698..289f33b 100644 --- a/Makefile +++ b/Makefile @@ -77,7 +77,7 @@ docs-report: docs-check: docs-strict ln -sfn . site/avito_python_api - lychee --root-dir "$(PWD)/site" --exclude "avito\.ru" --exclude "^https://p141592\.github\.io/avito_python_api/" --retry-wait-time 5 --max-retries 3 --timeout 30 site/ + lychee --root-dir "$(PWD)/site" --exclude "avito\.ru" --exclude "^https://18studio\.github\.io/avito_python_api/" --retry-wait-time 5 --max-retries 3 --timeout 30 site/ qa-docs: poetry run pydocstyle \ diff --git a/README.md b/README.md index afc2ae5..40c8171 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SDK для Avito -[![CI](https://github.com/p141592/avito_python_api/actions/workflows/ci.yml/badge.svg)](https://github.com/p141592/avito_python_api/actions/workflows/ci.yml) -[![Coverage Status](https://coveralls.io/repos/github/p141592/avito_python_api/badge.svg?branch=main)](https://coveralls.io/github/p141592/avito_python_api?branch=main) +[![CI](https://github.com/18studio/avito_python_api/actions/workflows/ci.yml/badge.svg)](https://github.com/18studio/avito_python_api/actions/workflows/ci.yml) +[![Coverage Status](https://coveralls.io/repos/github/18studio/avito_python_api/badge.svg?branch=main)](https://coveralls.io/github/18studio/avito_python_api?branch=main) [![PyPI Downloads](https://img.shields.io/pypi/dm/avito-py.svg)](https://pypi.org/project/avito-py/) [![Docs](https://img.shields.io/badge/docs-latest-blue)](https://18studio.github.io/avito_python_api/) @@ -85,19 +85,6 @@ with AvitoClient(settings) as avito: Все опциональные параметры конструктора — keyword-only. `AvitoClient` иммутабелен: `base_url`, таймауты, retry-политика и `auth` не меняются у живого клиента — вместо этого создаётся новый клиент. -Async-поверхность использует те же доменные методы и модели, но требует `async with`: - -```python -from avito import AsyncAvitoClient - -async with AsyncAvitoClient.from_env() as avito: - profile = await avito.account().get_self() - listings = await (await avito.ad(user_id=123).list(limit=20)).materialize() -``` - -Подробный контракт async lifecycle, ASGI-рецепты и ограничения описаны в -[async how-to](https://p141592.github.io/avito_python_api/how-to/async/). - ### Переменные окружения | Переменная | Обязательная | Описание | @@ -121,6 +108,71 @@ async with AsyncAvitoClient.from_env() as avito: - `AvitoSettings.from_env()` и `AvitoClient.from_env()` детерминированно читают `.env` из текущей рабочей директории или из переданного `env_file`; - при отсутствии `AVITO_CLIENT_ID` или `AVITO_CLIENT_SECRET` SDK поднимает `ConfigurationError` при создании клиента, до первого HTTP-запроса. +## Асинхронный режим + +Для async-кода используйте `AsyncAvitoClient`. Он повторяет доменную поверхность +`AvitoClient`: фабрики (`account()`, `ad()`, `chat()`, `order()` и другие), +аргументы методов и возвращаемые SDK-модели остаются теми же, но сетевые вызовы +выполняются через `await`. + +`AsyncAvitoClient` обязательно открывается через `async with`: в этот момент SDK +создаёт loop-bound `httpx.AsyncClient`, async locks и transport. Для ручного +закрытия есть `await avito.aclose()`, но для application-кода предпочтителен +контекстный менеджер. + +```python +from avito import AsyncAvitoClient + + +async def load_active_ads() -> list[str]: + async with AsyncAvitoClient.from_env() as avito: + profile = await avito.account().get_self() + ads = await avito.ad(user_id=profile.id).list(status="active", limit=20) + items = await ads.materialize() + return [item.title for item in items] +``` + +Async-пагинация возвращает `AsyncPaginatedList[T]`, а не обычный `list`. +Читайте страницы через `async for` или явно материализуйте результат: + +```python +from avito import AsyncAvitoClient + + +async def print_ads() -> None: + async with AsyncAvitoClient.from_env() as avito: + ads = await avito.ad(user_id=123).list(status="active", limit=100) + + async for item in ads: + print(item.title) +``` + +Для ASGI-приложений создавайте один `AsyncAvitoClient` в lifespan приложения и +закрывайте его на shutdown. Один экземпляр клиента нельзя переносить между event +loop. + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from avito import AsyncAvitoClient + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + async with AsyncAvitoClient.from_env() as avito: + app.state.avito = avito + yield + + +app = FastAPI(lifespan=lifespan) +``` + +Подробнее: [Асинхронный режим](https://18studio.github.io/avito_python_api/how-to/async/) +и справочник [AvitoClient и AsyncAvitoClient](https://18studio.github.io/avito_python_api/reference/client/). + ## Примеры по доменам ### Аккаунт и объявления diff --git a/docs/site/assets/_gen_reference.py b/docs/site/assets/_gen_reference.py index c3f6ca7..5ebee82 100644 --- a/docs/site/assets/_gen_reference.py +++ b/docs/site/assets/_gen_reference.py @@ -16,7 +16,7 @@ EXCLUDED_PACKAGES = {"auth", "core", "testing"} PACKAGE_ROOT = Path("avito") -GITHUB_API_URL = "https://github.com/p141592/avito_python_api/blob/main/docs/avito/api" +GITHUB_API_URL = "https://github.com/18studio/avito_python_api/blob/main/docs/avito/api" def public_domain_packages() -> list[str]: diff --git a/docs/site/explanations/.pages b/docs/site/explanations/.pages index 7e3f9b8..d6df08d 100644 --- a/docs/site/explanations/.pages +++ b/docs/site/explanations/.pages @@ -7,6 +7,7 @@ nav: - pagination-semantics.md - dry-run-and-idempotency.md - testing-strategy.md + - async-domain-template.md - api-coverage-and-deprecations.md - swagger-binding-subsystem.md - config-resolution.md diff --git a/docs/site/explanations/index.md b/docs/site/explanations/index.md index 9b4c814..3ea8f97 100644 --- a/docs/site/explanations/index.md +++ b/docs/site/explanations/index.md @@ -12,6 +12,7 @@ Explanations описывают причины архитектурных реш | [Семантика пагинации](pagination-semantics.md) | Почему `PaginatedList` ленивый и когда загружаются страницы | | [Dry-run и идемпотентность](dry-run-and-idempotency.md) | Как write-операции проверяются без сетевого вызова | | [Стратегия тестирования](testing-strategy.md) | Как `FakeTransport`, contract-тесты и docs-harness проверяют SDK | +| [Шаблон async-домена](async-domain-template.md) | Как портировать доменный объект в `async_domain.py` без нарушения Swagger bindings | | [Покрытие API и deprecation](api-coverage-and-deprecations.md) | Как specs, reference и runtime warnings связаны между собой | | [Swagger binding subsystem](swagger-binding-subsystem.md) | Как Swagger specs, bindings, strict lint, JSON report и contract runner сохраняют coverage-контекст | | [Resolution конфигурации](config-resolution.md) | Как env, `.env` и defaults превращаются в `AvitoSettings` | diff --git a/docs/site/how-to/async.md b/docs/site/how-to/async.md index c2c1a01..9241c6e 100644 --- a/docs/site/how-to/async.md +++ b/docs/site/how-to/async.md @@ -1,4 +1,4 @@ -# Async API +# Асинхронный режим `AsyncAvitoClient` повторяет доменную поверхность `AvitoClient`, но все сетевые методы вызываются через `await`. Клиент обязательно открывается через `async with`: diff --git a/docs/site/how-to/index.md b/docs/site/how-to/index.md index 7c1a595..5fbe0ab 100644 --- a/docs/site/how-to/index.md +++ b/docs/site/how-to/index.md @@ -5,7 +5,7 @@ How-to раздел собирает рецепты для конкретных | Рецепт | Задача | |---|---| | [Авторизация и конфигурация](auth-and-config.md) | Создать клиент через env, явные ключи или `AvitoSettings` | -| [Async API](async.md) | Использовать `AsyncAvitoClient`, ASGI lifespan и async fake transport | +| [Асинхронный режим](async.md) | Использовать `AsyncAvitoClient`, ASGI lifespan и async fake transport | | [Профиль, баланс и иерархия аккаунта](account-profile.md) | Получить профиль, баланс, историю операций и данные сотрудников | | [Объявления, статистика и продвижение](ad-listing-and-stats.md) | Найти объявления, открыть карточку, прочитать статистику и подготовить VAS | | [Продвижение с dry-run](promotion-dry-run.md) | Проверить payload write-операции без сетевого вызова | diff --git a/docs/site/index.md b/docs/site/index.md index c9bfcb9..5dc1b75 100644 --- a/docs/site/index.md +++ b/docs/site/index.md @@ -16,8 +16,8 @@ pip install avito-py
-[![CI](https://github.com/p141592/avito_python_api/actions/workflows/ci.yml/badge.svg)](https://github.com/p141592/avito_python_api/actions/workflows/ci.yml) -[![Coverage Status](https://coveralls.io/repos/github/p141592/avito_python_api/badge.svg?branch=main)](https://coveralls.io/github/p141592/avito_python_api?branch=main) +[![CI](https://github.com/18studio/avito_python_api/actions/workflows/ci.yml/badge.svg)](https://github.com/18studio/avito_python_api/actions/workflows/ci.yml) +[![Coverage Status](https://coveralls.io/repos/github/18studio/avito_python_api/badge.svg?branch=main)](https://coveralls.io/github/18studio/avito_python_api?branch=main) [![PyPI Downloads](https://img.shields.io/pypi/dm/avito-py.svg)](https://pypi.org/project/avito-py/) [![API coverage](https://img.shields.io/badge/API%20coverage-204%2F204-success)](reference/coverage.md) @@ -43,6 +43,14 @@ pip install avito-py [:octicons-arrow-right-24: How-to рецепты](how-to/index.md) +- :material-sync:{ .lg .middle } **Асинхронный режим** + + --- + + `AsyncAvitoClient`, `async with`, ASGI lifespan, async-пагинация и тестирование без HTTP. + + [:octicons-arrow-right-24: Async how-to](how-to/async.md) + - :material-code-tags:{ .lg .middle } **Нужен точный контракт** --- @@ -70,3 +78,5 @@ pip install avito-py | **Режим** | Tutorials | How-to | Reference | Explanations | | **Цель** | Обучение через действие | Решить конкретную задачу | Точная информация | Понять «почему» | | **Раздел** | [Tutorials](tutorials/index.md) | [How-to](how-to/index.md) | [Reference](reference/index.md) | [Explanations](explanations/index.md) | + +Для async-кода начните с рецепта [Асинхронный режим](how-to/async.md), а точный контракт смотрите в [AvitoClient и AsyncAvitoClient](reference/client.md). diff --git a/docs/site/reference/index.md b/docs/site/reference/index.md index a860974..a03dfb8 100644 --- a/docs/site/reference/index.md +++ b/docs/site/reference/index.md @@ -5,7 +5,8 @@ | Страница | Что искать | |---|---| -| [AvitoClient](client.md) | Инициализация, контекстный менеджер, фабричные методы, `debug_info()` | +| [AvitoClient и AsyncAvitoClient](client.md) | Sync/async инициализация, контекстные менеджеры, фабричные методы, `debug_info()` | +| [Асинхронный режим](../how-to/async.md) | Практический lifecycle `AsyncAvitoClient`, ASGI и async fake transport | | [Конфигурация](config.md) | `AvitoSettings`, `AuthSettings`, env-переменные, per-operation overrides | | [Покрытие API](coverage.md) | 204/204 Swagger operations из binding report | | [Методы API](operations.md) | Карта Swagger operation → публичный SDK-метод | diff --git a/mkdocs.yml b/mkdocs.yml index 9c9c82b..b7a575c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,8 +1,8 @@ site_name: avito-py site_description: Sync/async Python SDK для Avito API site_url: https://18studio.github.io/avito_python_api/ -repo_url: https://github.com/p141592/avito_python_api -repo_name: p141592/avito_python_api +repo_url: https://github.com/18studio/avito_python_api +repo_name: 18studio/avito_python_api edit_uri: edit/main/docs/site/ docs_dir: docs/site diff --git a/pyproject.toml b/pyproject.toml index 742159c..abe128a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,8 @@ packages=[ ] license="MIT" readme="README.md" -homepage="https://github.com/p141592/avito_python_api" -repository="https://github.com/p141592/avito_python_api" +homepage="https://github.com/18studio/avito_python_api" +repository="https://github.com/18studio/avito_python_api" documentation="https://18studio.github.io/avito_python_api/" keywords=["avito", "sdk", "python", "api"] classifiers=[ From 73a4a63d495bfa6ea946953b8a6acef1b064ba40 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Sat, 9 May 2026 01:25:18 +0300 Subject: [PATCH 26/26] Fix docs --- poetry.lock | 1950 --------------------------------------------------- 1 file changed, 1950 deletions(-) delete mode 100644 poetry.lock diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index e5b7b67..0000000 --- a/poetry.lock +++ /dev/null @@ -1,1950 +0,0 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. - -[[package]] -name = "anyio" -version = "4.13.0" -description = "High-level concurrency and networking framework on top of asyncio or Trio" -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708"}, - {file = "anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"}, -] - -[package.dependencies] -idna = ">=2.8" -typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} - -[package.extras] -trio = ["trio (>=0.32.0)"] - -[[package]] -name = "appdirs" -version = "1.4.4" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, -] - -[[package]] -name = "attrs" -version = "26.1.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.9" -groups = ["dev", "docs"] -files = [ - {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, - {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, -] - -[[package]] -name = "babel" -version = "2.18.0" -description = "Internationalization utilities" -optional = false -python-versions = ">=3.8" -groups = ["docs"] -files = [ - {file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"}, - {file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"}, -] - -[package.extras] -dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] - -[[package]] -name = "backrefs" -version = "6.2" -description = "A wrapper around re and regex that adds additional back references." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "backrefs-6.2-py310-none-any.whl", hash = "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8"}, - {file = "backrefs-6.2-py311-none-any.whl", hash = "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be"}, - {file = "backrefs-6.2-py312-none-any.whl", hash = "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90"}, - {file = "backrefs-6.2-py313-none-any.whl", hash = "sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b"}, - {file = "backrefs-6.2-py314-none-any.whl", hash = "sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7"}, - {file = "backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7"}, - {file = "backrefs-6.2.tar.gz", hash = "sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49"}, -] - -[package.extras] -extras = ["regex"] - -[[package]] -name = "bandit" -version = "1.9.4" -description = "Security oriented static analyser for python code." -optional = false -python-versions = ">=3.10" -groups = ["docs"] -files = [ - {file = "bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e"}, - {file = "bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} -PyYAML = ">=5.3.1" -rich = "*" -stevedore = ">=1.20.0" - -[package.extras] -baseline = ["GitPython (>=3.1.30)"] -sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] -test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] -toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] -yaml = ["PyYAML"] - -[[package]] -name = "bowler" -version = "0.9.0" -description = "Safe code refactoring for modern Python projects" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "bowler-0.9.0-py3-none-any.whl", hash = "sha256:0351839e9917765be694aa52c99ea784dc1286c9bdd6fd066b810097fc273e1b"}, - {file = "bowler-0.9.0.tar.gz", hash = "sha256:cdb85ce2e7bd545802a15d755d1daf2b6a125429355c50d2019a9f35d63e45db"}, -] - -[package.dependencies] -attrs = "*" -click = "*" -fissix = "*" -moreorless = ">=0.2.0" -volatile = "*" - -[[package]] -name = "bracex" -version = "2.6" -description = "Bash style brace expander." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952"}, - {file = "bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7"}, -] - -[[package]] -name = "certifi" -version = "2026.2.25" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev", "docs"] -files = [ - {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, - {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.7" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["docs"] -files = [ - {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, - {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, - {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, -] - -[[package]] -name = "click" -version = "8.3.3" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["dev", "docs"] -files = [ - {file = "click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"}, - {file = "click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev", "docs"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] -markers = {dev = "sys_platform == \"win32\" or platform_system == \"Windows\""} - -[[package]] -name = "coverage" -version = "7.13.5" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"}, - {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0"}, - {file = "coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58"}, - {file = "coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e"}, - {file = "coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d"}, - {file = "coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8"}, - {file = "coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf"}, - {file = "coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9"}, - {file = "coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028"}, - {file = "coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01"}, - {file = "coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c"}, - {file = "coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf"}, - {file = "coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810"}, - {file = "coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de"}, - {file = "coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1"}, - {file = "coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17"}, - {file = "coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85"}, - {file = "coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b"}, - {file = "coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664"}, - {file = "coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d"}, - {file = "coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2"}, - {file = "coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a"}, - {file = "coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819"}, - {file = "coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911"}, - {file = "coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f"}, - {file = "coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0"}, - {file = "coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc"}, - {file = "coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633"}, - {file = "coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8"}, - {file = "coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b"}, - {file = "coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a"}, - {file = "coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215"}, - {file = "coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43"}, - {file = "coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45"}, - {file = "coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61"}, - {file = "coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179"}, -] - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - -[[package]] -name = "fissix" -version = "24.4.24" -description = "Monkeypatches to override default behavior of lib2to3." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "fissix-24.4.24-py3-none-any.whl", hash = "sha256:be7f5c66e9e212bd9b3365c9e8f2453e973d0a645f31c8eba842724adb4c0c50"}, - {file = "fissix-24.4.24.tar.gz", hash = "sha256:7e8f1e448d1ebc1c8be68be8bf71123650710076ea9dcecb7801804b04f43547"}, -] - -[package.dependencies] -appdirs = ">=1.4.4" - -[package.extras] -dev = ["attribution (==1.7.1)", "black (==24.4.0)", "flit (==3.9.0)", "isort (==5.8.0)", "pytest (==8.1.1)"] -docs = ["sphinx (==7.3.7)", "sphinx-mdinclude (==0.6.0)"] - -[[package]] -name = "ghp-import" -version = "2.1.0" -description = "Copy your docs directly to the gh-pages branch." -optional = false -python-versions = "*" -groups = ["docs"] -files = [ - {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, - {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, -] - -[package.dependencies] -python-dateutil = ">=2.8.1" - -[package.extras] -dev = ["flake8", "markdown", "twine", "wheel"] - -[[package]] -name = "griffelib" -version = "2.0.2" -description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." -optional = false -python-versions = ">=3.10" -groups = ["docs"] -files = [ - {file = "griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1"}, - {file = "griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e"}, -] - -[package.extras] -pypi = ["pip (>=24.0)", "platformdirs (>=4.2)", "wheel (>=0.42)"] - -[[package]] -name = "h11" -version = "0.16.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, - {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, - {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.16" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<1.0)"] - -[[package]] -name = "httpx" -version = "0.28.1" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, - {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "==1.*" -idna = "*" - -[package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "idna" -version = "3.11" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev", "docs"] -files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - -[[package]] -name = "interrogate" -version = "1.7.0" -description = "Interrogate a codebase for docstring coverage." -optional = false -python-versions = ">=3.8" -groups = ["docs"] -files = [ - {file = "interrogate-1.7.0-py3-none-any.whl", hash = "sha256:b13ff4dd8403369670e2efe684066de9fcb868ad9d7f2b4095d8112142dc9d12"}, - {file = "interrogate-1.7.0.tar.gz", hash = "sha256:a320d6ec644dfd887cc58247a345054fc4d9f981100c45184470068f4b3719b0"}, -] - -[package.dependencies] -attrs = "*" -click = ">=7.1" -colorama = "*" -py = "*" -tabulate = "*" - -[package.extras] -dev = ["cairosvg", "coverage[toml]", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "sphinx", "sphinx-autobuild", "wheel"] -docs = ["sphinx", "sphinx-autobuild"] -png = ["cairosvg"] -tests = ["coverage[toml]", "pytest", "pytest-cov", "pytest-mock"] - -[[package]] -name = "jinja2" -version = "3.1.6" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -groups = ["docs"] -files = [ - {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, - {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "libcst" -version = "1.8.6" -description = "A concrete syntax tree with AST-like properties for Python 3.0 through 3.14 programs." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "libcst-1.8.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a20c5182af04332cc94d8520792befda06d73daf2865e6dddc5161c72ea92cb9"}, - {file = "libcst-1.8.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36473e47cb199b7e6531d653ee6ffed057de1d179301e6c67f651f3af0b499d6"}, - {file = "libcst-1.8.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:06fc56335a45d61b7c1b856bfab4587b84cfe31e9d6368f60bb3c9129d900f58"}, - {file = "libcst-1.8.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6b23d14a7fc0addd9795795763af26b185deb7c456b1e7cc4d5228e69dab5ce8"}, - {file = "libcst-1.8.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:16cfe0cfca5fd840e1fb2c30afb628b023d3085b30c3484a79b61eae9d6fe7ba"}, - {file = "libcst-1.8.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:455f49a93aea4070132c30ebb6c07c2dea0ba6c1fde5ffde59fc45dbb9cfbe4b"}, - {file = "libcst-1.8.6-cp310-cp310-win_amd64.whl", hash = "sha256:72cca15800ffc00ba25788e4626189fe0bc5fe2a0c1cb4294bce2e4df21cc073"}, - {file = "libcst-1.8.6-cp310-cp310-win_arm64.whl", hash = "sha256:6cad63e3a26556b020b634d25a8703b605c0e0b491426b3e6b9e12ed20f09100"}, - {file = "libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073"}, - {file = "libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6"}, - {file = "libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978"}, - {file = "libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532"}, - {file = "libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64"}, - {file = "libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b"}, - {file = "libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f"}, - {file = "libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c"}, - {file = "libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9"}, - {file = "libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09"}, - {file = "libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d"}, - {file = "libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5"}, - {file = "libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1"}, - {file = "libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86"}, - {file = "libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d"}, - {file = "libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7"}, - {file = "libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb"}, - {file = "libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196"}, - {file = "libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105"}, - {file = "libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d"}, - {file = "libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786"}, - {file = "libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30"}, - {file = "libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde"}, - {file = "libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf"}, - {file = "libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e"}, - {file = "libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58"}, - {file = "libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f"}, - {file = "libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93"}, - {file = "libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012"}, - {file = "libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4"}, - {file = "libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330"}, - {file = "libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42"}, - {file = "libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c"}, - {file = "libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661"}, - {file = "libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474"}, - {file = "libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8"}, - {file = "libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a"}, - {file = "libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47"}, - {file = "libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4"}, - {file = "libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9"}, - {file = "libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1"}, - {file = "libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4"}, - {file = "libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28"}, - {file = "libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa"}, - {file = "libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1"}, - {file = "libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996"}, - {file = "libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82"}, - {file = "libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f"}, - {file = "libcst-1.8.6-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:cb2679ef532f9fa5be5c5a283b6357cb6e9888a8dd889c4bb2b01845a29d8c0b"}, - {file = "libcst-1.8.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:203ec2a83f259baf686b9526268cd23d048d38be5589594ef143aee50a4faf7e"}, - {file = "libcst-1.8.6-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6366ab2107425bf934b0c83311177f2a371bfc757ee8c6ad4a602d7cbcc2f363"}, - {file = "libcst-1.8.6-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:6aa11df6c58812f731172b593fcb485d7ba09ccc3b52fea6c7f26a43377dc748"}, - {file = "libcst-1.8.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:351ab879c2fd20d9cb2844ed1ea3e617ed72854d3d1e2b0880ede9c3eea43ba8"}, - {file = "libcst-1.8.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:98fa1ca321c81fb1f02e5c43f956ca543968cc1a30b264fd8e0a2e1b0b0bf106"}, - {file = "libcst-1.8.6-cp39-cp39-win_amd64.whl", hash = "sha256:25fc7a1303cad7639ad45ec38c06789b4540b7258e9a108924aaa2c132af4aca"}, - {file = "libcst-1.8.6-cp39-cp39-win_arm64.whl", hash = "sha256:4d7bbdd35f3abdfb5ac5d1a674923572dab892b126a58da81ff2726102d6ec2e"}, - {file = "libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b"}, -] - -[package.dependencies] -pyyaml = [ - {version = ">=5.2", markers = "python_version < \"3.13\""}, - {version = ">=6.0.3", markers = "python_version >= \"3.14\""}, -] -pyyaml-ft = {version = ">=8.0.0", markers = "python_version == \"3.13\""} - -[[package]] -name = "librt" -version = "0.9.0" -description = "Mypyc runtime library" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "platform_python_implementation != \"PyPy\"" -files = [ - {file = "librt-0.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f8e12706dcb8ff6b3ed57514a19e45c49ad00bcd423e87b2b2e4b5f64578443"}, - {file = "librt-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e3dda8345307fd7306db0ed0cb109a63a2c85ba780eb9dc2d09b2049a931f9c"}, - {file = "librt-0.9.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:de7dac64e3eb832ffc7b840eb8f52f76420cde1b845be51b2a0f6b870890645e"}, - {file = "librt-0.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22a904cbdb678f7cb348c90d543d3c52f581663d687992fee47fd566dcbf5285"}, - {file = "librt-0.9.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:224b9727eb8bc188bc3bcf29d969dba0cd61b01d9bac80c41575520cc4baabb2"}, - {file = "librt-0.9.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e94cbc6ad9a6aeea46d775cbb11f361022f778a9cc8cc90af653d3a594b057ce"}, - {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7bc30ad339f4e1a01d4917d645e522a0bc0030644d8973f6346397c93ba1503f"}, - {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:56d65b583cf43b8cf4c8fbe1e1da20fa3076cc32a1149a141507af1062718236"}, - {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0a1be03168b2691ba61927e299b352a6315189199ca18a57b733f86cb3cc8d38"}, - {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:63c12efcd160e1d14da11af0c46c0217473e1e0d2ae1acbccc83f561ea4c2a7b"}, - {file = "librt-0.9.0-cp310-cp310-win32.whl", hash = "sha256:e9002e98dcb1c0a66723592520decd86238ddcef168b37ff6cfb559200b4b774"}, - {file = "librt-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:9fcb461fbf70654a52a7cc670e606f04449e2374c199b1825f754e16dacfedd8"}, - {file = "librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671"}, - {file = "librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d"}, - {file = "librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6"}, - {file = "librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1"}, - {file = "librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882"}, - {file = "librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990"}, - {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4"}, - {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb"}, - {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076"}, - {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a"}, - {file = "librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6"}, - {file = "librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8"}, - {file = "librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a"}, - {file = "librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4"}, - {file = "librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d"}, - {file = "librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f"}, - {file = "librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27"}, - {file = "librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2"}, - {file = "librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b"}, - {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265"}, - {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084"}, - {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8"}, - {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f"}, - {file = "librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f"}, - {file = "librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745"}, - {file = "librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9"}, - {file = "librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e"}, - {file = "librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22"}, - {file = "librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a"}, - {file = "librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5"}, - {file = "librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11"}, - {file = "librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858"}, - {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e"}, - {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0"}, - {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2"}, - {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d"}, - {file = "librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd"}, - {file = "librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519"}, - {file = "librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5"}, - {file = "librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb"}, - {file = "librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499"}, - {file = "librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f"}, - {file = "librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1"}, - {file = "librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f"}, - {file = "librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a"}, - {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f"}, - {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845"}, - {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b"}, - {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b"}, - {file = "librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9"}, - {file = "librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e"}, - {file = "librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f"}, - {file = "librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4"}, - {file = "librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228"}, - {file = "librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54"}, - {file = "librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71"}, - {file = "librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938"}, - {file = "librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3"}, - {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283"}, - {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee"}, - {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c"}, - {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15"}, - {file = "librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40"}, - {file = "librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118"}, - {file = "librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61"}, - {file = "librt-0.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5112c2fb7c2eefefaeaf5c97fec81343ef44ee86a30dcfaa8223822fba6467b4"}, - {file = "librt-0.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a81eea9b999b985e4bacc650c4312805ea7008fd5e45e1bf221310176a7bcb3a"}, - {file = "librt-0.9.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eea1b54943475f51698f85fa230c65ccac769f1e603b981be060ac5763d90927"}, - {file = "librt-0.9.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81107843ed1836874b46b310f9b1816abcb89912af627868522461c3b7333c0f"}, - {file = "librt-0.9.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa95738a68cedd3a6f5492feddc513e2e166b50602958139e47bbdd82da0f5a7"}, - {file = "librt-0.9.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6788207daa0c19955d2b668f3294a368d19f67d9b5f274553fd073c1260cbb9f"}, - {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f48c963a76d71b9d7927eb817b543d0dccd52ab6648b99d37bd54f4cd475d856"}, - {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:42ff8a962554c350d4a83cf47d9b7b78b0e6ff7943e87df7cdfc97c07f3c016f"}, - {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:657f8ba7b9eaaa82759a104137aed2a3ef7bc46ccfd43e0d89b04005b3e0a4cc"}, - {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d03fa4fd277a7974c1978c92c374c57f44edeee163d147b477b143446ad1bf6"}, - {file = "librt-0.9.0-cp39-cp39-win32.whl", hash = "sha256:d9da80e5b04acce03ced8ba6479a71c2a2edf535c2acc0d09c80d2f80f3bad15"}, - {file = "librt-0.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:54d412e47c21b85865676ed0724e37a89e9593c2eee1e7367adf85bfad56ffb1"}, - {file = "librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d"}, -] - -[[package]] -name = "markdown" -version = "3.10.2" -description = "Python implementation of John Gruber's Markdown." -optional = false -python-versions = ">=3.10" -groups = ["docs"] -files = [ - {file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"}, - {file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"}, -] - -[package.extras] -docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"] -testing = ["coverage", "pyyaml"] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.10" -groups = ["docs"] -files = [ - {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, - {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins (>=0.5.0)"] -profiling = ["gprof2dot"] -rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] - -[[package]] -name = "markupsafe" -version = "3.0.3" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, - {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, - {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, - {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, - {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, - {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, - {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, - {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, - {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -groups = ["docs"] -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "mergedeep" -version = "1.3.4" -description = "A deep merge function for 🐍." -optional = false -python-versions = ">=3.6" -groups = ["docs"] -files = [ - {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, - {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, -] - -[[package]] -name = "mike" -version = "2.2.0" -description = "Manage multiple versions of your MkDocs-powered documentation" -optional = false -python-versions = "*" -groups = ["docs"] -files = [ - {file = "mike-2.2.0-py3-none-any.whl", hash = "sha256:e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040"}, - {file = "mike-2.2.0.tar.gz", hash = "sha256:1e3858e32c0f125aac14432fc7848434358f9ae0962c5c5cde387ad47f6ad25e"}, -] - -[package.dependencies] -jinja2 = ">=2.7" -mkdocs = ">=1.0,<2.0" -pyparsing = ">=3.0" -pyyaml = ">=5.1" -pyyaml-env-tag = "*" -verspec = "*" - -[package.extras] -dev = ["coverage", "flake8 (>=3.0)", "flake8-quotes", "shtab"] -test = ["coverage", "flake8 (>=3.0)", "flake8-quotes", "shtab"] - -[[package]] -name = "mkdocs" -version = "1.6.1" -description = "Project documentation with Markdown." -optional = false -python-versions = ">=3.8" -groups = ["docs"] -files = [ - {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, - {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, -] - -[package.dependencies] -click = ">=7.0" -colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} -ghp-import = ">=1.0" -jinja2 = ">=2.11.1" -markdown = ">=3.3.6" -markupsafe = ">=2.0.1" -mergedeep = ">=1.3.4" -mkdocs-get-deps = ">=0.2.0" -packaging = ">=20.5" -pathspec = ">=0.11.1" -pyyaml = ">=5.1" -pyyaml-env-tag = ">=0.1" -watchdog = ">=2.0" - -[package.extras] -i18n = ["babel (>=2.9.0)"] -min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] - -[[package]] -name = "mkdocs-autorefs" -version = "1.4.4" -description = "Automatically link across pages in MkDocs." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089"}, - {file = "mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197"}, -] - -[package.dependencies] -Markdown = ">=3.3" -markupsafe = ">=2.0.1" -mkdocs = ">=1.1" - -[[package]] -name = "mkdocs-awesome-pages-plugin" -version = "2.10.1" -description = "An MkDocs plugin that simplifies configuring page titles and their order" -optional = false -python-versions = ">=3.8.1" -groups = ["docs"] -files = [ - {file = "mkdocs_awesome_pages_plugin-2.10.1-py3-none-any.whl", hash = "sha256:c6939dbea37383fc3cf8c0a4e892144ec3d2f8a585e16fdc966b34e7c97042a7"}, - {file = "mkdocs_awesome_pages_plugin-2.10.1.tar.gz", hash = "sha256:cda2cb88c937ada81a4785225f20ef77ce532762f4500120b67a1433c1cdbb2f"}, -] - -[package.dependencies] -mkdocs = ">=1" -natsort = ">=8.1.0" -wcmatch = ">=7" - -[[package]] -name = "mkdocs-gen-files" -version = "0.6.1" -description = "MkDocs plugin to programmatically generate documentation pages during the build" -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "mkdocs_gen_files-0.6.1-py3-none-any.whl", hash = "sha256:b3182bfc6219e35b8d26658cb988368659d5d023aac30c2a819247558fc12189"}, - {file = "mkdocs_gen_files-0.6.1.tar.gz", hash = "sha256:57d7ff2229e23d077e46d14a33db6d37c8823f6ce1a503c874c1764a71679763"}, -] - -[package.dependencies] -mkdocs = ">=1.4.1,<=1.6.1" -properdocs = ">=1.6.5" - -[[package]] -name = "mkdocs-get-deps" -version = "0.2.2" -description = "An extra command for MkDocs that infers required PyPI packages from `plugins` in mkdocs.yml" -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650"}, - {file = "mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1"}, -] - -[package.dependencies] -mergedeep = ">=1.3.4" -platformdirs = ">=2.2.0" -pyyaml = ">=5.1" - -[[package]] -name = "mkdocs-include-markdown-plugin" -version = "7.2.2" -description = "Mkdocs Markdown includer plugin." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "mkdocs_include_markdown_plugin-7.2.2-py3-none-any.whl", hash = "sha256:f2ec4487cf32d3e33ca528f9366f20fb9280ded9c8d1630eb2bbda244962dcd1"}, - {file = "mkdocs_include_markdown_plugin-7.2.2.tar.gz", hash = "sha256:f052ccb741eccf498116b826c1d78a2d761c56747372594709441cee0963fbc9"}, -] - -[package.dependencies] -mkdocs = ">=1.4" -wcmatch = "*" - -[package.extras] -cache = ["platformdirs"] - -[[package]] -name = "mkdocs-literate-nav" -version = "0.6.3" -description = "MkDocs plugin to specify the navigation in Markdown instead of YAML" -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "mkdocs_literate_nav-0.6.3-py3-none-any.whl", hash = "sha256:2c421561280fa9184f88cbf399bebbd4cc17ee507e978a31ce11fd6f3aabf233"}, - {file = "mkdocs_literate_nav-0.6.3.tar.gz", hash = "sha256:edbaca22343f861fe4e34aac47d55a0c9955c640dbf02eea99fe631e914cf9ee"}, -] - -[package.dependencies] -mkdocs = ">=1.4.1,<=1.6.1" -properdocs = ">=1.6.5" - -[[package]] -name = "mkdocs-material" -version = "9.7.6" -description = "Documentation that simply works" -optional = false -python-versions = ">=3.8" -groups = ["docs"] -files = [ - {file = "mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba"}, - {file = "mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69"}, -] - -[package.dependencies] -babel = ">=2.10" -backrefs = ">=5.7.post1" -colorama = ">=0.4" -jinja2 = ">=3.1" -markdown = ">=3.2" -mkdocs = ">=1.6,<2" -mkdocs-material-extensions = ">=1.3" -paginate = ">=0.5" -pygments = ">=2.16" -pymdown-extensions = ">=10.2" -requests = ">=2.30" - -[package.extras] -git = ["mkdocs-git-committers-plugin-2 (>=1.1)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4)"] -imaging = ["cairosvg (>=2.6)", "pillow (>=10.2)"] -recommended = ["mkdocs-minify-plugin (>=0.7)", "mkdocs-redirects (>=1.2)", "mkdocs-rss-plugin (>=1.6)"] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -description = "Extension pack for Python Markdown and MkDocs Material." -optional = false -python-versions = ">=3.8" -groups = ["docs"] -files = [ - {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, - {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, -] - -[[package]] -name = "mkdocstrings" -version = "1.0.4" -description = "Automatic documentation from sources, for MkDocs." -optional = false -python-versions = ">=3.10" -groups = ["docs"] -files = [ - {file = "mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b"}, - {file = "mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172"}, -] - -[package.dependencies] -Jinja2 = ">=3.1" -Markdown = ">=3.6" -MarkupSafe = ">=1.1" -mkdocs = ">=1.6" -mkdocs-autorefs = ">=1.4" -mkdocstrings-python = {version = ">=1.16.2", optional = true, markers = "extra == \"python\""} -pymdown-extensions = ">=6.3" - -[package.extras] -crystal = ["mkdocstrings-crystal (>=0.3.4)"] -python = ["mkdocstrings-python (>=1.16.2)"] -python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] - -[[package]] -name = "mkdocstrings-python" -version = "2.0.3" -description = "A Python handler for mkdocstrings." -optional = false -python-versions = ">=3.10" -groups = ["docs"] -files = [ - {file = "mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12"}, - {file = "mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8"}, -] - -[package.dependencies] -griffelib = ">=2.0" -mkdocs-autorefs = ">=1.4" -mkdocstrings = ">=0.30" - -[[package]] -name = "mktestdocs" -version = "0.2.5" -description = "A tool for testing markdown documentation" -optional = false -python-versions = ">=3.8" -groups = ["docs"] -files = [ - {file = "mktestdocs-0.2.5-py3-none-any.whl", hash = "sha256:2b20d2387b2025597f3129773edef16d09b08b1b6d31da6cc51af5b693c10430"}, - {file = "mktestdocs-0.2.5.tar.gz", hash = "sha256:7935d4e665b34b690cdf14749abb842723652485f67af12dce51099bcb98e105"}, -] - -[package.extras] -test = ["pytest"] - -[[package]] -name = "moreorless" -version = "0.5.0" -description = "Python diff wrapper" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "moreorless-0.5.0-py3-none-any.whl", hash = "sha256:66228870cd2f14bad5c3c3780aa71e29d3b2d9b5a01c03bfbf105efd4f668ecf"}, - {file = "moreorless-0.5.0.tar.gz", hash = "sha256:560a04f85006fccd74feaa4b6213a446392ff7b5ec0194a5464b6c30f182fa33"}, -] - -[package.dependencies] -click = "*" - -[[package]] -name = "mypy" -version = "1.20.1" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "mypy-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3ba5d1e712ada9c3b6223dcbc5a31dac334ed62991e5caa17bcf5a4ddc349af0"}, - {file = "mypy-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e731284c117b0987fb1e6c5013a56f33e7faa1fce594066ab83876183ce1c66"}, - {file = "mypy-1.20.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e945b872a05f4fbefabe2249c0b07b6b194e5e11a86ebee9edf855de09806c"}, - {file = "mypy-1.20.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fc88acef0dc9b15246502b418980478c1bfc9702057a0e1e7598d01a7af8937"}, - {file = "mypy-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:14911a115c73608f155f648b978c5055d16ff974e6b1b5512d7fedf4fa8b15c6"}, - {file = "mypy-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:76d9b4c992cca3331d9793ef197ae360ea44953cf35beb2526e95b9e074f2866"}, - {file = "mypy-1.20.1-cp310-cp310-win_arm64.whl", hash = "sha256:b408722f80be44845da555671a5ef3a0c63f51ca5752b0c20e992dc9c0fbd3cd"}, - {file = "mypy-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c01eb9bac2c6a962d00f9d23421cd2913840e65bba365167d057bd0b4171a92e"}, - {file = "mypy-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55d12ddbd8a9cac5b276878bd534fa39fff5bf543dc6ae18f25d30c8d7d27fca"}, - {file = "mypy-1.20.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0aa322c1468b6cdfc927a44ce130f79bb44bcd34eb4a009eb9f96571fd80955"}, - {file = "mypy-1.20.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f8bc95899cf676b6e2285779a08a998cc3a7b26f1026752df9d2741df3c79e8"}, - {file = "mypy-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:47c2b90191a870a04041e910277494b0d92f0711be9e524d45c074fe60c00b65"}, - {file = "mypy-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:9857dc8d2ec1a392ffbda518075beb00ac58859979c79f9e6bdcb7277082c2f2"}, - {file = "mypy-1.20.1-cp311-cp311-win_arm64.whl", hash = "sha256:09d8df92bb25b6065ab91b178da843dda67b33eb819321679a6e98a907ce0e10"}, - {file = "mypy-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:36ee2b9c6599c230fea89bbd79f401f9f9f8e9fcf0c777827789b19b7da90f51"}, - {file = "mypy-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fba3fb0968a7b48806b0c90f38d39296f10766885a94c83bd21399de1e14eb28"}, - {file = "mypy-1.20.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef1415a637cd3627d6304dfbeddbadd21079dafc2a8a753c477ce4fc0c2af54f"}, - {file = "mypy-1.20.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef3461b1ad5cd446e540016e90b5984657edda39f982f4cc45ca317b628f5a37"}, - {file = "mypy-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:542dd63c9e1339b6092eb25bd515f3a32a1453aee8c9521d2ddb17dacd840237"}, - {file = "mypy-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:1d55c7cd8ca22e31f93af2a01160a9e95465b5878de23dba7e48116052f20a8d"}, - {file = "mypy-1.20.1-cp312-cp312-win_arm64.whl", hash = "sha256:f5b84a79070586e0d353ee07b719d9d0a4aa7c8ee90c0ea97747e98cbe193019"}, - {file = "mypy-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f3886c03e40afefd327bd70b3f634b39ea82e87f314edaa4d0cce4b927ddcc1"}, - {file = "mypy-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e860eb3904f9764e83bafd70c8250bdffdc7dde6b82f486e8156348bf7ceb184"}, - {file = "mypy-1.20.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4b5aac6e785719da51a84f5d09e9e843d473170a9045b1ea7ea1af86225df4b"}, - {file = "mypy-1.20.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f37b6cd0fe2ad3a20f05ace48ca3523fc52ff86940e34937b439613b6854472e"}, - {file = "mypy-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4bbb0f6b54ce7cc350ef4a770650d15fa70edd99ad5267e227133eda9c94218"}, - {file = "mypy-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:c3dc20f8ec76eecd77148cdd2f1542ed496e51e185713bf488a414f862deb8f2"}, - {file = "mypy-1.20.1-cp313-cp313-win_arm64.whl", hash = "sha256:a9d62bbac5d6d46718e2b0330b25e6264463ed832722b8f7d4440ff1be3ca895"}, - {file = "mypy-1.20.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:12927b9c0ed794daedcf1dab055b6c613d9d5659ac511e8d936d96f19c087d12"}, - {file = "mypy-1.20.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:752507dd481e958b2c08fc966d3806c962af5a9433b5bf8f3bdd7175c20e34fe"}, - {file = "mypy-1.20.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c614655b5a065e56274c6cbbe405f7cf7e96c0654db7ba39bc680238837f7b08"}, - {file = "mypy-1.20.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c3f6221a76f34d5100c6d35b3ef6b947054123c3f8d6938a4ba00b1308aa572"}, - {file = "mypy-1.20.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4bdfc06303ac06500af71ea0cdbe995c502b3c9ba32f3f8313523c137a25d1b6"}, - {file = "mypy-1.20.1-cp314-cp314-win_amd64.whl", hash = "sha256:0131edd7eba289973d1ba1003d1a37c426b85cdef76650cd02da6420898a5eb3"}, - {file = "mypy-1.20.1-cp314-cp314-win_arm64.whl", hash = "sha256:33f02904feb2c07e1fdf7909026206396c9deeb9e6f34d466b4cfedb0aadbbe4"}, - {file = "mypy-1.20.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:168472149dd8cc505c98cefd21ad77e4257ed6022cd5ed2fe2999bed56977a5a"}, - {file = "mypy-1.20.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eb674600309a8f22790cca883a97c90299f948183ebb210fbef6bcee07cb1986"}, - {file = "mypy-1.20.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef2b2e4cc464ba9795459f2586923abd58a0055487cbe558cb538ea6e6bc142a"}, - {file = "mypy-1.20.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee461d396dd46b3f0ed5a098dbc9b8860c81c46ad44fa071afcfbc149f167c9"}, - {file = "mypy-1.20.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e364926308b3e66f1361f81a566fc1b2f8cd47fc8525e8136d4058a65a4b4f02"}, - {file = "mypy-1.20.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a0c17fbd746d38c70cbc42647cfd884f845a9708a4b160a8b4f7e70d41f4d7fa"}, - {file = "mypy-1.20.1-cp314-cp314t-win_arm64.whl", hash = "sha256:db2cb89654626a912efda69c0d5c1d22d948265e2069010d3dde3abf751c7d08"}, - {file = "mypy-1.20.1-py3-none-any.whl", hash = "sha256:1aae28507f253fe82d883790d1c0a0d35798a810117c88184097fe8881052f06"}, - {file = "mypy-1.20.1.tar.gz", hash = "sha256:6fc3f4ecd52de81648fed1945498bf42fa2993ddfad67c9056df36ae5757f804"}, -] - -[package.dependencies] -librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""} -mypy_extensions = ">=1.0.0" -pathspec = ">=1.0.0" -typing_extensions = ">=4.6.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -faster-cache = ["orjson"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -native-parser = ["ast-serialize (>=0.1.1,<1.0.0)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, - {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, -] - -[[package]] -name = "natsort" -version = "8.4.0" -description = "Simple yet flexible natural sorting in Python." -optional = false -python-versions = ">=3.7" -groups = ["docs"] -files = [ - {file = "natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c"}, - {file = "natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581"}, -] - -[package.extras] -fast = ["fastnumbers (>=2.0.0)"] -icu = ["PyICU (>=1.0.0)"] - -[[package]] -name = "packaging" -version = "26.1" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["dev", "docs"] -files = [ - {file = "packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f"}, - {file = "packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de"}, -] - -[[package]] -name = "paginate" -version = "0.5.7" -description = "Divides large result sets into pages for easier browsing" -optional = false -python-versions = "*" -groups = ["docs"] -files = [ - {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"}, - {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"}, -] - -[package.extras] -dev = ["pytest", "tox"] -lint = ["black"] - -[[package]] -name = "pathspec" -version = "1.0.4" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.9" -groups = ["dev", "docs"] -files = [ - {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, - {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, -] - -[package.extras] -hyperscan = ["hyperscan (>=0.7)"] -optional = ["typing-extensions (>=4)"] -re2 = ["google-re2 (>=1.1)"] -tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] - -[[package]] -name = "platformdirs" -version = "4.9.6" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.10" -groups = ["docs"] -files = [ - {file = "platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"}, - {file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"}, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - -[[package]] -name = "properdocs" -version = "1.6.7" -description = "Project documentation with Markdown." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "properdocs-1.6.7-py3-none-any.whl", hash = "sha256:6fa0cfa2e01bf338f684892c8a506cf70ea88ae7f3479c933b6fa20168101cbd"}, - {file = "properdocs-1.6.7.tar.gz", hash = "sha256:adc7b16e562890af0e098a7e5b02e3a81c20894a87d6a28d345c9300de73c26e"}, -] - -[package.dependencies] -click = ">=7.0" -colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} -ghp-import = ">=1.0" -jinja2 = ">=2.11.1" -markdown = ">=3.3.6" -markupsafe = ">=2.0.1" -packaging = ">=20.5" -pathspec = ">=0.11.1" -platformdirs = ">=2.2.0" -pyyaml = ">=5.1" -pyyaml-env-tag = ">=0.1" -watchdog = ">=2.0" - -[package.extras] -i18n = ["babel (>=2.9.0)"] - -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["docs"] -files = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, -] - -[[package]] -name = "pydocstyle" -version = "6.3.0" -description = "Python docstring style checker" -optional = false -python-versions = ">=3.6" -groups = ["docs"] -files = [ - {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, - {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, -] - -[package.dependencies] -snowballstemmer = ">=2.2.0" - -[package.extras] -toml = ["tomli (>=1.2.3) ; python_version < \"3.11\""] - -[[package]] -name = "pygments" -version = "2.20.0" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.9" -groups = ["dev", "docs"] -files = [ - {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, - {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pymdown-extensions" -version = "10.21.2" -description = "Extension pack for Python Markdown." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638"}, - {file = "pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc"}, -] - -[package.dependencies] -markdown = ">=3.6" -pyyaml = "*" - -[package.extras] -extra = ["pygments (>=2.19.1)"] - -[[package]] -name = "pyparsing" -version = "3.3.2" -description = "pyparsing - Classes and methods to define and execute parsing grammars" -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d"}, - {file = "pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "pytest" -version = "9.0.3" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, - {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, -] - -[package.dependencies] -colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1.0.1" -packaging = ">=22" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, - {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, -] - -[package.dependencies] -pytest = ">=8.2,<10" -typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["docs"] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "pyyaml" -version = "6.0.3" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["dev", "docs"] -files = [ - {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, - {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, - {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, - {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, - {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, - {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, - {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, - {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, - {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, - {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, - {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, - {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, - {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, -] -markers = {dev = "python_version == \"3.12\" or python_version >= \"3.14\""} - -[[package]] -name = "pyyaml-env-tag" -version = "1.1" -description = "A custom YAML tag for referencing environment variables in YAML files." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04"}, - {file = "pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff"}, -] - -[package.dependencies] -pyyaml = "*" - -[[package]] -name = "pyyaml-ft" -version = "8.0.0" -description = "YAML parser and emitter for Python with support for free-threading" -optional = false -python-versions = ">=3.13" -groups = ["dev"] -markers = "python_version == \"3.13\"" -files = [ - {file = "pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255"}, - {file = "pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793"}, - {file = "pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab"}, -] - -[[package]] -name = "requests" -version = "2.33.1" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.10" -groups = ["docs"] -files = [ - {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, - {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, -] - -[package.dependencies] -certifi = ">=2023.5.7" -charset_normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.26,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] - -[[package]] -name = "respx" -version = "0.22.0" -description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0"}, - {file = "respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91"}, -] - -[package.dependencies] -httpx = ">=0.25.0" - -[[package]] -name = "rich" -version = "15.0.0" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.9.0" -groups = ["docs"] -files = [ - {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, - {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "ruff" -version = "0.12.12" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc"}, - {file = "ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727"}, - {file = "ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb"}, - {file = "ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577"}, - {file = "ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e"}, - {file = "ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e"}, - {file = "ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8"}, - {file = "ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5"}, - {file = "ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92"}, - {file = "ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45"}, - {file = "ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5"}, - {file = "ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4"}, - {file = "ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23"}, - {file = "ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489"}, - {file = "ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee"}, - {file = "ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1"}, - {file = "ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d"}, - {file = "ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093"}, - {file = "ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6"}, -] - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["docs"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "snowballstemmer" -version = "3.0.1" -description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*" -groups = ["docs"] -files = [ - {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"}, - {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"}, -] - -[[package]] -name = "stevedore" -version = "5.7.0" -description = "Manage dynamic plugins for Python applications" -optional = false -python-versions = ">=3.10" -groups = ["docs"] -files = [ - {file = "stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed"}, - {file = "stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3"}, -] - -[[package]] -name = "tabulate" -version = "0.10.0" -description = "Pretty-print tabular data" -optional = false -python-versions = ">=3.10" -groups = ["docs"] -files = [ - {file = "tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3"}, - {file = "tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d"}, -] - -[package.extras] -widechars = ["wcwidth"] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, -] -markers = {main = "python_version == \"3.12\""} - -[[package]] -name = "urllib3" -version = "2.6.3" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, - {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, -] - -[package.extras] -brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] - -[[package]] -name = "verspec" -version = "0.1.0" -description = "Flexible version handling" -optional = false -python-versions = "*" -groups = ["docs"] -files = [ - {file = "verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31"}, - {file = "verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e"}, -] - -[package.extras] -test = ["coverage", "flake8 (>=3.7)", "mypy", "pretend", "pytest"] - -[[package]] -name = "volatile" -version = "2.1.0" -description = "A small extension for the tempfile module." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "volatile-2.1.0.tar.gz", hash = "sha256:9be36ad508e3354e016c115de0397dc2203b9800a73d9d177ca9d37a8d3a31d3"}, -] - -[[package]] -name = "watchdog" -version = "6.0.0" -description = "Filesystem events monitoring" -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, - {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, - {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, - {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, - {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, - {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, - {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, - {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, - {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, - {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, - {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, - {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, - {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, - {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, - {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, - {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, - {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, - {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, - {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, - {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, - {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, - {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, - {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, -] - -[package.extras] -watchmedo = ["PyYAML (>=3.10)"] - -[[package]] -name = "wcmatch" -version = "10.1" -description = "Wildcard/glob file name matcher." -optional = false -python-versions = ">=3.9" -groups = ["docs"] -files = [ - {file = "wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a"}, - {file = "wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af"}, -] - -[package.dependencies] -bracex = ">=2.1.1" - -[metadata] -lock-version = "2.1" -python-versions = ">=3.12,<4.0" -content-hash = "2700535ead0e54ad8fd11c01b4c2b13c6a2afdcf9936f662984fdfaae89d9ad3"